Merge branch 'stable-3.3'

* stable-3.3:
  RenameEmail: Preserve files which are not updated

Signed-off-by: Edwin Kempin <ekempin@google.com>
Change-Id: I1bff81db7d1e7aa55ae83c1b4e2c060611f429f0
diff --git a/BUILD b/BUILD
index 578af9d..5333141 100644
--- a/BUILD
+++ b/BUILD
@@ -9,52 +9,24 @@
     "//tools/bzl:plugin.bzl",
     "gerrit_plugin",
 )
-load("//tools/bzl:genrule2.bzl", "genrule2")
-load("//tools/bzl:js.bzl", "polygerrit_plugin")
-load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
 
 gerrit_plugin(
     name = "code-owners",
-    srcs = glob(["java/com/google/gerrit/plugins/codeowners/**/*.java"]),
+    srcs = glob(["java/com/google/gerrit/plugins/codeowners/module/*.java"]),
     manifest_entries = [
         "Gerrit-PluginName: code-owners",
-        "Gerrit-Module: com.google.gerrit.plugins.codeowners.Module",
-        "Gerrit-HttpModule: com.google.gerrit.plugins.codeowners.HttpModule",
+        "Gerrit-Module: com.google.gerrit.plugins.codeowners.module.Module",
+        "Gerrit-HttpModule: com.google.gerrit.plugins.codeowners.module.HttpModule",
+        "Gerrit-BatchModule: com.google.gerrit.plugins.codeowners.module.BatchModule",
     ],
-    resource_jars = [":code-owners-fe-static"],
+    resource_jars = ["//plugins/code-owners/ui:code-owners"],
     resource_strip_prefix = "plugins/code-owners/resources",
     resources = glob(["resources/**/*"]),
-    deps = ["//plugins/code-owners/proto:owners_metadata_java_proto"],
-)
-
-polygerrit_plugin(
-    name = "code-owners-fe",
-    app = "plugin-bundle.js",
-    plugin_name = "code-owners",
-)
-
-rollup_bundle(
-    name = "plugin-bundle",
-    srcs = glob([
-        "ui/**/*.js",
-    ]),
-    entry_point = "ui/plugin.js",
-    format = "iife",
-    rollup_bin = "//tools/node_tools:rollup-bin",
-    sourcemap = "hidden",
     deps = [
-        "@tools_npm//rollup-plugin-node-resolve",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/api/impl",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/backend",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/common",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/restapi",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/validation",
     ],
 )
-
-genrule2(
-    name = "code-owners-fe-static",
-    srcs = [":code-owners-fe"],
-    outs = ["code-owners-fe-static.jar"],
-    cmd = " && ".join([
-        "mkdir $$TMP/static",
-        "cp -r $(locations :code-owners-fe) $$TMP/static",
-        "cd $$TMP",
-        "zip -Drq $$ROOT/$@ -g .",
-    ]),
-)
\ No newline at end of file
diff --git a/README.md b/README.md
index 26e2c93..02330f1 100644
--- a/README.md
+++ b/README.md
@@ -5,8 +5,7 @@
 If the code-owners plugin is enabled, changes can only be submitted if all
 touched files are covered by approvals from code owners.
 
-Also see resources/Documentation/about.md
+Also see [resources/Documentation/about.md](./resources/Documentation/about.md).
 
 IMPORTANT: Before installing/enabling the plugin follow the instructions from
-the setup guide, see resources/Documentation/setup-guide.md
-
+the setup guide, see [resources/Documentation/setup-guide.md](./resources/Documentation/setup-guide.md).
diff --git a/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersIT.java b/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersIT.java
index da0b172..e42501d 100644
--- a/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersIT.java
+++ b/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersIT.java
@@ -15,15 +15,17 @@
 package com.google.gerrit.plugins.codeowners.acceptance;
 
 import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.common.truth.TruthJUnit.assume;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperations;
-import com.google.gerrit.plugins.codeowners.api.ChangeCodeOwnersFactory;
-import com.google.gerrit.plugins.codeowners.api.CodeOwnerConfigsFactory;
-import com.google.gerrit.plugins.codeowners.api.CodeOwnersFactory;
-import com.google.gerrit.plugins.codeowners.api.ProjectCodeOwnersFactory;
+import com.google.gerrit.plugins.codeowners.api.impl.ChangeCodeOwnersFactory;
+import com.google.gerrit.plugins.codeowners.api.impl.CodeOwnerConfigsFactory;
+import com.google.gerrit.plugins.codeowners.api.impl.CodeOwnersFactory;
+import com.google.gerrit.plugins.codeowners.api.impl.ProjectCodeOwnersFactory;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId;
-import com.google.gerrit.plugins.codeowners.config.BackendConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.BackendConfig;
+import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend;
 import com.google.gerrit.testing.ConfigSuite;
 import java.util.Arrays;
 import org.eclipse.jgit.lib.Config;
@@ -76,6 +78,8 @@
   protected ChangeCodeOwnersFactory changeCodeOwnersApiFactory;
   protected ProjectCodeOwnersFactory projectCodeOwnersApiFactory;
 
+  private BackendConfig backendConfig;
+
   @Before
   public void baseSetup() throws Exception {
     codeOwnerConfigOperations =
@@ -85,5 +89,20 @@
     changeCodeOwnersApiFactory = plugin.getSysInjector().getInstance(ChangeCodeOwnersFactory.class);
     projectCodeOwnersApiFactory =
         plugin.getSysInjector().getInstance(ProjectCodeOwnersFactory.class);
+    backendConfig = plugin.getSysInjector().getInstance(BackendConfig.class);
+  }
+
+  protected void skipTestIfImportsNotSupportedByCodeOwnersBackend() {
+    // the proto backend doesn't support imports
+    assumeThatCodeOwnersBackendIsNotProtoBackend();
+  }
+
+  protected void skipTestIfIgnoreParentCodeOwnersNotSupportedByCodeOwnersBackend() {
+    // the proto backend doesn't support ignoring parent code owners
+    assumeThatCodeOwnersBackendIsNotProtoBackend();
+  }
+
+  protected void assumeThatCodeOwnersBackendIsNotProtoBackend() {
+    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersTest.java b/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersTest.java
index 0ed3041..1309568 100644
--- a/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersTest.java
+++ b/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersTest.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.plugins.codeowners.acceptance;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.collect.ImmutableList;
@@ -34,11 +35,15 @@
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
 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.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
+import com.google.gerrit.plugins.codeowners.backend.config.BackendConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.StatusConfig;
+import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
+import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.inject.Inject;
 import java.nio.file.Path;
 import java.util.Map;
@@ -67,11 +72,13 @@
   @Inject private ProjectOperations projectOperations;
 
   private CodeOwnerConfigOperations codeOwnerConfigOperations;
+  private BackendConfig backendConfig;
 
   @Before
   public void testSetup() throws Exception {
     codeOwnerConfigOperations =
         plugin.getSysInjector().getInstance(CodeOwnerConfigOperations.class);
+    backendConfig = plugin.getSysInjector().getInstance(BackendConfig.class);
   }
 
   protected String createChangeWithFileDeletion(Path filePath) throws Exception {
@@ -168,7 +175,7 @@
                 CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS, subsection, key, values));
   }
 
-  private void updateCodeOwnersConfig(Project.NameKey project, Consumer<Config> configUpdater)
+  protected void updateCodeOwnersConfig(Project.NameKey project, Consumer<Config> configUpdater)
       throws Exception {
     Config codeOwnersConfig = new Config();
     configUpdater.accept(codeOwnersConfig);
@@ -193,6 +200,7 @@
 
   protected void createOwnersOverrideLabel(String labelName) throws RestApiException {
     LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = "NoOp";
     input.values = ImmutableMap.of("+1", "Override", " 0", "No Override");
     gApi.projects().name(project.get()).label(labelName).create(input).get();
 
@@ -210,27 +218,6 @@
   }
 
   /**
-   * Creates an arbitrary code owner config file.
-   *
-   * <p>Can be used to create an arbitrary code owner config in order to avoid entering the
-   * bootstrapping code path in {@link
-   * com.google.gerrit.plugins.codeowners.backend.CodeOwnerApprovalCheck}.
-   */
-  protected void createArbitraryCodeOwnerConfigFile() throws Exception {
-    TestAccount arbitraryUser =
-        accountCreator.create(
-            "arbitrary-user", "arbitrary-user@example.com", "Arbitrary User", null);
-
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/arbitrary/path/")
-        .addCodeOwnerEmail(arbitraryUser.email())
-        .create();
-  }
-
-  /**
    * Creates a non-parseable code owner config file at the given path.
    *
    * @param path path of the code owner config file
@@ -246,6 +233,26 @@
   }
 
   /**
+   * Returns the parsing error message for the non-parseable code owner config that was created by
+   * {@link #createNonParseableCodeOwnerConfig(String)}.
+   */
+  protected String getParsingErrorMessageForNonParseableCodeOwnerConfig() {
+    return getParsingErrorMessage(
+        ImmutableMap.of(
+            FindOwnersBackend.class,
+            "invalid line: INVALID",
+            ProtoBackend.class,
+            "1:8: Expected \"{\"."));
+  }
+
+  protected String getParsingErrorMessage(
+      ImmutableMap<Class<? extends CodeOwnerBackend>, String> messagesByBackend) {
+    CodeOwnerBackend codeOwnerBackend = backendConfig.getDefaultBackend();
+    assertThat(messagesByBackend).containsKey(codeOwnerBackend.getClass());
+    return messagesByBackend.get(codeOwnerBackend.getClass());
+  }
+
+  /**
    * 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
@@ -324,4 +331,14 @@
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, subject, files);
     return push.to("refs/for/master");
   }
+
+  protected String getCodeOwnerConfigFileName() {
+    CodeOwnerBackend backend = backendConfig.getDefaultBackend();
+    if (backend instanceof FindOwnersBackend) {
+      return FindOwnersBackend.CODE_OWNER_CONFIG_FILE_NAME;
+    } else if (backend instanceof ProtoBackend) {
+      return ProtoBackend.CODE_OWNER_CONFIG_FILE_NAME;
+    }
+    throw new IllegalStateException("unknown code owner backend: " + backend.getClass().getName());
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/acceptance/BUILD b/java/com/google/gerrit/plugins/codeowners/acceptance/BUILD
index bd5bc0b..65883e0 100644
--- a/java/com/google/gerrit/plugins/codeowners/acceptance/BUILD
+++ b/java/com/google/gerrit/plugins/codeowners/acceptance/BUILD
@@ -12,6 +12,9 @@
         "//java/com/google/gerrit/acceptance:lib",
         "//plugins/code-owners:code-owners__plugin",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/api/impl",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/backend",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing/backend:testutil",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/util",
     ],
 )
diff --git a/java/com/google/gerrit/plugins/codeowners/acceptance/TestModule.java b/java/com/google/gerrit/plugins/codeowners/acceptance/TestModule.java
index b8643fc..0394508 100644
--- a/java/com/google/gerrit/plugins/codeowners/acceptance/TestModule.java
+++ b/java/com/google/gerrit/plugins/codeowners/acceptance/TestModule.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.plugins.codeowners.acceptance;
 
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.plugins.codeowners.Module;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperations;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperationsImpl;
+import com.google.gerrit.plugins.codeowners.module.Module;
 import com.google.gerrit.plugins.codeowners.testing.backend.TestCodeOwnerConfigStorage;
 
 /**
diff --git a/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/BUILD b/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/BUILD
index b08b998..2660baa 100644
--- a/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/BUILD
+++ b/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/BUILD
@@ -10,6 +10,7 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
-        "//plugins/code-owners:code-owners__plugin",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/backend",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/util",
     ],
 )
diff --git a/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/CodeOwnerConfigOperationsImpl.java b/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/CodeOwnerConfigOperationsImpl.java
index 8462d52..5fe97c9 100644
--- a/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/CodeOwnerConfigOperationsImpl.java
+++ b/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/CodeOwnerConfigOperationsImpl.java
@@ -16,14 +16,14 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
-import com.google.gerrit.plugins.codeowners.JgitPath;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigImportModification;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigUpdate;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerSetModification;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwners;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnersUpdate;
-import com.google.gerrit.plugins.codeowners.config.BackendConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.BackendConfig;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.project.ProjectCache;
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..d2acaed 100644
--- a/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/TestPathExpressions.java
+++ b/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite/TestPathExpressions.java
@@ -15,10 +15,11 @@
 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;
-import com.google.gerrit.plugins.codeowners.config.BackendConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.BackendConfig;
 import com.google.inject.Inject;
 
 /**
@@ -40,15 +41,20 @@
   }
 
   /**
-   * Creates a path expression that matches all files of the given file type in the current folder.
+   * Creates a path expression that matches all files of the given file type in the current folder
+   * and all subfolders.
    *
    * @param fileType the file type
    */
-  public String matchFileTypeInCurrentFolder(String fileType) {
+  public String matchFileType(String fileType) {
     PathExpressionMatcher pathExpressionMatcher = getPathExpressionMatcher();
-    if (pathExpressionMatcher instanceof GlobMatcher
-        || pathExpressionMatcher instanceof SimplePathExpressionMatcher) {
+    if (pathExpressionMatcher instanceof GlobMatcher) {
+      return "**." + fileType;
+    } else if (pathExpressionMatcher instanceof FindOwnersGlobMatcher) {
       return "*." + fileType;
+    } else if (pathExpressionMatcher instanceof SimplePathExpressionMatcher) {
+      // '...' (matches any string, including slashes), followed by '.<file-type>'
+      return "...." + fileType;
     }
     throw new IllegalStateException(
         String.format(
@@ -63,7 +69,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/BUILD b/java/com/google/gerrit/plugins/codeowners/api/BUILD
new file mode 100644
index 0000000..9ab1591
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/api/BUILD
@@ -0,0 +1,13 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:plugin.bzl", "PLUGIN_DEPS_NEVERLINK")
+
+java_library(
+    name = "api",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = PLUGIN_DEPS_NEVERLINK + [
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/backend",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/common",
+        "//plugins/code-owners/proto:owners_metadata_java_proto",
+    ],
+)
diff --git a/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwners.java b/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwners.java
index 22bc543..1155e35 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwners.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwners.java
@@ -31,6 +31,7 @@
   /** Create a request to retrieve code owner config files from the branch. */
   CodeOwnerConfigFilesRequest codeOwnerConfigFiles() throws RestApiException;
 
+  /** Request to retrieve code owner config files from the branch. */
   abstract class CodeOwnerConfigFilesRequest {
     private boolean includeNonParsableFiles;
     private String email;
@@ -57,7 +58,7 @@
       return this;
     }
 
-    /** Returns the email that should appear in the returned code owner config files/ */
+    /** Returns the email that should appear in the returned code owner config files. */
     @Nullable
     public String getEmail() {
       return email;
@@ -74,13 +75,13 @@
       return this;
     }
 
-    /** Returns the path glob that should be matched by the returned code owner config files/ */
+    /** 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 */
+    /** Executes the request and retrieves the paths of the requested code owner config file. */
     public abstract List<String> paths() throws RestApiException;
   }
 
@@ -88,6 +89,88 @@
   RenameEmailResultInfo renameEmailInCodeOwnerConfigFiles(RenameEmailInput input)
       throws RestApiException;
 
+  /** Checks the code ownership of a user for a path in a branch. */
+  CodeOwnerCheckRequest checkCodeOwner() throws RestApiException;
+
+  /** Request for checking the code ownership of a user for a path in a branch. */
+  abstract class CodeOwnerCheckRequest {
+    private String email;
+    private String path;
+    private String change;
+    private String user;
+
+    /**
+     * Sets the email for which the code ownership should be checked.
+     *
+     * @param email the email for which the code ownership should be checked
+     */
+    public CodeOwnerCheckRequest email(String email) {
+      this.email = email;
+      return this;
+    }
+
+    /** Returns the email for which the code ownership should be checked. */
+    @Nullable
+    public String getEmail() {
+      return email;
+    }
+
+    /**
+     * Sets the path for which the code ownership should be checked.
+     *
+     * @param path the path for which the code ownership should be checked
+     */
+    public CodeOwnerCheckRequest path(String path) {
+      this.path = path;
+      return this;
+    }
+
+    /** Returns the path for which the code ownership should be checked. */
+    @Nullable
+    public String getPath() {
+      return path;
+    }
+
+    /**
+     * Sets the change for which permissions should be checked.
+     *
+     * <p>If not specified change permissions are not checked.
+     *
+     * @param change the change for which permissions should be checked
+     */
+    public CodeOwnerCheckRequest change(@Nullable String change) {
+      this.change = change;
+      return this;
+    }
+
+    /** Returns the change for which permissions should be checked. */
+    @Nullable
+    public String getChange() {
+      return change;
+    }
+
+    /**
+     * Sets the user for which the code owner visibility should be checked.
+     *
+     * <p>If not specified the code owner visibility is not checked.
+     *
+     * @param user the user for which the code owner visibility should be checked
+     */
+    public CodeOwnerCheckRequest user(@Nullable String user) {
+      this.user = user;
+      return this;
+    }
+
+    /** Returns the user for which the code owner visibility should be checked. */
+    @Nullable
+    public String getUser() {
+      return user;
+    }
+
+    /** Executes the request and retrieves the result. */
+    public abstract CodeOwnerCheckInfo check() throws RestApiException;
+  }
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -108,5 +191,10 @@
         throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public CodeOwnerCheckRequest checkCodeOwner() throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/ChangeCodeOwners.java b/java/com/google/gerrit/plugins/codeowners/api/ChangeCodeOwners.java
index 8c1a7dd..12aebb4 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/ChangeCodeOwners.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/ChangeCodeOwners.java
@@ -16,15 +16,16 @@
 
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.Optional;
 
 /**
  * Java API for change code owners.
  *
- * <p>To create an instance for a change use {@link ChangeCodeOwnersFactory}.
+ * <p>To create an instance for a change use {@code ChangeCodeOwnersFactory}.
  */
 public interface ChangeCodeOwners {
-  /** Returns the code owner status for the files in the change. */
-  CodeOwnerStatusInfo getCodeOwnerStatus() throws RestApiException;
+  /** Creates a request to retrieve the code owner status for the files in the change. */
+  CodeOwnerStatusRequest getCodeOwnerStatus() throws RestApiException;
 
   /** Returns the revision-level code owners API for the current revision. */
   default RevisionCodeOwners current() throws RestApiException {
@@ -32,20 +33,62 @@
   }
 
   /** Returns the revision-level code owners API for the given revision. */
-  default RevisionCodeOwners revision(int id) throws RestApiException {
-    return revision(Integer.toString(id));
-  }
-
-  /** Returns the revision-level code owners API for the given revision. */
   RevisionCodeOwners revision(String id) throws RestApiException;
 
   /**
+   * Request to compute code owner status.
+   *
+   * <p>Allows to set parameters on the request before executing it by calling {@link #get()}.
+   */
+  abstract class CodeOwnerStatusRequest {
+    private Integer start;
+    private Integer limit;
+
+    /**
+     * Sets a limit on the number of code owner statuses that should be returned.
+     *
+     * @param start number of code owner statuses to skip
+     */
+    public CodeOwnerStatusRequest withStart(int start) {
+      this.start = start;
+      return this;
+    }
+
+    /** Returns the number of code owner statuses to skip. */
+    public Optional<Integer> getStart() {
+      return Optional.ofNullable(start);
+    }
+
+    /**
+     * Sets a limit on the number of code owner statuses that should be returned.
+     *
+     * @param limit the limit
+     */
+    public CodeOwnerStatusRequest withLimit(int limit) {
+      this.limit = limit;
+      return this;
+    }
+
+    /** Returns the limit. */
+    public Optional<Integer> getLimit() {
+      return Optional.ofNullable(limit);
+    }
+
+    /**
+     * Executes this request and retrieves the code owner status.
+     *
+     * @return the code owner status
+     */
+    public abstract CodeOwnerStatusInfo get() throws RestApiException;
+  }
+
+  /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
    */
   class NotImplemented implements ChangeCodeOwners {
     @Override
-    public CodeOwnerStatusInfo getCodeOwnerStatus() {
+    public CodeOwnerStatusRequest getCodeOwnerStatus() {
       throw new NotImplementedException();
     }
 
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerBranchConfigInfo.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerBranchConfigInfo.java
index 6fb3fc7..8cf28cc 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerBranchConfigInfo.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerBranchConfigInfo.java
@@ -63,16 +63,4 @@
    * <p>Not set if {@link #disabled} is {@code true}.
    */
   public List<RequiredApprovalInfo> overrideApproval;
-
-  /**
-   * Whether the branch doesn't contain any code owner config file yet.
-   *
-   * <p>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).
-   *
-   * <p>Not set if {@code false} or if {@link #disabled} is {@code true}.
-   */
-  public Boolean noCodeOwnersDefined;
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerCheckInfo.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerCheckInfo.java
new file mode 100644
index 0000000..c96645c
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerCheckInfo.java
@@ -0,0 +1,136 @@
+// 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 java.util.List;
+
+/**
+ * REST API representation of the result of checking a code owner via {code
+ * com.google.gerrit.plugins.codeowners.restapi.CheckCodeOwner}.
+ *
+ * <p>This class determines the JSON format of check result in the REST API.
+ */
+public class CodeOwnerCheckInfo {
+  /**
+   * Whether the given email owns the specified path in the branch.
+   *
+   * <p>True if:
+   *
+   * <ul>
+   *   <li>the given email is resolvable (see {@link #isResolvable}) and
+   *   <li>any code owner config file assigns codeownership to the email for the path (see {@link
+   *       #codeOwnerConfigFilePaths}) or the email is configured as default code owner (see {@link
+   *       CodeOwnerCheckInfo#isDefaultCodeOwner} field) or the email is configured as global code
+   *       owner (see {@link #isGlobalCodeOwner} field) or the user is a fallback code owner (see
+   *       {@link #isFallbackCodeOwner} field)
+   * </ul>
+   */
+  public boolean isCodeOwner;
+
+  /**
+   * Whether the given email is resolvable for the specified user or the calling user if no user was
+   * specified.
+   */
+  public boolean isResolvable;
+
+  /**
+   * Whether the user to which the given email was resolved has read permissions on the branch.
+   *
+   * <p>Not set if:
+   *
+   * <ul>
+   *   <li>the given email is not resolvable
+   *   <li>the given email is the all users wildcard (aka {@code *})
+   * </ul>
+   */
+  public Boolean canReadRef;
+
+  /**
+   * Whether the user to which the given email was resolved can see the specified change.
+   *
+   * <p>Not set if:
+   *
+   * <ul>
+   *   <li>the given email is not resolvable
+   *   <li>the given email is the all users wildcard (aka {@code *})
+   *   <li>no change was specified
+   * </ul>
+   */
+  public Boolean canSeeChange;
+
+  /**
+   * Whether the user to which the given email was resolved can code-owner approve the specified
+   * change.
+   *
+   * <p>Being able to code-owner approve the change means that the user has permissions to vote on
+   * the label that is required as code owner approval. Other permissions are not considered for
+   * computing this flag. In particular missing read permissions on the change don't have any effect
+   * on this flag. Whether the user misses read permissions on the change (and hence cannot apply
+   * the code owner approval) can be seen from the {@link #canSeeChange} flag.
+   *
+   * <p>Not set if:
+   *
+   * <ul>
+   *   <li>the given email is not resolvable
+   *   <li>the given email is the all users wildcard (aka {@code *})
+   *   <li>no change was specified
+   * </ul>
+   */
+  public Boolean canApproveChange;
+
+  /**
+   * Paths of the code owner config files that assign code ownership to the given email for the
+   * specified path.
+   *
+   * <p>If code ownership is assigned to the email via a code owner config files, but the email is
+   * not resolvable (see {@link #isResolvable} field), the user is not a code owner.
+   */
+  public List<String> codeOwnerConfigFilePaths;
+
+  /**
+   * Whether the given email is a fallback code owner of the specified path in the branch.
+   *
+   * <p>True if:
+   *
+   * <ul>
+   *   <li>the given email is resolvable (see {@link #isResolvable}) and
+   *   <li>no code owners are defined for the specified path in the branch and
+   *   <li>parent code owners are not ignored and
+   *   <li>the user is a fallback code owner according to the configured fallback code owner policy
+   */
+  public boolean isFallbackCodeOwner;
+
+  /**
+   * Whether the given email is configured as a default code owner.
+   *
+   * <p>If the email is configured as default code owner, but the email is not resolvable (see
+   * {@link #isResolvable} field), the user is not a code owner.
+   */
+  public boolean isDefaultCodeOwner;
+
+  /**
+   * Whether the given email is configured as a global code owner.
+   *
+   * <p>If the email is configured as global code owner, but the email is not resolvable (see {@link
+   * #isResolvable} field), the user is not a code owner.
+   */
+  public boolean isGlobalCodeOwner;
+
+  /** Whether the the specified path in the branch is owned by all users (aka {@code *}). */
+  public boolean isOwnedByAllUsers;
+
+  /** Debug logs that may help to understand why the user is or isn't a code owner. */
+  public List<String> debugLogs;
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerConfigInfo.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerConfigInfo.java
index b74d8ea..f877f64 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerConfigInfo.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerConfigInfo.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.plugins.codeowners.api;
 
-import com.google.common.base.MoreObjects;
 import java.util.List;
-import java.util.Objects;
 
 /**
  * Representation of a {@link com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig} in the
@@ -39,23 +37,4 @@
    * <p>Not set if there are no code owner sets defined in this code owner config.
    */
   public List<CodeOwnerSetInfo> codeOwnerSets;
-
-  @Override
-  public int hashCode() {
-    return Objects.hashCode(codeOwnerSets);
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (!(o instanceof CodeOwnerConfigInfo)) {
-      return false;
-    }
-    CodeOwnerConfigInfo other = (CodeOwnerConfigInfo) o;
-    return Objects.equals(codeOwnerSets, other.codeOwnerSets);
-  }
-
-  @Override
-  public String toString() {
-    return MoreObjects.toStringHelper(this).add("codeOwnerSets", codeOwnerSets).toString();
-  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerConfigValidationPolicy.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerConfigValidationPolicy.java
deleted file mode 100644
index db1e6ca..0000000
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerConfigValidationPolicy.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// 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;
-
-/** Policy that should be used to validate code owner config files. */
-public enum CodeOwnerConfigValidationPolicy {
-  /**
-   * The code owner config file validation is enabled and invalid code owner config files are
-   * rejected.
-   */
-  TRUE,
-
-  /**
-   * The code owner config file validation is disabled. Invalid code owner config files are not
-   * rejected.
-   */
-  FALSE,
-
-  /**
-   * Code owner config files are validated, but invalid code owner config files are not rejected.
-   */
-  DRY_RUN;
-
-  public boolean isDryRun() {
-    return this == CodeOwnerConfigValidationPolicy.DRY_RUN;
-  }
-
-  public boolean runValidation() {
-    return this != CodeOwnerConfigValidationPolicy.FALSE;
-  }
-}
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerConfigs.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerConfigs.java
index 24e054c..8754809 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerConfigs.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerConfigs.java
@@ -23,7 +23,7 @@
 /**
  * Java API for code owners configs in a branch.
  *
- * <p>To create an instance for a branch use {@link CodeOwnerConfigsFactory}.
+ * <p>To create an instance for a branch use {@code CodeOwnerConfigsFactory}.
  */
 public interface CodeOwnerConfigs {
   /**
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerInfo.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerInfo.java
index 934674a..c141bea 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerInfo.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerInfo.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.plugins.codeowners.api;
 
-import com.google.common.base.MoreObjects;
 import com.google.gerrit.extensions.common.AccountInfo;
-import java.util.Objects;
 
 /**
  * Representation of a code owner in the REST API.
@@ -26,23 +24,4 @@
 public class CodeOwnerInfo {
   /** The account of the code owner. */
   public AccountInfo account;
-
-  @Override
-  public int hashCode() {
-    return Objects.hashCode(account);
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (!(o instanceof CodeOwnerInfo)) {
-      return false;
-    }
-    CodeOwnerInfo other = (CodeOwnerInfo) o;
-    return Objects.equals(account, other.account);
-  }
-
-  @Override
-  public String toString() {
-    return MoreObjects.toStringHelper(this).add("account", account).toString();
-  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerProjectConfigInput.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerProjectConfigInput.java
new file mode 100644
index 0000000..8bc182e
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerProjectConfigInput.java
@@ -0,0 +1,121 @@
+// 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.plugins.codeowners.backend.FallbackCodeOwners;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerConfigValidationPolicy;
+import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
+import java.util.List;
+
+/**
+ * Input for the {@code com.google.gerrit.plugins.codeowners.restapi.PutCodeOwnerProjectConfig} REST
+ * endpoint
+ *
+ * <p>This class determines the JSON format of the input in the REST API.
+ *
+ * <p>If a field in this input entity is not set, the corresponding parameter in the {@code
+ * code-owners.config} file is not updated.
+ */
+public class CodeOwnerProjectConfigInput {
+  /** Whether the code owners functionality should be disabled/enabled for the project. */
+  public Boolean disabled;
+
+  /**
+   * Branches for which the code owners functionality is disabled.
+   *
+   * <p>Can be exact refs, ref patterns or regular expressions.
+   *
+   * <p>Overrides any existing disabled branch configuration.
+   */
+  public List<String> disabledBranches;
+
+  /** The file extension that should be used for code owner config files in this project. */
+  public String fileExtension;
+
+  /**
+   * The approval that is required from code owners.
+   *
+   * <p>The required approval must be specified in the format {@code <label-name>+<label-value>}.
+   *
+   * <p>If an empty string is provided the required approval configuration is unset. Unsetting the
+   * required approval means that the inherited required approval configuration or the default
+   * required approval ({@code Code-Review+1}) will apply.
+   *
+   * <p>In contrast to providing an empty string, providing {@code null} (or not setting the value)
+   * means that the required approval configuration is not updated.
+   */
+  public String requiredApproval;
+
+  /**
+   * The approvals that count as override for the code owners submit check.
+   *
+   * <p>The override approvals must be specified in the format {@code <label-name>+<label-value>}.
+   */
+  public List<String> overrideApprovals;
+
+  /** Policy that controls who should own paths that have no code owners defined. */
+  public FallbackCodeOwners fallbackCodeOwners;
+
+  /** Emails of users that should be code owners globally across all branches. */
+  public List<String> globalCodeOwners;
+
+  /** Emails of users that should be exempted from requiring code owner approvals. */
+  public List<String> exemptedUsers;
+
+  /** Strategy that defines for merge commits which files require code owner approvals. */
+  public MergeCommitStrategy mergeCommitStrategy;
+
+  /** Whether an implicit code owner approval from the last uploader is assumed. */
+  public Boolean implicitApprovals;
+
+  /**
+   * URL for a page that provides project/host-specific information about how to request a code
+   * owner override.
+   */
+  public String overrideInfoUrl;
+
+  /**
+   * URL for a page that provides project/host-specific information about how to deal with invalid
+   * code owner config files.
+   */
+  public String invalidCodeOwnerConfigInfoUrl;
+
+  /** Whether code owner config files are read-only. */
+  public Boolean readOnly;
+
+  /** Whether pure revert changes are exempted from needing code owner approvals for submit. */
+  public Boolean exemptPureReverts;
+
+  /** Policy for validating code owner config files when a commit is received. */
+  public CodeOwnerConfigValidationPolicy enableValidationOnCommitReceived;
+
+  /** Policy for validating code owner config files when a change is submitted. */
+  public CodeOwnerConfigValidationPolicy enableValidationOnSubmit;
+
+  /**
+   * Whether modifications of code owner config files that newly add non-resolvable code owners
+   * should be rejected on commit received and submit.
+   */
+  public Boolean rejectNonResolvableCodeOwners;
+
+  /**
+   * Whether modifications of code owner config files that newly add non-resolvable imports should
+   * be rejected on commit received an submit.
+   */
+  public Boolean rejectNonResolvableImports;
+
+  /** The maximum number of paths that are included in change messages. */
+  public Integer maxPathsInChangeMessages;
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerReferenceInfo.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerReferenceInfo.java
index 1ca0c63..6ba7f69 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerReferenceInfo.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerReferenceInfo.java
@@ -14,9 +14,6 @@
 
 package com.google.gerrit.plugins.codeowners.api;
 
-import com.google.common.base.MoreObjects;
-import java.util.Objects;
-
 /**
  * Representation of a {@link com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference} in
  * the REST API.
@@ -26,23 +23,4 @@
 public class CodeOwnerReferenceInfo {
   /** The email of the code owner. */
   public String email;
-
-  @Override
-  public int hashCode() {
-    return Objects.hashCode(email);
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (!(o instanceof CodeOwnerReferenceInfo)) {
-      return false;
-    }
-    CodeOwnerReferenceInfo other = (CodeOwnerReferenceInfo) o;
-    return Objects.equals(email, other.email);
-  }
-
-  @Override
-  public String toString() {
-    return MoreObjects.toStringHelper(this).add("email", email).toString();
-  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerSetInfo.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerSetInfo.java
index 54ccc3f..8dccbd6 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerSetInfo.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerSetInfo.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.plugins.codeowners.api;
 
-import com.google.common.base.MoreObjects;
 import java.util.List;
-import java.util.Objects;
 
 /**
  * Representation of a {@link com.google.gerrit.plugins.codeowners.backend.CodeOwnerSet} in the REST
@@ -31,23 +29,4 @@
    * <p>Not set if there are no code owners defined in this code owner set.
    */
   public List<CodeOwnerReferenceInfo> codeOwners;
-
-  @Override
-  public int hashCode() {
-    return Objects.hashCode(codeOwners);
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (!(o instanceof CodeOwnerSetInfo)) {
-      return false;
-    }
-    CodeOwnerSetInfo other = (CodeOwnerSetInfo) o;
-    return Objects.equals(codeOwners, other.codeOwners);
-  }
-
-  @Override
-  public String toString() {
-    return MoreObjects.toStringHelper(this).add("codeOwners", codeOwners).toString();
-  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerStatusInfo.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerStatusInfo.java
index b10e2be..e9ae34d 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerStatusInfo.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerStatusInfo.java
@@ -34,4 +34,11 @@
 
   /** List of the code owner statuses for the files in the change. */
   public List<FileCodeOwnerStatusInfo> fileCodeOwnerStatuses;
+
+  /**
+   * Whether the request would deliver more results if not limited.
+   *
+   * <p>Not set if {@code false}.
+   */
+  public Boolean more;
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwners.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwners.java
index 5d1182c..a2079b8 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwners.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwners.java
@@ -24,14 +24,13 @@
 import java.nio.file.Paths;
 import java.util.Arrays;
 import java.util.EnumSet;
-import java.util.List;
 import java.util.Optional;
 import java.util.Set;
 
 /**
  * Java API for code owners in a branch or change revision.
  *
- * <p>To create an instance for a branch or change revision use {@link CodeOwnersFactory}.
+ * <p>To create an instance for a branch or change revision use {@code CodeOwnersFactory}.
  */
 public interface CodeOwners {
   /** Query code owners for a path. */
@@ -47,6 +46,10 @@
     private Set<ListAccountsOption> options = EnumSet.noneOf(ListAccountsOption.class);
     private Integer limit;
     private String revision;
+    private Long seed;
+    private Boolean resolveAllUsers;
+    private Boolean highestScoreOnly;
+    private Boolean debug;
 
     /**
      * Lists the code owners for the given path.
@@ -55,7 +58,7 @@
      *     exist
      * @return the code owners for the given path
      */
-    public abstract List<CodeOwnerInfo> get(Path path) throws RestApiException;
+    public abstract CodeOwnersInfo get(Path path) throws RestApiException;
 
     /**
      * Lists the code owners for the given path.
@@ -64,7 +67,7 @@
      *     exist
      * @return the code owners for the given path
      */
-    public List<CodeOwnerInfo> get(String path) throws RestApiException {
+    public CodeOwnersInfo get(String path) throws RestApiException {
       return get(Paths.get(path));
     }
 
@@ -93,6 +96,51 @@
     }
 
     /**
+     * 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 whether code ownerships that are assigned to all users should be resolved to random
+     * users.
+     *
+     * @param resolveAllUsers whether code ownerships that are assigned to all users should be
+     *     resolved to random users
+     */
+    public QueryRequest setResolveAllUsers(boolean resolveAllUsers) {
+      this.resolveAllUsers = resolveAllUsers;
+      return this;
+    }
+
+    /**
+     * Sets whether only the code owners with the highest score should be returned.
+     *
+     * @param highestScoreOnly whether only the code owners with the highest score should be
+     *     returned
+     */
+    public QueryRequest withHighestScoreOnly(boolean highestScoreOnly) {
+      this.highestScoreOnly = highestScoreOnly;
+      return this;
+    }
+
+    /**
+     * Sets whether debug logs should be included into the response.
+     *
+     * <p>Requires the 'Check Code Owner' global capability.
+     *
+     * @param debug whether debug logs should be included into the response
+     */
+    public QueryRequest withDebug(boolean debug) {
+      this.debug = debug;
+      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,20 +162,32 @@
       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);
+    }
+
+    /**
+     * Whether code ownerships that are assigned to all users should be resolved to random users.
+     */
+    public Optional<Boolean> getResolveAllUsers() {
+      return Optional.ofNullable(resolveAllUsers);
+    }
+
+    /** Whether only the code owners with the highest score should be returned. */
+    public Optional<Boolean> getHighestScoreOnly() {
+      return Optional.ofNullable(highestScoreOnly);
+    }
+
+    /** Whether debug logs should be included into the response. */
+    public Optional<Boolean> getDebug() {
+      return Optional.ofNullable(debug);
+    }
+
     /** Returns the branch revision from which the code owner configs should be read. */
     public Optional<String> getRevision() {
       return Optional.ofNullable(revision);
     }
-
-    @Override
-    public String toString() {
-      StringBuilder sb = new StringBuilder(getClass().getSimpleName()).append('{');
-      if (!options.isEmpty()) {
-        sb.append("options=").append(options);
-      }
-      sb.append('}');
-      return sb.toString();
-    }
   }
 
   /**
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnersInfo.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnersInfo.java
new file mode 100644
index 0000000..d699029
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnersInfo.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.api;
+
+import java.util.List;
+
+/**
+ * Representation of a code owners list in the REST API.
+ *
+ * <p>This class determines the JSON format for the response of the {@code
+ * com.google.gerrit.plugins.codeowners.restapi.GetCodeOwnersForPathInBranch} and {@code
+ * com.google.gerrit.plugins.codeowners.restapi.GetCodeOwnersForPathInChange} REST endpoints.
+ */
+public class CodeOwnersInfo {
+  /** List of code owners. */
+  public List<CodeOwnerInfo> codeOwners;
+
+  /**
+   * Whether the path is owned by all users.
+   *
+   * <p>Not set if {@code false}.
+   */
+  public Boolean ownedByAllUsers;
+
+  /**
+   * Debug logs that may help to understand why a user is or isn't suggested as a code owner.
+   *
+   * <p>Only set if requested via {@code --debug}.
+   */
+  public List<String> debugLogs;
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/api/GeneralInfo.java b/java/com/google/gerrit/plugins/codeowners/api/GeneralInfo.java
index 8caa0fe..d0c123f 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/GeneralInfo.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/GeneralInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.plugins.codeowners.api;
 
 import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
+import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
 
 /**
  * Representation of the general code owners configuration in the REST API.
@@ -46,6 +47,12 @@
    */
   public String overrideInfoUrl;
 
+  /**
+   * Optional URL for a page that provides project/host-specific information about how to deal with
+   * invalid code owner config files.
+   */
+  public String invalidCodeOwnerConfigInfoUrl;
+
   /** 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/OwnedPathsInfo.java b/java/com/google/gerrit/plugins/codeowners/api/OwnedPathsInfo.java
new file mode 100644
index 0000000..ba41418
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/api/OwnedPathsInfo.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.api;
+
+import java.util.List;
+
+/**
+ * Representation of a list of owned paths in the REST API.
+ *
+ * <p>This class determines the JSON format for the response of the {@code
+ * com.google.gerrit.plugins.codeowners.restapi.GetOwnedPaths} REST endpoint.
+ */
+public class OwnedPathsInfo {
+  /**
+   * List of the owned paths.
+   *
+   * <p>The paths are returned as absolute paths.
+   *
+   * <p>The paths are sorted alphabetically.
+   */
+  public List<String> ownedPaths;
+
+  /**
+   * Whether the request would deliver more results if not limited.
+   *
+   * <p>Not set if {@code false}.
+   */
+  public Boolean more;
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/api/PathCodeOwnerStatusInfo.java b/java/com/google/gerrit/plugins/codeowners/api/PathCodeOwnerStatusInfo.java
index 80e1f70..7643e53 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/PathCodeOwnerStatusInfo.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/PathCodeOwnerStatusInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.plugins.codeowners.api;
 
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
+
 /** JSON entity that describes the code owner status for a path that was touched in a change. */
 public class PathCodeOwnerStatusInfo {
   /**
diff --git a/java/com/google/gerrit/plugins/codeowners/api/ProjectCodeOwners.java b/java/com/google/gerrit/plugins/codeowners/api/ProjectCodeOwners.java
index ccce1ed..5f99cd1 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/ProjectCodeOwners.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/ProjectCodeOwners.java
@@ -25,12 +25,21 @@
 /**
  * Project-level Java API of the code-owners plugin.
  *
- * <p>To create an instance for a project use {@link ProjectCodeOwnersFactory}.
+ * <p>To create an instance for a project use {@code ProjectCodeOwnersFactory}.
  */
 public interface ProjectCodeOwners {
   /** Returns the code owner project configuration. */
   CodeOwnerProjectConfigInfo getConfig() throws RestApiException;
 
+  /**
+   * Update the code owner project configuration.
+   *
+   * @param input the input specifying which parameters should be updated
+   * @return the update code owner project configuration
+   */
+  CodeOwnerProjectConfigInfo updateConfig(CodeOwnerProjectConfigInput input)
+      throws RestApiException;
+
   /** Create a request to check the code owner config files in the project. */
   CheckCodeOwnerConfigFilesRequest checkCodeOwnerConfigFiles() throws RestApiException;
 
@@ -147,6 +156,12 @@
     }
 
     @Override
+    public CodeOwnerProjectConfigInfo updateConfig(CodeOwnerProjectConfigInput input)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public CheckCodeOwnerConfigFilesRequest checkCodeOwnerConfigFiles() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/RevisionCodeOwners.java b/java/com/google/gerrit/plugins/codeowners/api/RevisionCodeOwners.java
index df9ed04..7b2655b 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/RevisionCodeOwners.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/RevisionCodeOwners.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 
 /**
  * Revision-level Java API of the code-owners plugin.
@@ -34,6 +35,11 @@
    */
   CheckCodeOwnerConfigFilesRequest checkCodeOwnerConfigFiles() throws RestApiException;
 
+  /**
+   * Retrieves the paths of the revision that owned by the user that is specified in the request.
+   */
+  OwnedPathsRequest getOwnedPaths() throws RestApiException;
+
   /** Request to check code owner config files. */
   abstract class CheckCodeOwnerConfigFilesRequest {
     private String path;
@@ -88,6 +94,57 @@
     public abstract Map<String, List<ConsistencyProblemInfo>> check() throws RestApiException;
   }
 
+  /** Request to check code owner config files. */
+  abstract class OwnedPathsRequest {
+    private Integer start;
+    private Integer limit;
+    private String user;
+
+    /**
+     * Sets a limit on the number of owned paths that should be returned.
+     *
+     * @param start number of owned paths to skip
+     */
+    public OwnedPathsRequest withStart(int start) {
+      this.start = start;
+      return this;
+    }
+
+    /** Returns the number of owned paths to skip. */
+    public Optional<Integer> getStart() {
+      return Optional.ofNullable(start);
+    }
+
+    /**
+     * Sets a limit on the number of owned paths that should be returned.
+     *
+     * @param limit the limit
+     */
+    public OwnedPathsRequest withLimit(int limit) {
+      this.limit = limit;
+      return this;
+    }
+
+    /** Returns the limit. */
+    public Optional<Integer> getLimit() {
+      return Optional.ofNullable(limit);
+    }
+
+    /** Sets the user for which the owned paths should be retrieved. */
+    public OwnedPathsRequest forUser(String user) {
+      this.user = user;
+      return this;
+    }
+
+    /** Returns the user for which the owned paths should be retrieved. */
+    public String getUser() {
+      return user;
+    }
+
+    /** Executes the request and retrieves the paths that are owned by the user. */
+    public abstract OwnedPathsInfo get() throws RestApiException;
+  }
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -97,5 +154,10 @@
     public CheckCodeOwnerConfigFilesRequest checkCodeOwnerConfigFiles() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public OwnedPathsRequest getOwnedPaths() throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/ApiModule.java b/java/com/google/gerrit/plugins/codeowners/api/impl/ApiModule.java
similarity index 95%
rename from java/com/google/gerrit/plugins/codeowners/api/ApiModule.java
rename to java/com/google/gerrit/plugins/codeowners/api/impl/ApiModule.java
index 92fce1e..838f2f8 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/ApiModule.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/impl/ApiModule.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.api;
+package com.google.gerrit.plugins.codeowners.api.impl;
 
 import com.google.gerrit.extensions.config.FactoryModule;
 
diff --git a/java/com/google/gerrit/plugins/codeowners/api/impl/BUILD b/java/com/google/gerrit/plugins/codeowners/api/impl/BUILD
new file mode 100644
index 0000000..0fe77d6
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/api/impl/BUILD
@@ -0,0 +1,12 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:plugin.bzl", "PLUGIN_DEPS_NEVERLINK")
+
+java_library(
+    name = "impl",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = PLUGIN_DEPS_NEVERLINK + [
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/api",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/restapi",
+    ],
+)
diff --git a/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwnersImpl.java b/java/com/google/gerrit/plugins/codeowners/api/impl/BranchCodeOwnersImpl.java
similarity index 71%
rename from java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwnersImpl.java
rename to java/com/google/gerrit/plugins/codeowners/api/impl/BranchCodeOwnersImpl.java
index 7052a1b..0bf1917 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/BranchCodeOwnersImpl.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/impl/BranchCodeOwnersImpl.java
@@ -12,11 +12,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.api;
+package com.google.gerrit.plugins.codeowners.api.impl;
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.plugins.codeowners.api.BranchCodeOwners;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerBranchConfigInfo;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerCheckInfo;
+import com.google.gerrit.plugins.codeowners.api.RenameEmailInput;
+import com.google.gerrit.plugins.codeowners.api.RenameEmailResultInfo;
+import com.google.gerrit.plugins.codeowners.restapi.CheckCodeOwner;
 import com.google.gerrit.plugins.codeowners.restapi.GetCodeOwnerBranchConfig;
 import com.google.gerrit.plugins.codeowners.restapi.GetCodeOwnerConfigFiles;
 import com.google.gerrit.plugins.codeowners.restapi.RenameEmail;
@@ -35,6 +41,7 @@
   private final GetCodeOwnerBranchConfig getCodeOwnerBranchConfig;
   private final Provider<GetCodeOwnerConfigFiles> getCodeOwnerConfigFilesProvider;
   private final RenameEmail renameEmail;
+  private final Provider<CheckCodeOwner> checkCodeOwnerProvider;
   private final BranchResource branchResource;
 
   @Inject
@@ -42,10 +49,12 @@
       GetCodeOwnerBranchConfig getCodeOwnerBranchConfig,
       Provider<GetCodeOwnerConfigFiles> getCodeOwnerConfigFilesProvider,
       RenameEmail renameEmail,
+      Provider<CheckCodeOwner> checkCodeOwnerProvider,
       @Assisted BranchResource branchResource) {
     this.getCodeOwnerConfigFilesProvider = getCodeOwnerConfigFilesProvider;
     this.getCodeOwnerBranchConfig = getCodeOwnerBranchConfig;
     this.renameEmail = renameEmail;
+    this.checkCodeOwnerProvider = checkCodeOwnerProvider;
     this.branchResource = branchResource;
   }
 
@@ -81,4 +90,23 @@
       throw asRestApiException("Cannot rename email", e);
     }
   }
+
+  @Override
+  public CodeOwnerCheckRequest checkCodeOwner() throws RestApiException {
+    return new CodeOwnerCheckRequest() {
+      @Override
+      public CodeOwnerCheckInfo check() throws RestApiException {
+        CheckCodeOwner checkCodeOwner = checkCodeOwnerProvider.get();
+        checkCodeOwner.setEmail(getEmail());
+        checkCodeOwner.setPath(getPath());
+        checkCodeOwner.setChange(getChange());
+        checkCodeOwner.setUser(getUser());
+        try {
+          return checkCodeOwner.apply(branchResource).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot check code owner", e);
+        }
+      }
+    };
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/ChangeCodeOwnersFactory.java b/java/com/google/gerrit/plugins/codeowners/api/impl/ChangeCodeOwnersFactory.java
similarity index 91%
rename from java/com/google/gerrit/plugins/codeowners/api/ChangeCodeOwnersFactory.java
rename to java/com/google/gerrit/plugins/codeowners/api/impl/ChangeCodeOwnersFactory.java
index 732c9e7..22b73a5 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/ChangeCodeOwnersFactory.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/impl/ChangeCodeOwnersFactory.java
@@ -12,13 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.api;
+package com.google.gerrit.plugins.codeowners.api.impl;
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.plugins.codeowners.api.ChangeCodeOwners;
+import com.google.gerrit.plugins.codeowners.api.CodeOwners;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
 import com.google.inject.Inject;
@@ -62,7 +64,7 @@
     try {
       return changesCollection.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(changeId));
     } catch (Exception e) {
-      throw asRestApiException("Cannot retrieve revision", e);
+      throw asRestApiException("Cannot retrieve change", e);
     }
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/ChangeCodeOwnersImpl.java b/java/com/google/gerrit/plugins/codeowners/api/impl/ChangeCodeOwnersImpl.java
similarity index 66%
rename from java/com/google/gerrit/plugins/codeowners/api/ChangeCodeOwnersImpl.java
rename to java/com/google/gerrit/plugins/codeowners/api/impl/ChangeCodeOwnersImpl.java
index ad6ed18..17c78cf 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/ChangeCodeOwnersImpl.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/impl/ChangeCodeOwnersImpl.java
@@ -12,17 +12,21 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.api;
+package com.google.gerrit.plugins.codeowners.api.impl;
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.plugins.codeowners.api.ChangeCodeOwners;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatusInfo;
+import com.google.gerrit.plugins.codeowners.api.RevisionCodeOwners;
 import com.google.gerrit.plugins.codeowners.restapi.GetCodeOwnerStatus;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.restapi.change.Revisions;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 
 /** Implementation of the {@link ChangeCodeOwners} API. */
@@ -34,27 +38,35 @@
   private final Revisions revisions;
   private final RevisionCodeOwnersImpl.Factory revisionCodeOwnersApi;
   private final ChangeResource changeResource;
-  private final GetCodeOwnerStatus getCodeOwnerStatus;
+  private final Provider<GetCodeOwnerStatus> getCodeOwnerStatusProvider;
 
   @Inject
   public ChangeCodeOwnersImpl(
       Revisions revisions,
       RevisionCodeOwnersImpl.Factory revisionCodeOwnersApi,
-      GetCodeOwnerStatus getCodeOwnerStatus,
+      Provider<GetCodeOwnerStatus> getCodeOwnerStatusProvider,
       @Assisted ChangeResource changeResource) {
     this.revisions = revisions;
     this.revisionCodeOwnersApi = revisionCodeOwnersApi;
-    this.getCodeOwnerStatus = getCodeOwnerStatus;
+    this.getCodeOwnerStatusProvider = getCodeOwnerStatusProvider;
     this.changeResource = changeResource;
   }
 
   @Override
-  public CodeOwnerStatusInfo getCodeOwnerStatus() throws RestApiException {
-    try {
-      return getCodeOwnerStatus.apply(changeResource).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get code owner status", e);
-    }
+  public CodeOwnerStatusRequest getCodeOwnerStatus() throws RestApiException {
+    return new CodeOwnerStatusRequest() {
+      @Override
+      public CodeOwnerStatusInfo get() throws RestApiException {
+        try {
+          GetCodeOwnerStatus getCodeOwnerStatus = getCodeOwnerStatusProvider.get();
+          getStart().ifPresent(getCodeOwnerStatus::setStart);
+          getLimit().ifPresent(getCodeOwnerStatus::setLimit);
+          return getCodeOwnerStatus.apply(changeResource).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot get code owner status", e);
+        }
+      }
+    };
   }
 
   @Override
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerConfigsFactory.java b/java/com/google/gerrit/plugins/codeowners/api/impl/CodeOwnerConfigsFactory.java
similarity index 96%
rename from java/com/google/gerrit/plugins/codeowners/api/CodeOwnerConfigsFactory.java
rename to java/com/google/gerrit/plugins/codeowners/api/impl/CodeOwnerConfigsFactory.java
index 0be3d07..f11c9ed 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerConfigsFactory.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/impl/CodeOwnerConfigsFactory.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.api;
+package com.google.gerrit.plugins.codeowners.api.impl;
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerConfigs;
 import com.google.gerrit.server.project.BranchResource;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.restapi.project.BranchesCollection;
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerConfigsInBranchImpl.java b/java/com/google/gerrit/plugins/codeowners/api/impl/CodeOwnerConfigsInBranchImpl.java
similarity index 93%
rename from java/com/google/gerrit/plugins/codeowners/api/CodeOwnerConfigsInBranchImpl.java
rename to java/com/google/gerrit/plugins/codeowners/api/impl/CodeOwnerConfigsInBranchImpl.java
index 54573b2..27b90b7 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerConfigsInBranchImpl.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/impl/CodeOwnerConfigsInBranchImpl.java
@@ -12,13 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.api;
+package com.google.gerrit.plugins.codeowners.api.impl;
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerConfigInfo;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerConfigs;
 import com.google.gerrit.plugins.codeowners.restapi.CodeOwnerConfigsInBranchCollection;
 import com.google.gerrit.plugins.codeowners.restapi.CodeOwnerConfigsInBranchCollection.PathResource;
 import com.google.gerrit.plugins.codeowners.restapi.GetCodeOwnerConfigForPathInBranch;
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnersFactory.java b/java/com/google/gerrit/plugins/codeowners/api/impl/CodeOwnersFactory.java
similarity index 97%
rename from java/com/google/gerrit/plugins/codeowners/api/CodeOwnersFactory.java
rename to java/com/google/gerrit/plugins/codeowners/api/impl/CodeOwnersFactory.java
index 28acd8a..b5bc249 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnersFactory.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/impl/CodeOwnersFactory.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.api;
+package com.google.gerrit.plugins.codeowners.api.impl;
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.plugins.codeowners.api.CodeOwners;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.project.BranchResource;
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnersInBranchImpl.java b/java/com/google/gerrit/plugins/codeowners/api/impl/CodeOwnersInBranchImpl.java
similarity index 84%
rename from java/com/google/gerrit/plugins/codeowners/api/CodeOwnersInBranchImpl.java
rename to java/com/google/gerrit/plugins/codeowners/api/impl/CodeOwnersInBranchImpl.java
index d5cb276..fff0b9c 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnersInBranchImpl.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/impl/CodeOwnersInBranchImpl.java
@@ -12,12 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.api;
+package com.google.gerrit.plugins.codeowners.api.impl;
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.plugins.codeowners.api.CodeOwners;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnersInfo;
 import com.google.gerrit.plugins.codeowners.restapi.CodeOwnersInBranchCollection;
 import com.google.gerrit.plugins.codeowners.restapi.GetCodeOwnersForPathInBranch;
 import com.google.gerrit.server.project.BranchResource;
@@ -25,7 +27,6 @@
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.nio.file.Path;
-import java.util.List;
 
 /** Implementation of the {@link CodeOwners} API for a branch. */
 public class CodeOwnersInBranchImpl implements CodeOwners {
@@ -51,11 +52,15 @@
   public QueryRequest query() {
     return new QueryRequest() {
       @Override
-      public List<CodeOwnerInfo> get(Path path) throws RestApiException {
+      public CodeOwnersInfo get(Path path) throws RestApiException {
         try {
           GetCodeOwnersForPathInBranch getCodeOwners = getCodeOwnersProvider.get();
           getOptions().forEach(getCodeOwners::addOption);
           getLimit().ifPresent(getCodeOwners::setLimit);
+          getSeed().ifPresent(getCodeOwners::setSeed);
+          getResolveAllUsers().ifPresent(getCodeOwners::setResolveAllUsers);
+          getHighestScoreOnly().ifPresent(getCodeOwners::setHighestScoreOnly);
+          getDebug().ifPresent(getCodeOwners::setDebug);
           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/impl/CodeOwnersInChangeImpl.java
similarity index 84%
rename from java/com/google/gerrit/plugins/codeowners/api/CodeOwnersInChangeImpl.java
rename to java/com/google/gerrit/plugins/codeowners/api/impl/CodeOwnersInChangeImpl.java
index 4c8cfdc..97a5715 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnersInChangeImpl.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/impl/CodeOwnersInChangeImpl.java
@@ -12,13 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.api;
+package com.google.gerrit.plugins.codeowners.api.impl;
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.plugins.codeowners.api.CodeOwners;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnersInfo;
 import com.google.gerrit.plugins.codeowners.restapi.CodeOwnersInChangeCollection;
 import com.google.gerrit.plugins.codeowners.restapi.GetCodeOwnersForPathInChange;
 import com.google.gerrit.server.change.RevisionResource;
@@ -26,7 +28,6 @@
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.nio.file.Path;
-import java.util.List;
 
 /** Implementation of the {@link CodeOwners} API for a revision in a change. */
 public class CodeOwnersInChangeImpl implements CodeOwners {
@@ -52,7 +53,7 @@
   public QueryRequest query() {
     return new QueryRequest() {
       @Override
-      public List<CodeOwnerInfo> get(Path path) throws RestApiException {
+      public CodeOwnersInfo get(Path path) throws RestApiException {
         try {
           if (getRevision().isPresent()) {
             throw new BadRequestException("specifying revision is not supported");
@@ -61,6 +62,10 @@
           GetCodeOwnersForPathInChange getCodeOwners = getCodeOwnersProvider.get();
           getOptions().forEach(getCodeOwners::addOption);
           getLimit().ifPresent(getCodeOwners::setLimit);
+          getSeed().ifPresent(getCodeOwners::setSeed);
+          getResolveAllUsers().ifPresent(getCodeOwners::setResolveAllUsers);
+          getHighestScoreOnly().ifPresent(getCodeOwners::setHighestScoreOnly);
+          getDebug().ifPresent(getCodeOwners::setDebug);
           CodeOwnersInChangeCollection.PathResource pathInChangeResource =
               codeOwnersInChangeCollection.parse(
                   revisionResource, IdString.fromDecoded(path.toString()));
diff --git a/java/com/google/gerrit/plugins/codeowners/api/ProjectCodeOwnersFactory.java b/java/com/google/gerrit/plugins/codeowners/api/impl/ProjectCodeOwnersFactory.java
similarity index 95%
rename from java/com/google/gerrit/plugins/codeowners/api/ProjectCodeOwnersFactory.java
rename to java/com/google/gerrit/plugins/codeowners/api/impl/ProjectCodeOwnersFactory.java
index 633eebb..cb85a1f 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/ProjectCodeOwnersFactory.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/impl/ProjectCodeOwnersFactory.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.api;
+package com.google.gerrit.plugins.codeowners.api.impl;
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.plugins.codeowners.api.ProjectCodeOwners;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/plugins/codeowners/api/ProjectCodeOwnersImpl.java b/java/com/google/gerrit/plugins/codeowners/api/impl/ProjectCodeOwnersImpl.java
similarity index 79%
rename from java/com/google/gerrit/plugins/codeowners/api/ProjectCodeOwnersImpl.java
rename to java/com/google/gerrit/plugins/codeowners/api/impl/ProjectCodeOwnersImpl.java
index 54b37ec..b8b0b17 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/ProjectCodeOwnersImpl.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/impl/ProjectCodeOwnersImpl.java
@@ -12,15 +12,21 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.api;
+package com.google.gerrit.plugins.codeowners.api.impl;
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.plugins.codeowners.api.BranchCodeOwners;
+import com.google.gerrit.plugins.codeowners.api.CheckCodeOwnerConfigFilesInput;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerProjectConfigInfo;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerProjectConfigInput;
+import com.google.gerrit.plugins.codeowners.api.ProjectCodeOwners;
 import com.google.gerrit.plugins.codeowners.restapi.CheckCodeOwnerConfigFiles;
 import com.google.gerrit.plugins.codeowners.restapi.GetCodeOwnerProjectConfig;
+import com.google.gerrit.plugins.codeowners.restapi.PutCodeOwnerProjectConfig;
 import com.google.gerrit.server.project.BranchResource;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.restapi.project.BranchesCollection;
@@ -38,6 +44,7 @@
   private final BranchesCollection branchesCollection;
   private final BranchCodeOwnersImpl.Factory branchCodeOwnersApi;
   private final GetCodeOwnerProjectConfig getCodeOwnerProjectConfig;
+  private final PutCodeOwnerProjectConfig putCodeOwnerProjectConfig;
   private final CheckCodeOwnerConfigFiles checkCodeOwnerConfigFiles;
   private final ProjectResource projectResource;
 
@@ -46,11 +53,13 @@
       BranchesCollection branchesCollection,
       BranchCodeOwnersImpl.Factory branchCodeOwnersApi,
       GetCodeOwnerProjectConfig getCodeOwnerProjectConfig,
+      PutCodeOwnerProjectConfig putCodeOwnerProjectConfig,
       CheckCodeOwnerConfigFiles checkCodeOwnerConfigFiles,
       @Assisted ProjectResource projectResource) {
     this.branchesCollection = branchesCollection;
     this.branchCodeOwnersApi = branchCodeOwnersApi;
     this.getCodeOwnerProjectConfig = getCodeOwnerProjectConfig;
+    this.putCodeOwnerProjectConfig = putCodeOwnerProjectConfig;
     this.checkCodeOwnerConfigFiles = checkCodeOwnerConfigFiles;
     this.projectResource = projectResource;
   }
@@ -76,6 +85,16 @@
   }
 
   @Override
+  public CodeOwnerProjectConfigInfo updateConfig(CodeOwnerProjectConfigInput input)
+      throws RestApiException {
+    try {
+      return putCodeOwnerProjectConfig.apply(projectResource, input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update code owner project config", e);
+    }
+  }
+
+  @Override
   public CheckCodeOwnerConfigFilesRequest checkCodeOwnerConfigFiles() throws RestApiException {
     return new CheckCodeOwnerConfigFilesRequest() {
       @Override
diff --git a/java/com/google/gerrit/plugins/codeowners/api/RevisionCodeOwnersImpl.java b/java/com/google/gerrit/plugins/codeowners/api/impl/RevisionCodeOwnersImpl.java
similarity index 68%
rename from java/com/google/gerrit/plugins/codeowners/api/RevisionCodeOwnersImpl.java
rename to java/com/google/gerrit/plugins/codeowners/api/impl/RevisionCodeOwnersImpl.java
index 817639f..db8cb27 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/RevisionCodeOwnersImpl.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/impl/RevisionCodeOwnersImpl.java
@@ -12,15 +12,20 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.api;
+package com.google.gerrit.plugins.codeowners.api.impl;
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.plugins.codeowners.api.CheckCodeOwnerConfigFilesInRevisionInput;
+import com.google.gerrit.plugins.codeowners.api.OwnedPathsInfo;
+import com.google.gerrit.plugins.codeowners.api.RevisionCodeOwners;
 import com.google.gerrit.plugins.codeowners.restapi.CheckCodeOwnerConfigFilesInRevision;
+import com.google.gerrit.plugins.codeowners.restapi.GetOwnedPaths;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.util.List;
 import java.util.Map;
@@ -32,13 +37,16 @@
   }
 
   private final CheckCodeOwnerConfigFilesInRevision checkCodeOwnerConfigFilesInRevision;
+  private final Provider<GetOwnedPaths> getOwnedPathsProvider;
   private final RevisionResource revisionResource;
 
   @Inject
   public RevisionCodeOwnersImpl(
       CheckCodeOwnerConfigFilesInRevision checkCodeOwnerConfigFilesInRevision,
+      Provider<GetOwnedPaths> getOwnedPathsProvider,
       @Assisted RevisionResource revisionResource) {
     this.checkCodeOwnerConfigFilesInRevision = checkCodeOwnerConfigFilesInRevision;
+    this.getOwnedPathsProvider = getOwnedPathsProvider;
     this.revisionResource = revisionResource;
   }
 
@@ -59,4 +67,22 @@
       }
     };
   }
+
+  @Override
+  public OwnedPathsRequest getOwnedPaths() throws RestApiException {
+    return new OwnedPathsRequest() {
+      @Override
+      public OwnedPathsInfo get() throws RestApiException {
+        try {
+          GetOwnedPaths getOwnedPaths = getOwnedPathsProvider.get();
+          getStart().ifPresent(getOwnedPaths::setStart);
+          getLimit().ifPresent(getOwnedPaths::setLimit);
+          getOwnedPaths.setUser(getUser());
+          return getOwnedPaths.apply(revisionResource).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot get owned paths", e);
+        }
+      }
+    };
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java b/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java
index 1de1325..20a44c2 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java
@@ -20,8 +20,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -53,6 +52,7 @@
   private final MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory;
   private final RetryHelper retryHelper;
   private final String defaultFileName;
+  private final CodeOwnerConfigFile.Factory codeOwnerConfigFileFactory;
   private final CodeOwnerConfigParser codeOwnerConfigParser;
 
   protected AbstractFileBasedCodeOwnerBackend(
@@ -62,6 +62,7 @@
       MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
       RetryHelper retryHelper,
       String defaultFileName,
+      CodeOwnerConfigFile.Factory codeOwnerConfigFileFactory,
       CodeOwnerConfigParser codeOwnerConfigParser) {
     this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
     this.repoManager = repoManager;
@@ -69,6 +70,7 @@
     this.metaDataUpdateInternalFactory = metaDataUpdateInternalFactory;
     this.retryHelper = retryHelper;
     this.defaultFileName = defaultFileName;
+    this.codeOwnerConfigFileFactory = codeOwnerConfigFileFactory;
     this.codeOwnerConfigParser = codeOwnerConfigParser;
   }
 
@@ -104,7 +106,7 @@
       @Nullable ObjectId revision) {
     try (Repository repository = repoManager.openRepository(codeOwnerConfigKey.project())) {
       if (revision == null) {
-        return CodeOwnerConfigFile.loadCurrent(
+        return codeOwnerConfigFileFactory.loadCurrent(
             fileName, codeOwnerConfigParser, repository, codeOwnerConfigKey);
       }
 
@@ -114,7 +116,7 @@
         revWalk = new RevWalk(repository);
       }
       try {
-        return CodeOwnerConfigFile.load(
+        return codeOwnerConfigFileFactory.load(
             fileName, codeOwnerConfigParser, revWalk, revision, codeOwnerConfigKey);
       } finally {
         if (closeRevWalk) {
@@ -122,10 +124,10 @@
         }
       }
     } catch (IOException e) {
-      throw new StorageException(
+      throw new CodeOwnersInternalServerErrorException(
           String.format("failed to load code owner config %s", codeOwnerConfigKey), e);
     } catch (ConfigInvalidException e) {
-      throw new StorageException(
+      throw new CodeOwnersInternalServerErrorException(
           String.format(
               "invalid code owner config file %s (project = %s, branch = %s)",
               codeOwnerConfigKey.filePath(defaultFileName),
@@ -172,7 +174,8 @@
     String quotedFileExtension =
         Pattern.quote(
             codeOwnersPluginConfiguration
-                .getFileExtension(project)
+                .getProjectConfig(project)
+                .getFileExtension()
                 .map(ext -> "." + ext)
                 .orElse(""));
     String nameExtension = "(\\w)+";
@@ -189,7 +192,11 @@
 
   private String getFileName(Project.NameKey project) {
     return defaultFileName
-        + codeOwnersPluginConfiguration.getFileExtension(project).map(ext -> "." + ext).orElse("");
+        + codeOwnersPluginConfiguration
+            .getProjectConfig(project)
+            .getFileExtension()
+            .map(ext -> "." + ext)
+            .orElse("");
   }
 
   @Override
@@ -207,7 +214,8 @@
           .call();
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
-      throw new StorageException(e);
+      throw new CodeOwnersInternalServerErrorException(
+          String.format("failed to upsert code owner config %s", codeOwnerConfigKey), e);
     }
   }
 
@@ -217,7 +225,8 @@
       CodeOwnerConfigUpdate codeOwnerConfigUpdate) {
     try (Repository repository = repoManager.openRepository(codeOwnerConfigKey.project())) {
       CodeOwnerConfigFile codeOwnerConfigFile =
-          CodeOwnerConfigFile.loadCurrent(
+          codeOwnerConfigFileFactory
+              .loadCurrent(
                   getFileName(codeOwnerConfigKey.project()),
                   codeOwnerConfigParser,
                   repository,
@@ -231,7 +240,7 @@
 
       return codeOwnerConfigFile.getLoadedCodeOwnerConfig();
     } catch (IOException | ConfigInvalidException e) {
-      throw new StorageException(
+      throw new CodeOwnersInternalServerErrorException(
           String.format("failed to upsert code owner config %s", codeOwnerConfigKey), e);
     }
   }
@@ -255,7 +264,7 @@
     } catch (Throwable t) {
       metaDataUpdate.close();
       Throwables.throwIfUnchecked(t);
-      throw new StorageException("Failed to create MetaDataUpdate", t);
+      throw new CodeOwnersInternalServerErrorException("Failed to create MetaDataUpdate", t);
     }
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/BUILD b/java/com/google/gerrit/plugins/codeowners/backend/BUILD
new file mode 100644
index 0000000..83671a0
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/BUILD
@@ -0,0 +1,14 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:plugin.bzl", "PLUGIN_DEPS_NEVERLINK")
+
+java_library(
+    name = "backend",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = PLUGIN_DEPS_NEVERLINK + [
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/common",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/metrics",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/util",
+        "//plugins/code-owners/proto:owners_metadata_java_proto",
+    ],
+)
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java b/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
index adccb0f..e137ba7 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
@@ -16,12 +16,17 @@
 
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.events.ReviewerAddedListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginGlobalConfigSnapshot;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginProjectConfigSnapshot;
 import com.google.gerrit.server.ExceptionHook;
 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. */
@@ -30,6 +35,9 @@
   protected void configure() {
     factory(CodeOwnersUpdate.Factory.class);
     factory(CodeOwnerConfigScanner.Factory.class);
+    factory(CodeOwnersPluginGlobalConfigSnapshot.Factory.class);
+    factory(CodeOwnersPluginProjectConfigSnapshot.Factory.class);
+    factory(CodeOwnersPluginConfig.Factory.class);
 
     DynamicMap.mapOf(binder(), CodeOwnerBackend.class);
 
@@ -45,6 +53,9 @@
     install(new CodeOwnerSubmitRule.Module());
 
     DynamicSet.bind(binder(), ExceptionHook.class).to(CodeOwnersExceptionHook.class);
+    DynamicSet.bind(binder(), OnPostReview.class).to(OnCodeOwnerApproval.class);
+    DynamicSet.bind(binder(), OnPostReview.class).to(OnCodeOwnerOverride.class);
+    DynamicSet.bind(binder(), ReviewerAddedListener.class).to(CodeOwnersOnAddReviewer.class);
   }
 
   @Provides
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/ChangedFiles.java b/java/com/google/gerrit/plugins/codeowners/backend/ChangedFiles.java
index 57e72ef..2835935 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/ChangedFiles.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/ChangedFiles.java
@@ -15,59 +15,125 @@
 package com.google.gerrit.plugins.codeowners.backend;
 
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.collect.ImmutableSet;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.plugins.codeowners.api.MergeCommitStrategy;
-import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.common.ChangedFile;
+import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
+import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.git.InMemoryInserter;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.patch.AutoMerger;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
 import org.eclipse.jgit.diff.DiffEntry;
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.diff.RawTextComparator;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.io.DisabledOutputStream;
 
 /**
- * Class to compute the files that have been changed in a revision.
+ * Class to get/compute the files that have been changed in a revision.
  *
- * <p>The file diff is newly computed on each access and not retrieved from any cache. This is
- * better than using {@link com.google.gerrit.server.patch.PatchListCache} which does a lot of
- * unneeded computations and hence is slower. The Gerrit diff caches are currently being redesigned.
- * Once the envisioned {@code ModifiedFilesCache} is available we should consider using it.
+ * <p>The {@link #getFromDiffCache(Project.NameKey, ObjectId)} method is retrieving the file diff
+ * from the diff cache and has rename detection enabled.
+ *
+ * <p>In contrast to this, for the {@code compute} methods the file diff is newly computed on each
+ * access and rename detection is disabled (as it's too expensive to do it on each access).
+ *
+ * <p>If possible, using {@link #getFromDiffCache(Project.NameKey, ObjectId)} is preferred, however
+ * {@link #getFromDiffCache(Project.NameKey, ObjectId)} cannot be used for newly created commits
+ * that are only available from a specific {@link RevWalk} instance since the {@link RevWalk}
+ * instance cannot be passed in.
+ *
+ * <p>The {@link com.google.gerrit.server.patch.PatchListCache} is deprecated, and hence it not
+ * being used here.
  */
 @Singleton
 public class ChangedFiles {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static int MAX_CHANGED_FILES_TO_LOG = 25;
+
   private final GitRepositoryManager repoManager;
   private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
-  private final PatchListCache patchListCache;
+  private final DiffOperations diffOperations;
+  private final Provider<AutoMerger> autoMergerProvider;
+  private final CodeOwnerMetrics codeOwnerMetrics;
+  private final ThreeWayMergeStrategy mergeStrategy;
+  private final ExperimentFeatures experimentFeatures;
 
   @Inject
   public ChangedFiles(
+      @GerritServerConfig Config cfg,
       GitRepositoryManager repoManager,
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
-      PatchListCache patchListCache) {
+      DiffOperations diffOperations,
+      Provider<AutoMerger> autoMergerProvider,
+      CodeOwnerMetrics codeOwnerMetrics,
+      ExperimentFeatures experimentFeatures) {
     this.repoManager = repoManager;
     this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
-    this.patchListCache = patchListCache;
+    this.diffOperations = diffOperations;
+    this.autoMergerProvider = autoMergerProvider;
+    this.codeOwnerMetrics = codeOwnerMetrics;
+    this.experimentFeatures = experimentFeatures;
+    this.mergeStrategy = MergeUtil.getMergeStrategy(cfg);
+  }
+
+  /**
+   * Returns the changed files for the given revision.
+   *
+   * <p>By default the changed files are computed on access (see {@link #compute(Project.NameKey,
+   * ObjectId)}).
+   *
+   * <p>Only if enabled via the {@link CodeOwnersExperimentFeaturesConstants#USE_DIFF_CACHE}
+   * experiment feature flag the changed files are retrieved from the diff cache (see {@link
+   * #getFromDiffCache(Project.NameKey, ObjectId)}).
+   *
+   * @param project the project
+   * @param revision the revision for which the changed files should be computed
+   * @return the files that have been changed in the given revision, sorted alphabetically by path
+   */
+  public ImmutableList<ChangedFile> getOrCompute(Project.NameKey project, ObjectId revision)
+      throws IOException, PatchListNotAvailableException, DiffNotAvailableException {
+    if (experimentFeatures.isFeatureEnabled(CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)) {
+      if (isInitialCommit(project, revision)) {
+        // DiffOperations doesn't support getting the list of modified files for the initial commit.
+        return compute(project, revision);
+      }
+
+      return getFromDiffCache(project, revision);
+    }
+    return compute(project, revision);
   }
 
   /**
@@ -75,13 +141,15 @@
    *
    * <p>The diff is computed against the parent commit.
    *
+   * <p>Rename detection is disabled.
+   *
    * @param revisionResource the revision resource for which the changed files should be computed
-   * @return the files that have been changed in the given revision
+   * @return the files that have been changed in the given revision, sorted alphabetically by path
    * @throws IOException thrown if the computation fails due to an I/O error
    * @throws PatchListNotAvailableException thrown if getting the patch list for a merge commit
    *     against the auto merge failed
    */
-  public ImmutableSet<ChangedFile> compute(RevisionResource revisionResource)
+  public ImmutableList<ChangedFile> compute(RevisionResource revisionResource)
       throws IOException, PatchListNotAvailableException {
     requireNonNull(revisionResource, "revisionResource");
     return compute(revisionResource.getProject(), revisionResource.getPatchSet().commitId());
@@ -92,14 +160,16 @@
    *
    * <p>The diff is computed against the parent commit.
    *
+   * <p>Rename detection is disabled.
+   *
    * @param project the project
    * @param revision the revision for which the changed files should be computed
-   * @return the files that have been changed in the given revision
+   * @return the files that have been changed in the given revision, sorted alphabetically by path
    * @throws IOException thrown if the computation fails due to an I/O error
    * @throws PatchListNotAvailableException thrown if getting the patch list for a merge commit
    *     against the auto merge failed
    */
-  public ImmutableSet<ChangedFile> compute(Project.NameKey project, ObjectId revision)
+  public ImmutableList<ChangedFile> compute(Project.NameKey project, ObjectId revision)
       throws IOException, PatchListNotAvailableException {
     requireNonNull(project, "project");
     requireNonNull(revision, "revision");
@@ -111,24 +181,24 @@
     }
   }
 
-  public ImmutableSet<ChangedFile> compute(
+  public ImmutableList<ChangedFile> compute(
       Project.NameKey project, Config repoConfig, RevWalk revWalk, RevCommit revCommit)
-      throws IOException, PatchListNotAvailableException {
+      throws IOException {
     return compute(
         project,
         repoConfig,
         revWalk,
         revCommit,
-        codeOwnersPluginConfiguration.getMergeCommitStrategy(project));
+        codeOwnersPluginConfiguration.getProjectConfig(project).getMergeCommitStrategy());
   }
 
-  public ImmutableSet<ChangedFile> compute(
+  public ImmutableList<ChangedFile> compute(
       Project.NameKey project,
       Config repoConfig,
       RevWalk revWalk,
       RevCommit revCommit,
       MergeCommitStrategy mergeCommitStrategy)
-      throws IOException, PatchListNotAvailableException {
+      throws IOException {
     requireNonNull(project, "project");
     requireNonNull(repoConfig, "repoConfig");
     requireNonNull(revWalk, "revWalk");
@@ -140,63 +210,131 @@
 
     if (revCommit.getParentCount() > 1
         && MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION.equals(mergeCommitStrategy)) {
-      return computeByComparingAgainstAutoMerge(project, revCommit);
+      RevCommit autoMergeCommit = getAutoMergeCommit(project, revCommit);
+      return compute(repoConfig, revWalk, revCommit, autoMergeCommit);
     }
 
-    return computeByComparingAgainstFirstParent(repoConfig, revWalk, revCommit);
+    RevCommit baseCommit = revCommit.getParentCount() > 0 ? revCommit.getParent(0) : null;
+    return compute(repoConfig, revWalk, revCommit, baseCommit);
+  }
+
+  private RevCommit getAutoMergeCommit(Project.NameKey project, RevCommit mergeCommit)
+      throws IOException {
+    try (Timer0.Context ctx = codeOwnerMetrics.getAutoMerge.start();
+        Repository repository = repoManager.openRepository(project);
+        InMemoryInserter inserter = new InMemoryInserter(repository);
+        ObjectReader reader = inserter.newReader();
+        RevWalk revWalk = new RevWalk(reader)) {
+      return autoMergerProvider
+          .get()
+          .lookupFromGitOrMergeInMemory(repository, revWalk, inserter, mergeCommit, mergeStrategy);
+    }
   }
 
   /**
-   * Computes the changed files by comparing the given merge commit against the auto merge.
-   *
-   * <p>Since computing the auto merge is expensive, we do not compute it and diff against it on our
-   * own, but rather ask the patch list cache for it.
-   *
-   * @param project the project that contains the merge commit
-   * @param mergeCommit the merge commit for which the changed files should be computed
-   * @return the changed files for the given merge commit
-   */
-  private ImmutableSet<ChangedFile> computeByComparingAgainstAutoMerge(
-      Project.NameKey project, RevCommit mergeCommit) throws PatchListNotAvailableException {
-    checkState(
-        mergeCommit.getParentCount() > 1, "expected %s to be a merge commit", mergeCommit.name());
-
-    // for merge commits the default base is the auto merge commit
-    PatchListKey patchListKey =
-        PatchListKey.againstDefaultBase(mergeCommit, Whitespace.IGNORE_NONE);
-
-    return patchListCache.get(patchListKey, project).getPatches().stream()
-        .filter(
-            patchListEntry ->
-                patchListEntry.getNewName() == null || !Patch.isMagic(patchListEntry.getNewName()))
-        .map(ChangedFile::create)
-        .collect(toImmutableSet());
-  }
-
-  /**
-   * Computes the changed files by comparing the given commit against its first parent.
+   * Computes the changed files by comparing the given commit against the given base commit.
    *
    * <p>The computation also works if the commit doesn't have any parent.
    *
+   * <p>Rename detection is disabled.
+   *
    * @param repoConfig the repository configuration
    * @param revWalk the rev walk
-   * @param revCommit the commit for which the changed files should be computed
-   * @return the changed files for the given commit
+   * @param commit the commit for which the changed files should be computed
+   * @param baseCommit the base commit against which the given commit should be compared, {@code
+   *     null} if the commit doesn't have any parent commit
+   * @return the changed files for the given commit, sorted alphabetically by path
    */
-  private ImmutableSet<ChangedFile> computeByComparingAgainstFirstParent(
-      Config repoConfig, RevWalk revWalk, RevCommit revCommit) throws IOException {
-    RevCommit baseCommit = revCommit.getParentCount() > 0 ? revCommit.getParent(0) : null;
+  private ImmutableList<ChangedFile> compute(
+      Config repoConfig, RevWalk revWalk, RevCommit commit, @Nullable RevCommit baseCommit)
+      throws IOException {
     logger.atFine().log("baseCommit = %s", baseCommit != null ? baseCommit.name() : "n/a");
-
-    try (DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
-      diffFormatter.setReader(revWalk.getObjectReader(), repoConfig);
-      diffFormatter.setDiffComparator(RawTextComparator.DEFAULT);
-      diffFormatter.setDetectRenames(true);
-      List<DiffEntry> diffEntries = diffFormatter.scan(baseCommit, revCommit);
-      ImmutableSet<ChangedFile> changedFiles =
-          diffEntries.stream().map(ChangedFile::create).collect(toImmutableSet());
-      logger.atFine().log("changed files = %s", changedFiles);
-      return changedFiles;
+    try (Timer0.Context ctx = codeOwnerMetrics.computeChangedFiles.start()) {
+      // Detecting renames is expensive (since it requires Git to load and compare file contents of
+      // added and deleted files) and can significantly increase the latency for changes that touch
+      // large files. To avoid this latency we do not enable the rename detection on the
+      // DiffFormater. As a result of this renamed files will be returned as 2 ChangedFile's, one
+      // for the deletion of the old path and one for the addition of the new path.
+      try (DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
+        diffFormatter.setReader(revWalk.getObjectReader(), repoConfig);
+        diffFormatter.setDiffComparator(RawTextComparator.DEFAULT);
+        List<DiffEntry> diffEntries = diffFormatter.scan(baseCommit, commit);
+        ImmutableList<ChangedFile> changedFiles =
+            diffEntries.stream().map(ChangedFile::create).collect(toImmutableList());
+        if (changedFiles.size() <= MAX_CHANGED_FILES_TO_LOG) {
+          logger.atFine().log("changed files = %s", changedFiles);
+        } else {
+          logger.atFine().log(
+              "changed files = %s (and %d more)",
+              changedFiles.asList().subList(0, MAX_CHANGED_FILES_TO_LOG),
+              changedFiles.size() - MAX_CHANGED_FILES_TO_LOG);
+        }
+        return changedFiles;
+      }
     }
   }
+
+  /**
+   * Gets the changed files from the diff cache.
+   *
+   * <p>Doesn't support getting changed files for an initial revision. This is because the diff
+   * cache doesn't support getting changed files for commits that don't have any parent.
+   *
+   * <p>Rename detection is enabled.
+   *
+   * @throws IllegalStateException thrown if invoked for an initial revision
+   */
+  @VisibleForTesting
+  ImmutableList<ChangedFile> getFromDiffCache(Project.NameKey project, ObjectId revision)
+      throws IOException, DiffNotAvailableException {
+    requireNonNull(project, "project");
+    requireNonNull(revision, "revision");
+
+    checkState(!isInitialCommit(project, revision), "diff cache doesn't support initial commits");
+
+    MergeCommitStrategy mergeCommitStrategy =
+        codeOwnersPluginConfiguration.getProjectConfig(project).getMergeCommitStrategy();
+
+    try (Timer0.Context ctx = codeOwnerMetrics.getChangedFiles.start()) {
+      Map<String, FileDiffOutput> fileDiffOutputs;
+      if (mergeCommitStrategy.equals(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION)) {
+        // Use parentNum=null to do the comparison against the default base.
+        // For non-merge commits the default base is the only parent (aka parent 1, initial commits
+        // are not supported).
+        // For merge commits the default base is the auto-merge commit which should be used as base
+        // if the merge commit strategy is FILES_WITH_CONFLICT_RESOLUTION.
+        fileDiffOutputs =
+            diffOperations.listModifiedFilesAgainstParent(project, revision, /* parentNum=*/ null);
+      } else {
+        checkState(mergeCommitStrategy.equals(MergeCommitStrategy.ALL_CHANGED_FILES));
+        // Always use parent 1 to do the comparison.
+        // Non-merge commits should always be compared against against the first parent (initial
+        // commits are not supported).
+        // For merge commits also the first parent should be used if the merge commit strategy is
+        // ALL_CHANGED_FILES.
+        fileDiffOutputs = diffOperations.listModifiedFilesAgainstParent(project, revision, 1);
+      }
+
+      return toChangedFiles(filterOutMagicFilesAndSort(fileDiffOutputs)).collect(toImmutableList());
+    }
+  }
+
+  private boolean isInitialCommit(Project.NameKey project, ObjectId objectId) throws IOException {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      return revWalk.parseCommit(objectId).getParentCount() == 0;
+    }
+  }
+
+  private Stream<Map.Entry<String, FileDiffOutput>> filterOutMagicFilesAndSort(
+      Map<String, FileDiffOutput> fileDiffOutputs) {
+    return fileDiffOutputs.entrySet().stream()
+        .filter(e -> !Patch.isMagic(e.getKey()))
+        .sorted(comparing(Map.Entry::getKey));
+  }
+
+  private Stream<ChangedFile> toChangedFiles(
+      Stream<Map.Entry<String, FileDiffOutput>> fileDiffOutputs) {
+    return fileDiffOutputs.map(Map.Entry::getValue).map(ChangedFile::create);
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
index 2751067..65f4d78 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
@@ -15,33 +15,41 @@
 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 com.google.common.collect.ImmutableSet.toImmutableSet;
 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.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;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-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.metrics.Timer0;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginProjectConfigSnapshot;
+import com.google.gerrit.plugins.codeowners.backend.config.RequiredApproval;
+import com.google.gerrit.plugins.codeowners.common.ChangedFile;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.logging.Metadata;
-import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.git.PureRevertCache;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 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.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -52,7 +60,6 @@
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.stream.Stream;
-import java.util.stream.StreamSupport;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -81,10 +88,11 @@
   private final GitRepositoryManager repoManager;
   private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
   private final ChangedFiles changedFiles;
-  private final CodeOwnerConfigScanner.Factory codeOwnerConfigScannerFactory;
-  private final CodeOwnerConfigHierarchy codeOwnerConfigHierarchy;
+  private final PureRevertCache pureRevertCache;
+  private final Provider<CodeOwnerConfigHierarchy> codeOwnerConfigHierarchyProvider;
   private final Provider<CodeOwnerResolver> codeOwnerResolver;
   private final ApprovalsUtil approvalsUtil;
+  private final CodeOwnerMetrics codeOwnerMetrics;
 
   @Inject
   CodeOwnerApprovalCheck(
@@ -92,18 +100,72 @@
       GitRepositoryManager repoManager,
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       ChangedFiles changedFiles,
-      CodeOwnerConfigScanner.Factory codeOwnerConfigScannerFactory,
-      CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
+      PureRevertCache pureRevertCache,
+      Provider<CodeOwnerConfigHierarchy> codeOwnerConfigHierarchyProvider,
       Provider<CodeOwnerResolver> codeOwnerResolver,
-      ApprovalsUtil approvalsUtil) {
+      ApprovalsUtil approvalsUtil,
+      CodeOwnerMetrics codeOwnerMetrics) {
     this.permissionBackend = permissionBackend;
     this.repoManager = repoManager;
     this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
     this.changedFiles = changedFiles;
-    this.codeOwnerConfigScannerFactory = codeOwnerConfigScannerFactory;
-    this.codeOwnerConfigHierarchy = codeOwnerConfigHierarchy;
+    this.pureRevertCache = pureRevertCache;
+    this.codeOwnerConfigHierarchyProvider = codeOwnerConfigHierarchyProvider;
     this.codeOwnerResolver = codeOwnerResolver;
     this.approvalsUtil = approvalsUtil;
+    this.codeOwnerMetrics = codeOwnerMetrics;
+  }
+
+  /**
+   * Returns the paths of the files in the given patch set that are owned by the specified account.
+   *
+   * @param changeNotes the change notes for which the owned files should be returned
+   * @param patchSet the patch set for which the owned files should be returned
+   * @param accountId account ID of the code owner for which the owned files should be returned
+   * @param start number of owned paths to skip
+   * @param limit the max number of owned paths that should be returned (0 = unlimited)
+   * @return the paths of the files in the given patch set that are owned by the specified account
+   * @throws ResourceConflictException if the destination branch of the change no longer exists
+   */
+  public ImmutableList<Path> getOwnedPaths(
+      ChangeNotes changeNotes, PatchSet patchSet, Account.Id accountId, int start, int limit)
+      throws ResourceConflictException {
+    try (Timer0.Context ctx = codeOwnerMetrics.computeOwnedPaths.start()) {
+      logger.atFine().log(
+          "compute owned paths for account %d (project = %s, change = %d, patch set = %d,"
+              + " start = %d, limit = %d)",
+          accountId.get(),
+          changeNotes.getProjectName(),
+          changeNotes.getChangeId().get(),
+          patchSet.id().get(),
+          start,
+          limit);
+      Stream<Path> ownedPaths =
+          getFileStatusesForAccount(changeNotes, patchSet, accountId)
+              .flatMap(
+                  fileCodeOwnerStatus ->
+                      Stream.of(
+                              fileCodeOwnerStatus.newPathStatus(),
+                              fileCodeOwnerStatus.oldPathStatus())
+                          .filter(Optional::isPresent)
+                          .map(Optional::get))
+              .filter(
+                  pathCodeOwnerStatus -> pathCodeOwnerStatus.status() == CodeOwnerStatus.APPROVED)
+              .map(PathCodeOwnerStatus::path);
+      if (start > 0) {
+        ownedPaths = ownedPaths.skip(start);
+      }
+      if (limit > 0) {
+        ownedPaths = ownedPaths.limit(limit);
+      }
+      return ownedPaths.collect(toImmutableList());
+    } catch (IOException | PatchListNotAvailableException | DiffNotAvailableException e) {
+      throw new CodeOwnersInternalServerErrorException(
+          String.format(
+              "failed to compute owned paths of patch set %s for account %d",
+              patchSet.id(), accountId.get()),
+          e);
+    }
   }
 
   /**
@@ -113,27 +175,65 @@
    * @return whether the given change has sufficient code owner approvals to be submittable
    */
   public boolean isSubmittable(ChangeNotes changeNotes)
-      throws ResourceConflictException, IOException, PatchListNotAvailableException {
+      throws ResourceConflictException, IOException, PatchListNotAvailableException,
+          DiffNotAvailableException {
     requireNonNull(changeNotes, "changeNotes");
     logger.atFine().log(
         "checking if change %d in project %s is submittable",
         changeNotes.getChangeId().get(), changeNotes.getProjectName());
-    boolean isSubmittable =
-        !getFileStatuses(changeNotes)
-            .anyMatch(
-                fileStatus ->
-                    (fileStatus.newPathStatus().isPresent()
-                            && fileStatus.newPathStatus().get().status()
-                                != CodeOwnerStatus.APPROVED)
-                        || (fileStatus.oldPathStatus().isPresent()
-                            && fileStatus.oldPathStatus().get().status()
-                                != CodeOwnerStatus.APPROVED));
-    logger.atFine().log(
-        "change %d in project %s %s submittable",
-        changeNotes.getChangeId().get(),
-        changeNotes.getProjectName(),
-        isSubmittable ? "is" : "is not");
-    return isSubmittable;
+    CodeOwnerConfigHierarchy codeOwnerConfigHierarchy = codeOwnerConfigHierarchyProvider.get();
+    try {
+      boolean isSubmittable =
+          !getFileStatuses(codeOwnerConfigHierarchy, changeNotes)
+              .anyMatch(
+                  fileStatus ->
+                      (fileStatus.newPathStatus().isPresent()
+                              && fileStatus.newPathStatus().get().status()
+                                  != CodeOwnerStatus.APPROVED)
+                          || (fileStatus.oldPathStatus().isPresent()
+                              && fileStatus.oldPathStatus().get().status()
+                                  != CodeOwnerStatus.APPROVED));
+      logger.atFine().log(
+          "change %d in project %s %s submittable",
+          changeNotes.getChangeId().get(),
+          changeNotes.getProjectName(),
+          isSubmittable ? "is" : "is not");
+      return isSubmittable;
+    } finally {
+      codeOwnerMetrics.codeOwnerConfigBackendReadsPerChange.record(
+          codeOwnerConfigHierarchy.getCodeOwnerConfigCounters().getBackendReadCount());
+      codeOwnerMetrics.codeOwnerConfigCacheReadsPerChange.record(
+          codeOwnerConfigHierarchy.getCodeOwnerConfigCounters().getCacheReadCount());
+    }
+  }
+
+  /**
+   * Gets the code owner statuses for all files/paths that were changed in the current revision of
+   * the given change as a set.
+   *
+   * @param start number of file statuses to skip
+   * @param limit the max number of file statuses that should be returned (0 = unlimited)
+   * @see #getFileStatuses(CodeOwnerConfigHierarchy, ChangeNotes)
+   */
+  public ImmutableSet<FileCodeOwnerStatus> getFileStatusesAsSet(
+      ChangeNotes changeNotes, int start, int limit)
+      throws ResourceConflictException, IOException, PatchListNotAvailableException,
+          DiffNotAvailableException {
+    requireNonNull(changeNotes, "changeNotes");
+    try (Timer0.Context ctx = codeOwnerMetrics.computeFileStatuses.start()) {
+      logger.atFine().log(
+          "compute file statuses (project = %s, change = %d, start = %d, limit = %d)",
+          changeNotes.getProjectName(), changeNotes.getChangeId().get(), start, limit);
+      Stream<FileCodeOwnerStatus> fileStatuses =
+          getFileStatuses(codeOwnerConfigHierarchyProvider.get(), changeNotes);
+      if (start > 0) {
+        fileStatuses = fileStatuses.skip(start);
+      }
+      if (limit > 0) {
+        fileStatuses = fileStatuses.limit(limit);
+      }
+      return fileStatuses.collect(toImmutableSet());
+    }
   }
 
   /**
@@ -159,35 +259,72 @@
    *       approvals that were present on an old revision) would only confuse users
    * </ul>
    *
+   * @param codeOwnerConfigHierarchy {@link CodeOwnerConfigHierarchy} instance that should be used
+   *     to iterate over code owner config hierarchies
    * @param changeNotes the notes of the change for which the current code owner statuses should be
    *     returned
    */
-  public Stream<FileCodeOwnerStatus> getFileStatuses(ChangeNotes changeNotes)
-      throws ResourceConflictException, IOException, PatchListNotAvailableException {
+  private Stream<FileCodeOwnerStatus> getFileStatuses(
+      CodeOwnerConfigHierarchy codeOwnerConfigHierarchy, ChangeNotes changeNotes)
+      throws ResourceConflictException, IOException, PatchListNotAvailableException,
+          DiffNotAvailableException {
     requireNonNull(changeNotes, "changeNotes");
-    try (TraceTimer traceTimer =
-        TraceContext.newTimer(
-            "Compute file statuses",
-            Metadata.builder()
-                .projectName(changeNotes.getProjectName().get())
-                .changeId(changeNotes.getChangeId().get())
-                .build())) {
-      boolean enableImplicitApprovalFromUploader =
-          codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(changeNotes.getProjectName());
-      Account.Id patchSetUploader = changeNotes.getCurrentPatchSet().uploader();
+    try (Timer0.Context ctx = codeOwnerMetrics.prepareFileStatusComputation.start()) {
       logger.atFine().log(
-          "patchSetUploader = %d, implicit approval from uploader is %s",
-          patchSetUploader.get(), enableImplicitApprovalFromUploader ? "enabled" : "disabled");
+          "prepare stream to compute file statuses (project = %s, change = %d)",
+          changeNotes.getProjectName(), changeNotes.getChangeId().get());
 
-      RequiredApproval requiredApproval =
-          codeOwnersPluginConfiguration.getRequiredApproval(changeNotes.getProjectName());
+      CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig =
+          codeOwnersPluginConfiguration.getProjectConfig(changeNotes.getProjectName());
+
+      Account.Id changeOwner = changeNotes.getChange().getOwner();
+      Account.Id patchSetUploader = changeNotes.getCurrentPatchSet().uploader();
+      ImmutableSet<Account.Id> exemptedAccounts = codeOwnersConfig.getExemptedAccounts();
+      logger.atFine().log("exemptedAccounts = %s", exemptedAccounts);
+      if (exemptedAccounts.contains(patchSetUploader)) {
+        logger.atFine().log(
+            "patch set uploader %d is exempted from requiring code owner approvals",
+            patchSetUploader.get());
+        return getAllPathsAsApproved(changeNotes, changeNotes.getCurrentPatchSet());
+      }
+
+      boolean arePureRevertsExempted = codeOwnersConfig.arePureRevertsExempted();
+      logger.atFine().log("arePureRevertsExempted = %s", arePureRevertsExempted);
+      if (arePureRevertsExempted && isPureRevert(changeNotes)) {
+        logger.atFine().log(
+            "change is a pure revert and is exempted from requiring code owner approvals");
+        return getAllPathsAsApproved(changeNotes, changeNotes.getCurrentPatchSet());
+      }
+
+      boolean implicitApprovalConfig = codeOwnersConfig.areImplicitApprovalsEnabled();
+      boolean enableImplicitApproval =
+          implicitApprovalConfig && changeOwner.equals(patchSetUploader);
+      logger.atFine().log(
+          "changeOwner = %d, patchSetUploader = %d, implict approval config = %s\n=> implicit approval is %s",
+          changeOwner.get(),
+          patchSetUploader.get(),
+          implicitApprovalConfig,
+          enableImplicitApproval ? "enabled" : "disabled");
+
+      ImmutableList<PatchSetApproval> currentPatchSetApprovals =
+          getCurrentPatchSetApprovals(changeNotes);
+
+      RequiredApproval requiredApproval = codeOwnersConfig.getRequiredApproval();
       logger.atFine().log("requiredApproval = %s", requiredApproval);
 
-      ImmutableSet<RequiredApproval> overrideApprovals =
-          codeOwnersPluginConfiguration.getOverrideApproval(changeNotes.getProjectName());
-      boolean hasOverride = hasOverride(overrideApprovals, changeNotes, patchSetUploader);
+      ImmutableSet<RequiredApproval> overrideApprovals = codeOwnersConfig.getOverrideApprovals();
+      boolean hasOverride =
+          hasOverride(currentPatchSetApprovals, overrideApprovals, patchSetUploader);
       logger.atFine().log(
-          "hasOverride = %s (overrideApprovals = %s)", hasOverride, overrideApprovals);
+          "hasOverride = %s (overrideApprovals = %s)",
+          hasOverride,
+          overrideApprovals.stream()
+              .map(
+                  overrideApproval ->
+                      String.format(
+                          "%s (ignoreSelfApproval = %s)",
+                          overrideApproval, overrideApproval.labelType().isIgnoreSelfApproval()))
+              .collect(toImmutableList()));
 
       BranchNameKey branch = changeNotes.getChange().getDest();
       ObjectId revision = getDestBranchRevision(changeNotes.getChange());
@@ -200,34 +337,29 @@
               .resolveGlobalCodeOwners(changeNotes.getProjectName());
       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.
-      boolean isBootstrapping =
-          !codeOwnerConfigScannerFactory.create().containsAnyCodeOwnerConfigFile(branch);
-      logger.atFine().log("isBootstrapping = %s", isBootstrapping);
-
       ImmutableSet<Account.Id> reviewerAccountIds =
           getReviewerAccountIds(requiredApproval, changeNotes, patchSetUploader);
       ImmutableSet<Account.Id> approverAccountIds =
-          getApproverAccountIds(requiredApproval, changeNotes, patchSetUploader);
+          getApproverAccountIds(currentPatchSetApprovals, requiredApproval, patchSetUploader);
       logger.atFine().log("reviewers = %s, approvers = %s", reviewerAccountIds, approverAccountIds);
 
+      FallbackCodeOwners fallbackCodeOwners = codeOwnersConfig.getFallbackCodeOwners();
+
       return changedFiles
-          .compute(changeNotes.getProjectName(), changeNotes.getCurrentPatchSet().commitId())
+          .getOrCompute(changeNotes.getProjectName(), changeNotes.getCurrentPatchSet().commitId())
           .stream()
           .map(
               changedFile ->
                   getFileStatus(
+                      codeOwnerConfigHierarchy,
                       branch,
                       revision,
                       globalCodeOwners,
-                      enableImplicitApprovalFromUploader,
-                      patchSetUploader,
+                      enableImplicitApproval ? changeOwner : null,
                       reviewerAccountIds,
                       approverAccountIds,
+                      fallbackCodeOwners,
                       hasOverride,
-                      isBootstrapping,
                       changedFile));
     }
   }
@@ -241,66 +373,51 @@
    * <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 changeNotes the notes of the change for which the code owner statuses should be returned
+   * @param patchSet the patch set for which the code owner statuses should be returned
    * @param accountId the ID of the account for which an approval should be assumed
    */
+  @VisibleForTesting
   public Stream<FileCodeOwnerStatus> getFileStatusesForAccount(
-      ChangeNotes changeNotes, Account.Id accountId)
-      throws ResourceConflictException, IOException, PatchListNotAvailableException {
+      ChangeNotes changeNotes, PatchSet patchSet, Account.Id accountId)
+      throws ResourceConflictException, IOException, PatchListNotAvailableException,
+          DiffNotAvailableException {
     requireNonNull(changeNotes, "changeNotes");
+    requireNonNull(patchSet, "patchSet");
     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());
+    try (Timer0.Context ctx = codeOwnerMetrics.prepareFileStatusComputationForAccount.start()) {
+      logger.atFine().log(
+          "prepare stream to compute file statuses for account %d (project = %s, change = %d,"
+              + " patch set = %d)",
+          accountId.get(),
+          changeNotes.getProjectName(),
+          changeNotes.getChangeId().get(),
+          patchSet.id().get());
+
+      CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig =
+          codeOwnersPluginConfiguration.getProjectConfig(changeNotes.getProjectName());
+
+      RequiredApproval requiredApproval = codeOwnersConfig.getRequiredApproval();
       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);
+      FallbackCodeOwners fallbackCodeOwners = codeOwnersConfig.getFallbackCodeOwners();
       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))));
+          "fallbackCodeOwner = %s, isProjectOwner = %s", fallbackCodeOwners, isProjectOwner);
+      if (fallbackCodeOwners.equals(FallbackCodeOwners.PROJECT_OWNERS) && isProjectOwner) {
+        return getAllPathsAsApproved(changeNotes, patchSet);
       }
 
-      return changedFiles
-          .compute(changeNotes.getProjectName(), changeNotes.getCurrentPatchSet().commitId())
-          .stream()
+      CodeOwnerConfigHierarchy codeOwnerConfigHierarchy = codeOwnerConfigHierarchyProvider.get();
+      return changedFiles.getOrCompute(changeNotes.getProjectName(), patchSet.commitId()).stream()
           .map(
               changedFile ->
                   getFileStatus(
+                      codeOwnerConfigHierarchy,
                       branch,
                       revision,
                       /* globalCodeOwners= */ CodeOwnerResolverResult.createEmpty(),
@@ -308,86 +425,121 @@
                       // 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,
+                      /* implicitApprover= */ null,
                       /* reviewerAccountIds= */ ImmutableSet.of(),
                       // Assume an explicit approval of the given account.
                       /* approverAccountIds= */ ImmutableSet.of(accountId),
+                      fallbackCodeOwners,
                       /* hasOverride= */ false,
-                      /* isBootstrapping= */ false,
                       changedFile));
     }
   }
 
+  private boolean isPureRevert(ChangeNotes changeNotes) throws IOException {
+    try {
+      return changeNotes.getChange().getRevertOf() != null
+          && pureRevertCache.isPureRevert(changeNotes);
+    } catch (BadRequestException e) {
+      throw new CodeOwnersInternalServerErrorException(
+          String.format(
+              "failed to check if change %s in project %s is a pure revert",
+              changeNotes.getChangeId(), changeNotes.getProjectName()),
+          e);
+    }
+  }
+
+  private Stream<FileCodeOwnerStatus> getAllPathsAsApproved(
+      ChangeNotes changeNotes, PatchSet patchSet)
+      throws IOException, PatchListNotAvailableException, DiffNotAvailableException {
+    return changedFiles.getOrCompute(changeNotes.getProjectName(), patchSet.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))));
+  }
+
   private FileCodeOwnerStatus getFileStatus(
+      CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
       BranchNameKey branch,
       ObjectId revision,
       CodeOwnerResolverResult globalCodeOwners,
-      boolean enableImplicitApprovalFromUploader,
-      @Nullable Account.Id patchSetUploader,
+      @Nullable Account.Id implicitApprover,
       ImmutableSet<Account.Id> reviewerAccountIds,
       ImmutableSet<Account.Id> approverAccountIds,
+      FallbackCodeOwners fallbackCodeOwners,
       boolean hasOverride,
-      boolean isBootstrapping,
       ChangedFile changedFile) {
-    logger.atFine().log("computing file status for %s", changedFile);
+    try (Timer0.Context ctx = codeOwnerMetrics.computeFileStatus.start()) {
+      logger.atFine().log("computing file status for %s", changedFile);
 
-    // Compute the code owner status for the new path, if there is a new path.
-    Optional<PathCodeOwnerStatus> newPathStatus =
-        changedFile
-            .newPath()
-            .map(
-                newPath ->
-                    getPathCodeOwnerStatus(
-                        branch,
-                        revision,
-                        globalCodeOwners,
-                        enableImplicitApprovalFromUploader,
-                        patchSetUploader,
-                        reviewerAccountIds,
-                        approverAccountIds,
-                        hasOverride,
-                        isBootstrapping,
-                        newPath));
+      // Compute the code owner status for the new path, if there is a new path.
+      Optional<PathCodeOwnerStatus> newPathStatus =
+          changedFile
+              .newPath()
+              .map(
+                  newPath ->
+                      getPathCodeOwnerStatus(
+                          codeOwnerConfigHierarchy,
+                          branch,
+                          revision,
+                          globalCodeOwners,
+                          implicitApprover,
+                          reviewerAccountIds,
+                          approverAccountIds,
+                          fallbackCodeOwners,
+                          hasOverride,
+                          newPath));
 
-    // Compute the code owner status for the old path, if the file was deleted or renamed.
-    Optional<PathCodeOwnerStatus> oldPathStatus = Optional.empty();
-    if (changedFile.isDeletion() || changedFile.isRename()) {
-      checkState(changedFile.oldPath().isPresent(), "old path must be present for deletion/rename");
-      logger.atFine().log(
-          "file was %s (old path = %s)",
-          changedFile.isDeletion() ? "deleted" : "renamed", changedFile.oldPath().get());
-      oldPathStatus =
-          Optional.of(
-              getPathCodeOwnerStatus(
-                  branch,
-                  revision,
-                  globalCodeOwners,
-                  enableImplicitApprovalFromUploader,
-                  patchSetUploader,
-                  reviewerAccountIds,
-                  approverAccountIds,
-                  hasOverride,
-                  isBootstrapping,
-                  changedFile.oldPath().get()));
+      // Compute the code owner status for the old path, if the file was deleted or renamed.
+      Optional<PathCodeOwnerStatus> oldPathStatus = Optional.empty();
+      if (changedFile.isDeletion() || changedFile.isRename()) {
+        checkState(
+            changedFile.oldPath().isPresent(), "old path must be present for deletion/rename");
+        logger.atFine().log(
+            "file was %s (old path = %s)",
+            changedFile.isDeletion() ? "deleted" : "renamed", changedFile.oldPath().get());
+        oldPathStatus =
+            Optional.of(
+                getPathCodeOwnerStatus(
+                    codeOwnerConfigHierarchy,
+                    branch,
+                    revision,
+                    globalCodeOwners,
+                    implicitApprover,
+                    reviewerAccountIds,
+                    approverAccountIds,
+                    fallbackCodeOwners,
+                    hasOverride,
+                    changedFile.oldPath().get()));
+      }
+
+      FileCodeOwnerStatus fileCodeOwnerStatus =
+          FileCodeOwnerStatus.create(changedFile, newPathStatus, oldPathStatus);
+      logger.atFine().log("fileCodeOwnerStatus = %s", fileCodeOwnerStatus);
+      return fileCodeOwnerStatus;
     }
-
-    FileCodeOwnerStatus fileCodeOwnerStatus =
-        FileCodeOwnerStatus.create(changedFile, newPathStatus, oldPathStatus);
-    logger.atFine().log("fileCodeOwnerStatus = %s", fileCodeOwnerStatus);
-    return fileCodeOwnerStatus;
   }
 
   private PathCodeOwnerStatus getPathCodeOwnerStatus(
+      CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
       BranchNameKey branch,
       ObjectId revision,
       CodeOwnerResolverResult globalCodeOwners,
-      boolean enableImplicitApprovalFromUploader,
-      @Nullable Account.Id patchSetUploader,
+      @Nullable Account.Id implicitApprover,
       ImmutableSet<Account.Id> reviewerAccountIds,
       ImmutableSet<Account.Id> approverAccountIds,
+      FallbackCodeOwners fallbackCodeOwners,
       boolean hasOverride,
-      boolean isBootstrapping,
       Path absolutePath) {
     logger.atFine().log("computing path status for %s", absolutePath);
 
@@ -398,99 +550,134 @@
       return PathCodeOwnerStatus.create(absolutePath, CodeOwnerStatus.APPROVED);
     }
 
-    return isBootstrapping
-        ? getPathCodeOwnerStatusBootstrappingMode(
-            branch,
-            globalCodeOwners,
-            enableImplicitApprovalFromUploader,
-            patchSetUploader,
-            reviewerAccountIds,
-            approverAccountIds,
-            absolutePath)
-        : getPathCodeOwnerStatusRegularMode(
-            branch,
-            globalCodeOwners,
-            enableImplicitApprovalFromUploader,
-            patchSetUploader,
-            revision,
-            reviewerAccountIds,
-            approverAccountIds,
-            absolutePath);
-  }
+    AtomicReference<CodeOwnerStatus> codeOwnerStatus =
+        new AtomicReference<>(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
 
-  /**
-   * Gets the code owner status for the given path when the branch doesn't contain any code owner
-   * config file yet (bootstrapping mode).
-   *
-   * <p>If we are in bootstrapping mode we consider project owners as code owners. This allows
-   * bootstrapping the code owner configuration in the branch.
-   */
-  private PathCodeOwnerStatus getPathCodeOwnerStatusBootstrappingMode(
-      BranchNameKey branch,
-      CodeOwnerResolverResult globalCodeOwners,
-      boolean enableImplicitApprovalFromUploader,
-      @Nullable Account.Id patchSetUploader,
-      ImmutableSet<Account.Id> reviewerAccountIds,
-      ImmutableSet<Account.Id> approverAccountIds,
-      Path absolutePath) {
-    logger.atFine().log("computing path status for %s (bootstrapping mode)", absolutePath);
+    if (isApproved(absolutePath, globalCodeOwners, approverAccountIds, implicitApprover)) {
+      logger.atFine().log("%s was approved by a global code owner", absolutePath);
+      codeOwnerStatus.set(CodeOwnerStatus.APPROVED);
+    } else {
+      logger.atFine().log("%s was not approved by a global code owner", absolutePath);
 
-    CodeOwnerStatus codeOwnerStatus = CodeOwnerStatus.INSUFFICIENT_REVIEWERS;
-    if (isApprovedBootstrappingMode(
-        branch.project(),
-        absolutePath,
-        globalCodeOwners,
-        approverAccountIds,
-        enableImplicitApprovalFromUploader,
-        patchSetUploader)) {
-      codeOwnerStatus = CodeOwnerStatus.APPROVED;
-    } else if (isPendingBootstrappingMode(
-        branch.project(), absolutePath, globalCodeOwners, reviewerAccountIds)) {
-      codeOwnerStatus = CodeOwnerStatus.PENDING;
-    }
+      if (isPending(absolutePath, globalCodeOwners, reviewerAccountIds)) {
+        logger.atFine().log("%s is owned by a reviewer who is a global owner", absolutePath);
+        codeOwnerStatus.set(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);
+      AtomicBoolean hasRevelantCodeOwnerDefinitions = new AtomicBoolean(false);
+      AtomicBoolean parentCodeOwnersAreIgnored = new AtomicBoolean(false);
+      codeOwnerConfigHierarchy.visitForFile(
+          branch,
+          revision,
+          absolutePath,
+          (PathCodeOwnersVisitor)
+              pathCodeOwners -> {
+                CodeOwnerResolverResult codeOwners = resolveCodeOwners(pathCodeOwners);
+                logger.atFine().log(
+                    "code owners = %s (code owner config folder path = %s, file name = %s)",
+                    codeOwners,
+                    pathCodeOwners.getCodeOwnerConfig().key().folderPath(),
+                    pathCodeOwners.getCodeOwnerConfig().key().fileName().orElse("<default>"));
+
+                if (codeOwners.hasRevelantCodeOwnerDefinitions()) {
+                  hasRevelantCodeOwnerDefinitions.set(true);
+                }
+
+                if (isApproved(absolutePath, codeOwners, approverAccountIds, implicitApprover)) {
+                  codeOwnerStatus.set(CodeOwnerStatus.APPROVED);
+                  return false;
+                } else if (isPending(absolutePath, codeOwners, reviewerAccountIds)) {
+                  codeOwnerStatus.set(CodeOwnerStatus.PENDING);
+
+                  // We need to continue to check if any of the higher-level code owners approved
+                  // the change.
+                  return true;
+                }
+
+                // 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,
+                globalCodeOwners,
+                implicitApprover,
+                reviewerAccountIds,
+                approverAccountIds,
+                fallbackCodeOwners,
+                absolutePath));
+      }
     }
 
     PathCodeOwnerStatus pathCodeOwnerStatus =
-        PathCodeOwnerStatus.create(absolutePath, codeOwnerStatus);
+        PathCodeOwnerStatus.create(absolutePath, codeOwnerStatus.get());
     logger.atFine().log("pathCodeOwnerStatus = %s", pathCodeOwnerStatus);
     return pathCodeOwnerStatus;
   }
 
-  private boolean isApprovedBootstrappingMode(
+  /**
+   * Gets the code owner status for the given path when project owners are configured as fallback
+   * code owners.
+   */
+  private CodeOwnerStatus getCodeOwnerStatusForProjectOwnersAsFallbackCodeOwners(
+      BranchNameKey branch,
+      CodeOwnerResolverResult globalCodeOwners,
+      @Nullable Account.Id implicitApprover,
+      ImmutableSet<Account.Id> reviewerAccountIds,
+      ImmutableSet<Account.Id> approverAccountIds,
+      Path absolutePath) {
+    logger.atFine().log(
+        "computing code owner status for %s with project owners as fallback code owners",
+        absolutePath);
+
+    CodeOwnerStatus codeOwnerStatus = CodeOwnerStatus.INSUFFICIENT_REVIEWERS;
+    if (isApprovedByProjectOwnerOrGlobalOwner(
+        branch.project(), absolutePath, globalCodeOwners, approverAccountIds, implicitApprover)) {
+      codeOwnerStatus = CodeOwnerStatus.APPROVED;
+    } else if (isPendingByProjectOwnerOrGlobalOwner(
+        branch.project(), absolutePath, globalCodeOwners, reviewerAccountIds)) {
+      codeOwnerStatus = CodeOwnerStatus.PENDING;
+    }
+
+    logger.atFine().log("codeOwnerStatus = %s", codeOwnerStatus);
+    return codeOwnerStatus;
+  }
+
+  private boolean isApprovedByProjectOwnerOrGlobalOwner(
       Project.NameKey projectName,
       Path absolutePath,
       CodeOwnerResolverResult globalCodeOwners,
       ImmutableSet<Account.Id> approverAccountIds,
-      boolean enableImplicitApprovalFromUploader,
-      @Nullable Account.Id patchSetUploader) {
-    return (enableImplicitApprovalFromUploader
-            && isImplicitlyApprovedBootstrappingMode(
-                projectName, absolutePath, globalCodeOwners, patchSetUploader))
-        || isExplicitlyApprovedBootstrappingMode(
+      @Nullable Account.Id implicitApprover) {
+    return (implicitApprover != null
+            && isImplicitlyApprovedByProjectOwnerOrGlobalOwner(
+                projectName, absolutePath, globalCodeOwners, implicitApprover))
+        || isExplicitlyApprovedByProjectOwnerOrGlobalOwner(
             projectName, absolutePath, globalCodeOwners, approverAccountIds);
   }
 
-  private boolean isImplicitlyApprovedBootstrappingMode(
+  private boolean isImplicitlyApprovedByProjectOwnerOrGlobalOwner(
       Project.NameKey projectName,
       Path absolutePath,
       CodeOwnerResolverResult globalCodeOwners,
-      Account.Id patchSetUploader) {
-    requireNonNull(
-        patchSetUploader, "patchSetUploader must be set if implicit approvals are enabled");
-    if (isProjectOwner(projectName, patchSetUploader)) {
+      Account.Id implicitApprover) {
+    requireNonNull(implicitApprover, "implicitApprover");
+    if (isProjectOwner(projectName, implicitApprover)) {
       // 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
       // automatically approved.
@@ -501,7 +688,7 @@
     }
 
     if (globalCodeOwners.ownedByAllUsers()
-        || globalCodeOwners.codeOwnersAccountIds().contains(patchSetUploader)) {
+        || globalCodeOwners.codeOwnersAccountIds().contains(implicitApprover)) {
       // If the uploader of the patch set is a global code owner, there is an implicit code owner
       // approval from the patch set uploader so that the path is automatically approved.
       logger.atFine().log(
@@ -513,7 +700,7 @@
     return false;
   }
 
-  private boolean isExplicitlyApprovedBootstrappingMode(
+  private boolean isExplicitlyApprovedByProjectOwnerOrGlobalOwner(
       Project.NameKey projectName,
       Path absolutePath,
       CodeOwnerResolverResult globalCodeOwners,
@@ -535,7 +722,7 @@
     return false;
   }
 
-  private boolean isPendingBootstrappingMode(
+  private boolean isPendingByProjectOwnerOrGlobalOwner(
       Project.NameKey projectName,
       Path absolutePath,
       CodeOwnerResolverResult globalCodeOwners,
@@ -557,119 +744,17 @@
   }
 
   /**
-   * Gets the code owner status for the given path when the branch contains at least one code owner
-   * config file (regular mode).
-   */
-  private PathCodeOwnerStatus getPathCodeOwnerStatusRegularMode(
-      BranchNameKey branch,
-      CodeOwnerResolverResult globalCodeOwners,
-      boolean enableImplicitApprovalFromUploader,
-      @Nullable Account.Id patchSetUploader,
-      ObjectId revision,
-      ImmutableSet<Account.Id> reviewerAccountIds,
-      ImmutableSet<Account.Id> approverAccountIds,
-      Path absolutePath) {
-    logger.atFine().log("computing path status for %s (regular mode)", absolutePath);
-
-    AtomicReference<CodeOwnerStatus> codeOwnerStatus =
-        new AtomicReference<>(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-
-    if (isApproved(
-        absolutePath,
-        globalCodeOwners,
-        approverAccountIds,
-        enableImplicitApprovalFromUploader,
-        patchSetUploader)) {
-      logger.atFine().log("%s was approved by a global code owner", absolutePath);
-      codeOwnerStatus.set(CodeOwnerStatus.APPROVED);
-    } else {
-      logger.atFine().log("%s was not approved by a global code owner", absolutePath);
-
-      if (isPending(absolutePath, globalCodeOwners, reviewerAccountIds)) {
-        logger.atFine().log("%s is owned by a reviewer who is a global owner", absolutePath);
-        codeOwnerStatus.set(CodeOwnerStatus.PENDING);
-      }
-
-      AtomicBoolean hasRevelantCodeOwnerDefinitions = new AtomicBoolean(false);
-      AtomicBoolean parentCodeOwnersAreIgnored = new AtomicBoolean(false);
-      codeOwnerConfigHierarchy.visit(
-          branch,
-          revision,
-          absolutePath,
-          codeOwnerConfig -> {
-            CodeOwnerResolverResult codeOwners = getCodeOwners(codeOwnerConfig, absolutePath);
-            logger.atFine().log(
-                "code owners = %s (code owner config folder path = %s, file name = %s)",
-                codeOwners,
-                codeOwnerConfig.key().folderPath(),
-                codeOwnerConfig.key().fileName().orElse("<default>"));
-
-            if (codeOwners.hasRevelantCodeOwnerDefinitions()) {
-              hasRevelantCodeOwnerDefinitions.set(true);
-            }
-
-            if (isApproved(
-                absolutePath,
-                codeOwners,
-                approverAccountIds,
-                enableImplicitApprovalFromUploader,
-                patchSetUploader)) {
-              codeOwnerStatus.set(CodeOwnerStatus.APPROVED);
-              return false;
-            } else if (isPending(absolutePath, codeOwners, reviewerAccountIds)) {
-              codeOwnerStatus.set(CodeOwnerStatus.PENDING);
-
-              // We need to continue to check if any of the higher-level code owners approved the
-              // change.
-              return true;
-            }
-
-            // 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 =
-        PathCodeOwnerStatus.create(absolutePath, codeOwnerStatus.get());
-    logger.atFine().log("pathCodeOwnerStatus = %s", pathCodeOwnerStatus);
-    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,
+      BranchNameKey branch,
+      CodeOwnerResolverResult globalCodeOwners,
+      @Nullable Account.Id implicitApprover,
       ImmutableSet<Account.Id> reviewerAccountIds,
       ImmutableSet<Account.Id> approverAccountIds,
+      FallbackCodeOwners fallbackCodeOwners,
       Path absolutePath) {
-    FallbackCodeOwners fallbackCodeOwners =
-        codeOwnersPluginConfiguration.getFallbackCodeOwners(project);
     logger.atFine().log(
         "getting code owner status for fallback code owners (fallback code owners = %s)",
         fallbackCodeOwners);
@@ -677,15 +762,20 @@
       case NONE:
         logger.atFine().log("no fallback code owners");
         return codeOwnerStatus;
-      case ALL_USERS:
-        return getCodeOwnerStatusIfAllUsersAreCodeOwners(
-            enableImplicitApprovalFromUploader,
+      case PROJECT_OWNERS:
+        return getCodeOwnerStatusForProjectOwnersAsFallbackCodeOwners(
+            branch,
+            globalCodeOwners,
+            implicitApprover,
             reviewerAccountIds,
             approverAccountIds,
             absolutePath);
+      case ALL_USERS:
+        return getCodeOwnerStatusIfAllUsersAreCodeOwners(
+            implicitApprover != null, reviewerAccountIds, approverAccountIds, absolutePath);
     }
 
-    throw new StorageException(
+    throw new CodeOwnersInternalServerErrorException(
         String.format("unknown fallback code owners configured: %s", fallbackCodeOwners));
   }
 
@@ -722,12 +812,9 @@
       Path absolutePath,
       CodeOwnerResolverResult codeOwners,
       ImmutableSet<Account.Id> approverAccountIds,
-      boolean enableImplicitApprovalFromUploader,
-      @Nullable Account.Id patchSetUploader) {
-    if (enableImplicitApprovalFromUploader) {
-      requireNonNull(
-          patchSetUploader, "patchSetUploader must be set if implicit approvals are enabled");
-      if (codeOwners.codeOwnersAccountIds().contains(patchSetUploader)
+      @Nullable Account.Id implicitApprover) {
+    if (implicitApprover != null) {
+      if (codeOwners.codeOwnersAccountIds().contains(implicitApprover)
           || 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.
@@ -772,7 +859,7 @@
       }
       return isProjectOwner;
     } catch (PermissionBackendException e) {
-      throw new StorageException(
+      throw new CodeOwnersInternalServerErrorException(
           String.format(
               "failed to check owner permission of project %s for account %d",
               project.get(), accountId.get()),
@@ -781,17 +868,12 @@
   }
 
   /**
-   * Gets the code owners that own the given path according to the given code owner config.
+   * Resolves the given path code owners.
    *
-   * @param codeOwnerConfig the code owner config from which the code owners should be retrieved
-   * @param absolutePath the path for which the code owners should be retrieved
+   * @param pathCodeOwners the path code owners that should be resolved
    */
-  private CodeOwnerResolverResult getCodeOwners(
-      CodeOwnerConfig codeOwnerConfig, Path absolutePath) {
-    return codeOwnerResolver
-        .get()
-        .enforceVisibility(false)
-        .resolvePathCodeOwners(codeOwnerConfig, absolutePath);
+  private CodeOwnerResolverResult resolveCodeOwners(PathCodeOwners pathCodeOwners) {
+    return codeOwnerResolver.get().enforceVisibility(false).resolvePathCodeOwners(pathCodeOwners);
   }
 
   /**
@@ -820,23 +902,13 @@
    *
    * @param requiredApproval approval that is required from code owners to approve the files in a
    *     change
-   * @param changeNotes the change notes
    */
   private ImmutableSet<Account.Id> getApproverAccountIds(
-      RequiredApproval requiredApproval, ChangeNotes changeNotes, Account.Id patchSetUploader) {
+      ImmutableList<PatchSetApproval> currentPatchSetApprovals,
+      RequiredApproval requiredApproval,
+      Account.Id patchSetUploader) {
     ImmutableSet<Account.Id> approverAccountIds =
-        StreamSupport.stream(
-                approvalsUtil
-                    .byPatchSet(
-                        changeNotes,
-                        changeNotes.getCurrentPatchSet().id(),
-                        /** revWalk */
-                        null,
-                        /** repoConfig */
-                        null)
-                    .spliterator(),
-                /** parallel */
-                false)
+        currentPatchSetApprovals.stream()
             .filter(requiredApproval::isApprovedBy)
             .map(PatchSetApproval::accountId)
             .collect(toImmutableSet());
@@ -853,6 +925,19 @@
     return approverAccountIds;
   }
 
+  private ImmutableList<PatchSetApproval> getCurrentPatchSetApprovals(ChangeNotes changeNotes) {
+    try (Timer0.Context ctx = codeOwnerMetrics.computePatchSetApprovals.start()) {
+      return ImmutableList.copyOf(
+          approvalsUtil.byPatchSet(
+              changeNotes,
+              changeNotes.getCurrentPatchSet().id(),
+              /** revWalk */
+              null,
+              /** repoConfig */
+              null));
+    }
+  }
+
   private ImmutableSet<Account.Id> filterOutAccount(
       ImmutableSet<Account.Id> accountIds, Account.Id accountIdToFilterOut) {
     return accountIds.stream()
@@ -864,19 +949,18 @@
    * 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(
+      ImmutableList<PatchSetApproval> currentPatchSetApprovals,
       ImmutableSet<RequiredApproval> overrideApprovals,
-      ChangeNotes changeNotes,
       Account.Id patchSetUploader) {
     ImmutableSet<RequiredApproval> overrideApprovalsThatIgnoreSelfApprovals =
         overrideApprovals.stream()
             .filter(overrideApproval -> overrideApproval.labelType().isIgnoreSelfApproval())
             .collect(toImmutableSet());
-    return changeNotes.getApprovals().get(changeNotes.getCurrentPatchSet().id()).stream()
+    return currentPatchSetApprovals.stream()
         .filter(
             approval -> {
               // If the approval is from the patch set uploader and if it matches any of the labels
@@ -889,6 +973,9 @@
                                   .labelType()
                                   .getLabelId()
                                   .equals(approval.key().labelId()))) {
+                logger.atFine().log(
+                    "Filtered out self-override %s of patch set uploader",
+                    LabelVote.create(approval.label(), approval.value()));
                 return false;
               }
               return true;
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackend.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackend.java
index be5318d..3775f89 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackend.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackend.java
@@ -112,9 +112,7 @@
    * backend. It this case all {@link CodeOwnerSet}s that have path expressions are ignored and will
    * not have any effect.
    */
-  default Optional<PathExpressionMatcher> getPathExpressionMatcher() {
-    return Optional.empty();
-  }
+  Optional<PathExpressionMatcher> getPathExpressionMatcher();
 
   /**
    * Replaces the old email in the given code owner config file content with the new email.
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfig.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfig.java
index d01c4a3..fc78ab8 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfig.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfig.java
@@ -306,6 +306,11 @@
       return folderPath().resolve(fileName().orElse(defaultCodeOwnerConfigFileName));
     }
 
+    /** User-readable string representing of this code owner config key. */
+    public String format(CodeOwners codeOwners) {
+      return String.format("%s:%s:%s", project(), shortBranchName(), codeOwners.getFilePath(this));
+    }
+
     /**
      * Creates a builder from this code owner config key.
      *
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFile.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFile.java
index 5ecddb3..90fd878 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFile.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFile.java
@@ -19,9 +19,13 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
-import com.google.gerrit.plugins.codeowners.JgitPath;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
+import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -36,9 +40,9 @@
  * A representation of a code owner config that is stored as an {@code OWNERS} file in a source
  * branch.
  *
- * <p>For reading code owner configs or creating/updating them, refer to {@link #load(String,
- * CodeOwnerConfigParser, RevWalk, ObjectId, CodeOwnerConfig.Key)} and {@link #loadCurrent(String,
- * CodeOwnerConfigParser, Repository, CodeOwnerConfig.Key)}.
+ * <p>For reading code owner configs or creating/updating them, refer to {@link Factory#load(String,
+ * CodeOwnerConfigParser, RevWalk, ObjectId, CodeOwnerConfig.Key)} and {@link
+ * Factory#loadCurrent(String, CodeOwnerConfigParser, Repository, CodeOwnerConfig.Key)}.
  *
  * <p><strong>Note:</strong> Any modification (code owner config creation or update) only becomes
  * permanent (and hence written to repository) if {@link
@@ -46,83 +50,95 @@
  */
 @VisibleForTesting
 public class CodeOwnerConfigFile extends VersionedMetaData {
-  /**
-   * Creates a {@link CodeOwnerConfigFile} for a code owner config.
-   *
-   * <p>The code owner config is automatically loaded within this method and can be accessed via
-   * {@link #getLoadedCodeOwnerConfig()}.
-   *
-   * <p>It's safe to call this method for non-existing code owner configs. In that case, {@link
-   * #getLoadedCodeOwnerConfig()} won't return any code owner config. Thus, the existence of a code
-   * owner config can be easily tested.
-   *
-   * <p>The code owner config represented by the returned {@link CodeOwnerConfigFile} can be
-   * created/updated by setting an {@link CodeOwnerConfigUpdate} via {@link
-   * #setCodeOwnerConfigUpdate(CodeOwnerConfigUpdate)} and committing the {@link
-   * CodeOwnerConfigUpdate} via {@link #commit(com.google.gerrit.server.git.meta.MetaDataUpdate)}.
-   *
-   * @param defaultFileName the name of the code owner configuration files that should be used if
-   *     none is specified in the code owner config key
-   * @param codeOwnerConfigParser the parser that should be used to parse code owner config files
-   * @param revWalk the revWalk that should be used to load the revision
-   * @param revision the branch revision from which the code owner config file should be loaded
-   * @param codeOwnerConfigKey the key of the code owner config
-   * @return a {@link CodeOwnerConfigFile} for the code owner config with the specified key
-   * @throws IOException if the repository can't be accessed for some reason
-   * @throws ConfigInvalidException if the code owner config exists but can't be read due to an
-   *     invalid format
-   */
-  public static CodeOwnerConfigFile load(
-      String defaultFileName,
-      CodeOwnerConfigParser codeOwnerConfigParser,
-      RevWalk revWalk,
-      ObjectId revision,
-      CodeOwnerConfig.Key codeOwnerConfigKey)
-      throws IOException, ConfigInvalidException {
-    requireNonNull(defaultFileName, "defaultFileName");
-    requireNonNull(codeOwnerConfigParser, "codeOwnerConfigParser");
-    requireNonNull(revWalk, "revWalk");
-    requireNonNull(revision, "revision");
-    requireNonNull(codeOwnerConfigKey, "codeOwnerConfigKey");
+  public static class Factory {
+    private final CodeOwnerMetrics codeOwnerMetrics;
 
-    CodeOwnerConfigFile codeOwnerConfigFile =
-        new CodeOwnerConfigFile(defaultFileName, codeOwnerConfigParser, codeOwnerConfigKey);
-    codeOwnerConfigFile.load(codeOwnerConfigKey.project(), revWalk, revision);
-    return codeOwnerConfigFile;
+    @Inject
+    Factory(CodeOwnerMetrics codeOwnerMetrics) {
+      this.codeOwnerMetrics = codeOwnerMetrics;
+    }
+
+    /**
+     * Creates a {@link CodeOwnerConfigFile} for a code owner config.
+     *
+     * <p>The code owner config is automatically loaded within this method and can be accessed via
+     * {@link #getLoadedCodeOwnerConfig()}.
+     *
+     * <p>It's safe to call this method for non-existing code owner configs. In that case, {@link
+     * #getLoadedCodeOwnerConfig()} won't return any code owner config. Thus, the existence of a
+     * code owner config can be easily tested.
+     *
+     * <p>The code owner config represented by the returned {@link CodeOwnerConfigFile} can be
+     * created/updated by setting an {@link CodeOwnerConfigUpdate} via {@link
+     * #setCodeOwnerConfigUpdate(CodeOwnerConfigUpdate)} and committing the {@link
+     * CodeOwnerConfigUpdate} via {@link #commit(com.google.gerrit.server.git.meta.MetaDataUpdate)}.
+     *
+     * @param defaultFileName the name of the code owner configuration files that should be used if
+     *     none is specified in the code owner config key
+     * @param codeOwnerConfigParser the parser that should be used to parse code owner config files
+     * @param revWalk the revWalk that should be used to load the revision
+     * @param revision the branch revision from which the code owner config file should be loaded
+     * @param codeOwnerConfigKey the key of the code owner config
+     * @return a {@link CodeOwnerConfigFile} for the code owner config with the specified key
+     * @throws IOException if the repository can't be accessed for some reason
+     * @throws ConfigInvalidException if the code owner config exists but can't be read due to an
+     *     invalid format
+     */
+    public CodeOwnerConfigFile load(
+        String defaultFileName,
+        CodeOwnerConfigParser codeOwnerConfigParser,
+        RevWalk revWalk,
+        ObjectId revision,
+        CodeOwnerConfig.Key codeOwnerConfigKey)
+        throws IOException, ConfigInvalidException {
+      requireNonNull(defaultFileName, "defaultFileName");
+      requireNonNull(codeOwnerConfigParser, "codeOwnerConfigParser");
+      requireNonNull(revWalk, "revWalk");
+      requireNonNull(revision, "revision");
+      requireNonNull(codeOwnerConfigKey, "codeOwnerConfigKey");
+
+      CodeOwnerConfigFile codeOwnerConfigFile =
+          new CodeOwnerConfigFile(
+              codeOwnerMetrics, defaultFileName, codeOwnerConfigParser, codeOwnerConfigKey);
+      codeOwnerConfigFile.load(codeOwnerConfigKey.project(), revWalk, revision);
+      return codeOwnerConfigFile;
+    }
+
+    /**
+     * Creates a {@link CodeOwnerConfigFile} for a code owner config from the current revision in
+     * the branch.
+     *
+     * @param defaultFileName the name of the code owner configuration files that should be used if
+     *     none is specified in the code owner config key
+     * @param codeOwnerConfigParser the parser that should be used to parse code owner config files
+     * @param repository the repository in which the code owner config is stored
+     * @param codeOwnerConfigKey the key of the code owner config
+     * @return a {@link CodeOwnerConfigFile} for the code owner config with the specified key
+     * @throws IOException if the repository can't be accessed for some reason
+     * @throws ConfigInvalidException if the code owner config exists but can't be read due to an
+     *     invalid format
+     * @see #load(String, CodeOwnerConfigParser, RevWalk, ObjectId, CodeOwnerConfig.Key)
+     */
+    public CodeOwnerConfigFile loadCurrent(
+        String defaultFileName,
+        CodeOwnerConfigParser codeOwnerConfigParser,
+        Repository repository,
+        CodeOwnerConfig.Key codeOwnerConfigKey)
+        throws IOException, ConfigInvalidException {
+      requireNonNull(defaultFileName, "defaultFileName");
+      requireNonNull(codeOwnerConfigParser, "codeOwnerConfigParser");
+      requireNonNull(repository, "repository");
+      requireNonNull(codeOwnerConfigKey, "codeOwnerConfigKey");
+
+      CodeOwnerConfigFile codeOwnerConfigFile =
+          new CodeOwnerConfigFile(
+              codeOwnerMetrics, defaultFileName, codeOwnerConfigParser, codeOwnerConfigKey);
+      codeOwnerConfigFile.load(codeOwnerConfigKey.project(), repository);
+      return codeOwnerConfigFile;
+    }
   }
 
-  /**
-   * Creates a {@link CodeOwnerConfigFile} for a code owner config from the current revision in the
-   * branch.
-   *
-   * @param defaultFileName the name of the code owner configuration files that should be used if
-   *     none is specified in the code owner config key
-   * @param codeOwnerConfigParser the parser that should be used to parse code owner config files
-   * @param repository the repository in which the code owner config is stored
-   * @param codeOwnerConfigKey the key of the code owner config
-   * @return a {@link CodeOwnerConfigFile} for the code owner config with the specified key
-   * @throws IOException if the repository can't be accessed for some reason
-   * @throws ConfigInvalidException if the code owner config exists but can't be read due to an
-   *     invalid format
-   * @see #load(String, CodeOwnerConfigParser, RevWalk, ObjectId, CodeOwnerConfig.Key)
-   */
-  public static CodeOwnerConfigFile loadCurrent(
-      String defaultFileName,
-      CodeOwnerConfigParser codeOwnerConfigParser,
-      Repository repository,
-      CodeOwnerConfig.Key codeOwnerConfigKey)
-      throws IOException, ConfigInvalidException {
-    requireNonNull(defaultFileName, "defaultFileName");
-    requireNonNull(codeOwnerConfigParser, "codeOwnerConfigParser");
-    requireNonNull(repository, "repository");
-    requireNonNull(codeOwnerConfigKey, "codeOwnerConfigKey");
-
-    CodeOwnerConfigFile codeOwnerConfigFile =
-        new CodeOwnerConfigFile(defaultFileName, codeOwnerConfigParser, codeOwnerConfigKey);
-    codeOwnerConfigFile.load(codeOwnerConfigKey.project(), repository);
-    return codeOwnerConfigFile;
-  }
-
+  private final CodeOwnerMetrics codeOwnerMetrics;
   private final String defaultFileName;
   private final CodeOwnerConfigParser codeOwnerConfigParser;
   private final CodeOwnerConfig.Key codeOwnerConfigKey;
@@ -132,9 +148,11 @@
   private Optional<CodeOwnerConfigUpdate> codeOwnerConfigUpdate = Optional.empty();
 
   private CodeOwnerConfigFile(
+      CodeOwnerMetrics codeOwnerMetrics,
       String defaultFileName,
       CodeOwnerConfigParser codeOwnerConfigParser,
       CodeOwnerConfig.Key codeOwnerConfigKey) {
+    this.codeOwnerMetrics = codeOwnerMetrics;
     this.defaultFileName = defaultFileName;
     this.codeOwnerConfigParser = codeOwnerConfigParser;
     this.codeOwnerConfigKey = codeOwnerConfigKey;
@@ -186,18 +204,33 @@
   }
 
   @Override
+  protected byte[] readFile(String fileName) throws IOException {
+    try (Timer0.Context ctx = codeOwnerMetrics.readCodeOwnerConfig.start()) {
+      return super.readFile(fileName);
+    }
+  }
+
+  @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
     if (revision != null) {
-      Optional<String> codeOwnerConfigFileContent =
-          getFileIfItExists(JgitPath.of(codeOwnerConfigKey.filePath(defaultFileName)).get());
+      String codeOwnerConfigFilePath =
+          JgitPath.of(codeOwnerConfigKey.filePath(defaultFileName)).get();
+      Optional<String> codeOwnerConfigFileContent = getFileIfItExists(codeOwnerConfigFilePath);
       if (codeOwnerConfigFileContent.isPresent()) {
-        try {
+        try (Timer1.Context<String> ctx =
+            codeOwnerMetrics.parseCodeOwnerConfig.start(
+                codeOwnerConfigParser.getClass().getSimpleName())) {
           loadedCodeOwnersConfig =
               Optional.of(
                   codeOwnerConfigParser.parse(
                       revision, codeOwnerConfigKey, codeOwnerConfigFileContent.get()));
         } catch (CodeOwnerConfigParseException e) {
-          throw new ConfigInvalidException(e.getFullMessage(defaultFileName), e);
+          throw new InvalidCodeOwnerConfigException(
+              e.getFullMessage(defaultFileName),
+              projectName,
+              getRefName(),
+              codeOwnerConfigFilePath,
+              e);
         }
       }
     }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScanner.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScanner.java
index dd5da50..97d9ad2 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScanner.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScanner.java
@@ -19,9 +19,8 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.git.RefUpdateUtil;
-import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -93,7 +92,10 @@
     requireNonNull(commitMessage, "commitMessage");
     requireNonNull(codeOwnerConfigFileUpdater, "codeOwnerConfigFileUpdater");
 
-    CodeOwnerBackend codeOwnerBackend = codeOwnersPluginConfiguration.getBackend(branchNameKey);
+    CodeOwnerBackend codeOwnerBackend =
+        codeOwnersPluginConfiguration
+            .getProjectConfig(branchNameKey.project())
+            .getBackend(branchNameKey.branch());
     logger.atFine().log(
         "updating code owner files in branch %s of project %s",
         branchNameKey.branch(), branchNameKey.project());
@@ -138,7 +140,7 @@
       updateBranch(branchNameKey.branch(), repository, revision, commitId);
       return Optional.of(rw.parseCommit(commitId));
     } catch (IOException e) {
-      throw new StorageException(
+      throw new CodeOwnersInternalServerErrorException(
           String.format(
               "Failed to scan for code owner configs in branch %s of project %s",
               branchNameKey.branch(), branchNameKey.project()),
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchy.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchy.java
index e9db8c8..7ba3352 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchy.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchy.java
@@ -21,10 +21,8 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.Optional;
@@ -45,18 +43,21 @@
  * using {@code set noparent} in the root code owner config if the {@code find-owners} backend is
  * used).
  */
-@Singleton
 public class CodeOwnerConfigHierarchy {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final GitRepositoryManager repoManager;
   private final PathCodeOwners.Factory pathCodeOwnersFactory;
+  private final TransientCodeOwnerConfigCache transientCodeOwnerConfigCache;
 
   @Inject
   CodeOwnerConfigHierarchy(
-      GitRepositoryManager repoManager, PathCodeOwners.Factory pathCodeOwnersFactory) {
+      GitRepositoryManager repoManager,
+      PathCodeOwners.Factory pathCodeOwnersFactory,
+      TransientCodeOwnerConfigCache transientCodeOwnerConfigCache) {
     this.repoManager = repoManager;
     this.pathCodeOwnersFactory = pathCodeOwnersFactory;
+    this.transientCodeOwnerConfigCache = transientCodeOwnerConfigCache;
   }
 
   /**
@@ -102,10 +103,89 @@
       Path absolutePath,
       CodeOwnerConfigVisitor codeOwnerConfigVisitor,
       Consumer<CodeOwnerConfig.Key> parentCodeOwnersIgnoredCallback) {
+    requireNonNull(codeOwnerConfigVisitor, "codeOwnerConfigVisitor");
+    PathCodeOwnersVisitor pathCodeOwnersVisitor =
+        pathCodeOwners -> codeOwnerConfigVisitor.visit(pathCodeOwners.getCodeOwnerConfig());
+    visit(
+        branchNameKey,
+        revision,
+        absolutePath,
+        pathCodeOwnersVisitor,
+        parentCodeOwnersIgnoredCallback);
+  }
+
+  /**
+   * Visits the path code owners 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 pathCodeOwnersVisitor visitor that should be invoked for the applying path code owners
+   * @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,
+      PathCodeOwnersVisitor pathCodeOwnersVisitor,
+      Consumer<CodeOwnerConfig.Key> parentCodeOwnersIgnoredCallback) {
+    visit(
+        branchNameKey,
+        revision,
+        absolutePath,
+        absolutePath,
+        pathCodeOwnersVisitor,
+        parentCodeOwnersIgnoredCallback);
+  }
+
+  /**
+   * Visits the path code owners in the given branch that apply for the given file path by following
+   * the path hierarchy from the given path up to the root folder.
+   *
+   * <p>Same as {@link #visit(BranchNameKey, ObjectId, Path, PathCodeOwnersVisitor, Consumer)} with
+   * the only difference that the provided path must be a file path (no folder path). Knowing that
+   * that the path is a file path allows us to skip checking if there is a code owner config file in
+   * this path (if it's a file it cannot contain a code owner config file). This is a performance
+   * optimization that matters if code owner config files need to be looked up for 1000s of files
+   * (e.g. for large changes).
+   *
+   * @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 absoluteFilePath the path for which the code owner configs should be visited; the path
+   *     must be absolute; must be the path of a file; the path may or may not exist
+   * @param pathCodeOwnersVisitor visitor that should be invoked for the applying path code owners
+   * @param parentCodeOwnersIgnoredCallback callback that is invoked for the first visited code
+   *     owner config that ignores parent code owners
+   */
+  public void visitForFile(
+      BranchNameKey branchNameKey,
+      ObjectId revision,
+      Path absoluteFilePath,
+      PathCodeOwnersVisitor pathCodeOwnersVisitor,
+      Consumer<CodeOwnerConfig.Key> parentCodeOwnersIgnoredCallback) {
+    visit(
+        branchNameKey,
+        revision,
+        absoluteFilePath,
+        absoluteFilePath.getParent(),
+        pathCodeOwnersVisitor,
+        parentCodeOwnersIgnoredCallback);
+  }
+
+  private void visit(
+      BranchNameKey branchNameKey,
+      ObjectId revision,
+      Path absolutePath,
+      Path startFolder,
+      PathCodeOwnersVisitor pathCodeOwnersVisitor,
+      Consumer<CodeOwnerConfig.Key> parentCodeOwnersIgnoredCallback) {
     requireNonNull(branchNameKey, "branch");
     requireNonNull(revision, "revision");
     requireNonNull(absolutePath, "absolutePath");
-    requireNonNull(codeOwnerConfigVisitor, "codeOwnerConfigVisitor");
+    requireNonNull(pathCodeOwnersVisitor, "pathCodeOwnersVisitor");
     requireNonNull(parentCodeOwnersIgnoredCallback, "parentCodeOwnersIgnoredCallback");
     checkState(absolutePath.isAbsolute(), "path %s must be absolute", absolutePath);
 
@@ -113,9 +193,9 @@
         "visiting code owner configs for '%s' in branch '%s' in project '%s' (revision = '%s')",
         absolutePath, branchNameKey.shortName(), branchNameKey.project(), revision.name());
 
-    // Next path in which we look for a code owner configuration. We start at the given path and
+    // Next path in which we look for a code owner configuration. We start at the given folder and
     // then go up the parent hierarchy.
-    Path ownerConfigFolder = absolutePath;
+    Path ownerConfigFolder = startFolder;
 
     // Iterate over the parent code owner configurations.
     while (ownerConfigFolder != null) {
@@ -125,13 +205,13 @@
       CodeOwnerConfig.Key codeOwnerConfigKey =
           CodeOwnerConfig.Key.create(branchNameKey, ownerConfigFolder);
       Optional<PathCodeOwners> pathCodeOwners =
-          pathCodeOwnersFactory.create(codeOwnerConfigKey, revision, absolutePath);
+          pathCodeOwnersFactory.create(
+              transientCodeOwnerConfigCache, codeOwnerConfigKey, revision, absolutePath);
       if (pathCodeOwners.isPresent()) {
         logger.atFine().log("visit code owner config for %s", ownerConfigFolder);
-        boolean visitFurtherCodeOwnerConfigs =
-            codeOwnerConfigVisitor.visit(pathCodeOwners.get().getCodeOwnerConfig());
+        boolean visitFurtherCodeOwnerConfigs = pathCodeOwnersVisitor.visit(pathCodeOwners.get());
         boolean ignoreParentCodeOwners =
-            pathCodeOwners.get().resolveCodeOwnerConfig().ignoreParentCodeOwners();
+            pathCodeOwners.get().resolveCodeOwnerConfig().get().ignoreParentCodeOwners();
         if (ignoreParentCodeOwners) {
           parentCodeOwnersIgnoredCallback.accept(codeOwnerConfigKey);
         }
@@ -156,7 +236,7 @@
 
     if (!RefNames.REFS_CONFIG.equals(branchNameKey.branch())) {
       visitCodeOwnerConfigInRefsMetaConfig(
-          branchNameKey.project(), absolutePath, codeOwnerConfigVisitor);
+          branchNameKey.project(), absolutePath, pathCodeOwnersVisitor);
     }
   }
 
@@ -175,11 +255,10 @@
    *     the {@code refs/meta/config} branch
    * @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 pathCodeOwnersVisitor visitor that should be invoked
    */
   private void visitCodeOwnerConfigInRefsMetaConfig(
-      Project.NameKey project, Path absolutePath, CodeOwnerConfigVisitor codeOwnerConfigVisitor) {
+      Project.NameKey project, Path absolutePath, PathCodeOwnersVisitor pathCodeOwnersVisitor) {
     CodeOwnerConfig.Key metaCodeOwnerConfigKey =
         CodeOwnerConfig.Key.create(project, RefNames.REFS_CONFIG, "/");
     logger.atFine().log("visiting code owner config %s", metaCodeOwnerConfigKey);
@@ -192,15 +271,22 @@
       }
       RevCommit metaRevision = rw.parseCommit(ref.getObjectId());
       Optional<PathCodeOwners> pathCodeOwners =
-          pathCodeOwnersFactory.create(metaCodeOwnerConfigKey, metaRevision, absolutePath);
+          pathCodeOwnersFactory.create(
+              transientCodeOwnerConfigCache, metaCodeOwnerConfigKey, metaRevision, absolutePath);
       if (pathCodeOwners.isPresent()) {
         logger.atFine().log("visit code owner config %s", metaCodeOwnerConfigKey);
-        codeOwnerConfigVisitor.visit(pathCodeOwners.get().getCodeOwnerConfig());
+        pathCodeOwnersVisitor.visit(pathCodeOwners.get());
       } else {
         logger.atFine().log("code owner config %s not found", metaCodeOwnerConfigKey);
       }
     } catch (IOException e) {
-      throw new StorageException(String.format("failed to read %s", metaCodeOwnerConfigKey), e);
+      throw new CodeOwnersInternalServerErrorException(
+          String.format("failed to read %s", metaCodeOwnerConfigKey), e);
     }
   }
+
+  /** Returns the counters for cache and backend reads of code owner config files. */
+  public TransientCodeOwnerConfigCache.Counters getCodeOwnerConfigCounters() {
+    return transientCodeOwnerConfigCache.getCounters();
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigLoader.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigLoader.java
new file mode 100644
index 0000000..b263c20
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigLoader.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** API to load {@link CodeOwnerConfig}s. */
+public interface CodeOwnerConfigLoader {
+  /**
+   * Retrieves the code owner config for the given key from the given branch revision.
+   *
+   * @param codeOwnerConfigKey the key of the code owner config that should be retrieved
+   * @param revision the branch revision from which the code owner config should be loaded
+   * @return the code owner config for the given key if it exists, otherwise {@link
+   *     Optional#empty()}
+   */
+  public Optional<CodeOwnerConfig> get(CodeOwnerConfig.Key codeOwnerConfigKey, ObjectId revision);
+
+  /**
+   * Retrieves the code owner config for the given key from the current revision of the branch.
+   *
+   * @param codeOwnerConfigKey the key of the code owner config that should be retrieved
+   * @return the code owner config for the given key if it exists, otherwise {@link
+   *     Optional#empty()}
+   */
+  public Optional<CodeOwnerConfig> getFromCurrentRevision(CodeOwnerConfig.Key codeOwnerConfigKey);
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigParseException.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigParseException.java
index 3de255d..b8fc54b 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigParseException.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigParseException.java
@@ -40,7 +40,12 @@
   /** Returns all validation as a single, formatted string. */
   public String getFullMessage(String defaultCodeOwnerConfigFileName) {
     StringBuilder sb = new StringBuilder(getMessage());
-    sb.append(" '").append(codeOwnerConfigKey.filePath(defaultCodeOwnerConfigFileName)).append("'");
+    sb.append(
+        String.format(
+            " '%s' (project = %s, branch = %s)",
+            codeOwnerConfigKey.filePath(defaultCodeOwnerConfigFileName),
+            codeOwnerConfigKey.project(),
+            codeOwnerConfigKey.shortBranchName()));
     if (!messages.isEmpty()) {
       sb.append(':');
       for (ValidationError msg : messages) {
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigReference.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigReference.java
index 2bc571c..7f9d4c4 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigReference.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigReference.java
@@ -75,6 +75,19 @@
     return filePath().getFileName().toString();
   }
 
+  /** User-readable string representing this code owner config reference. */
+  public String format() {
+    StringBuilder formatted = new StringBuilder();
+    if (project().isPresent()) {
+      formatted.append(project().get()).append(":");
+    }
+    if (branch().isPresent()) {
+      formatted.append(branch().get()).append(":");
+    }
+    formatted.append(filePath());
+    return formatted.toString();
+  }
+
   /**
    * Creates a builder from this code owner config reference.
    *
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScanner.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScanner.java
index c8c96ab..20d7a4c 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScanner.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScanner.java
@@ -14,21 +14,18 @@
 
 package com.google.gerrit.plugins.codeowners.backend;
 
-import static com.google.gerrit.plugins.codeowners.backend.CodeOwners.getInvalidConfigCause;
+import static com.google.gerrit.plugins.codeowners.backend.CodeOwners.getInvalidCodeOwnerConfigCause;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Optional;
-import java.util.concurrent.atomic.AtomicBoolean;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -70,26 +67,6 @@
   }
 
   /**
-   * Whether there is at least one code owner config file in the given project and branch.
-   *
-   * @param branchNameKey the project and branch for which if should be checked if it contains any
-   *     code owner config file
-   * @return {@code true} if there is at least one code owner config file in the given project and
-   *     branch, otherwise {@code false}
-   */
-  public boolean containsAnyCodeOwnerConfigFile(BranchNameKey branchNameKey) {
-    AtomicBoolean found = new AtomicBoolean(false);
-    visit(
-        branchNameKey,
-        codeOwnerConfig -> {
-          found.set(true);
-          return false;
-        },
-        (codeOwnerConfigFilePath, configInvalidException) -> found.set(true));
-    return found.get();
-  }
-
-  /**
    * Visits all code owner config files in the given project and branch.
    *
    * @param branchNameKey the project and branch for which the code owner config files should be
@@ -128,7 +105,10 @@
     requireNonNull(codeOwnerConfigVisitor, "codeOwnerConfigVisitor");
     requireNonNull(invalidCodeOwnerConfigCallback, "invalidCodeOwnerConfigCallback");
 
-    CodeOwnerBackend codeOwnerBackend = codeOwnersPluginConfiguration.getBackend(branchNameKey);
+    CodeOwnerBackend codeOwnerBackend =
+        codeOwnersPluginConfiguration
+            .getProjectConfig(branchNameKey.project())
+            .getBackend(branchNameKey.branch());
     logger.atFine().log(
         "scanning code owner files in branch %s of project %s (path glob = %s)",
         branchNameKey.branch(), branchNameKey.project(), pathGlob);
@@ -162,17 +142,17 @@
         CodeOwnerConfig codeOwnerConfig;
         try {
           codeOwnerConfig = treeWalk.getCodeOwnerConfig();
-        } catch (StorageException storageException) {
-          Optional<ConfigInvalidException> configInvalidException =
-              getInvalidConfigCause(storageException);
-          if (!configInvalidException.isPresent()) {
+        } catch (CodeOwnersInternalServerErrorException codeOwnersInternalServerErrorException) {
+          Optional<InvalidCodeOwnerConfigException> invalidCodeOwnerConfigException =
+              getInvalidCodeOwnerConfigCause(codeOwnersInternalServerErrorException);
+          if (!invalidCodeOwnerConfigException.isPresent()) {
             // Propagate any failure that is not related to the contents of the code owner config.
-            throw storageException;
+            throw codeOwnersInternalServerErrorException;
           }
 
           // The code owner config is invalid and cannot be parsed.
           invalidCodeOwnerConfigCallback.onInvalidCodeOwnerConfig(
-              treeWalk.getFilePath(), configInvalidException.get());
+              treeWalk.getFilePath(), invalidCodeOwnerConfigException.get());
           continue;
         }
 
@@ -182,7 +162,7 @@
         }
       }
     } catch (IOException e) {
-      throw new StorageException(
+      throw new CodeOwnersInternalServerErrorException(
           String.format(
               "Failed to scan for code owner configs in branch %s of project %s",
               branchNameKey.branch(), branchNameKey.project()),
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigTreeWalk.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigTreeWalk.java
index 1ba8f74..0836a53 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigTreeWalk.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigTreeWalk.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.plugins.codeowners.JgitPath;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import java.io.IOException;
 import java.nio.file.FileSystems;
 import java.nio.file.Path;
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java
index 74e2db6..f78804f 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java
@@ -19,12 +19,15 @@
 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.Account;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountCache;
@@ -32,9 +35,6 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.logging.Metadata;
-import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -42,6 +42,8 @@
 import com.google.inject.Provider;
 import java.io.IOException;
 import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -59,6 +61,8 @@
   private final AccountCache accountCache;
   private final AccountControl.Factory accountControlFactory;
   private final PathCodeOwners.Factory pathCodeOwnersFactory;
+  private final CodeOwnerMetrics codeOwnerMetrics;
+  private final UnresolvedImportFormatter unresolvedImportFormatter;
 
   // Enforce visibility by default.
   private boolean enforceVisibility = true;
@@ -76,7 +80,9 @@
       ExternalIds externalIds,
       AccountCache accountCache,
       AccountControl.Factory accountControlFactory,
-      PathCodeOwners.Factory pathCodeOwnersFactory) {
+      PathCodeOwners.Factory pathCodeOwnersFactory,
+      CodeOwnerMetrics codeOwnerMetrics,
+      UnresolvedImportFormatter unresolvedImportFormatter) {
     this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
     this.permissionBackend = permissionBackend;
     this.currentUser = currentUser;
@@ -84,6 +90,8 @@
     this.accountCache = accountCache;
     this.accountControlFactory = accountControlFactory;
     this.pathCodeOwnersFactory = pathCodeOwnersFactory;
+    this.codeOwnerMetrics = codeOwnerMetrics;
+    this.unresolvedImportFormatter = unresolvedImportFormatter;
   }
 
   /**
@@ -130,6 +138,10 @@
    * Resolves the code owners from the given code owner config for the given path from {@link
    * CodeOwnerReference}s to a {@link CodeOwner}s.
    *
+   * <p>If the code owner config has already been resolved to {@link PathCodeOwners}, prefer calling
+   * {@link #resolvePathCodeOwners(PathCodeOwners)} instead, so that {@link PathCodeOwners} do not
+   * need to be created again.
+   *
    * <p>Non-resolvable code owners are filtered out.
    *
    * @param codeOwnerConfig the code owner config for which the local owners for the given path
@@ -143,20 +155,32 @@
     requireNonNull(codeOwnerConfig, "codeOwnerConfig");
     requireNonNull(absolutePath, "absolutePath");
     checkState(absolutePath.isAbsolute(), "path %s must be absolute", absolutePath);
+    return resolvePathCodeOwners(
+        pathCodeOwnersFactory.createWithoutCache(codeOwnerConfig, absolutePath));
+  }
 
-    try (TraceTimer traceTimer =
-        TraceContext.newTimer(
-            "Resolve path code owners",
-            Metadata.builder()
-                .projectName(codeOwnerConfig.key().project().get())
-                .branchName(codeOwnerConfig.key().ref())
-                .filePath(codeOwnerConfig.key().fileName().orElse("<default>"))
-                .build())) {
-      logger.atFine().log("resolving path code owners for path %s", absolutePath);
-      PathCodeOwnersResult pathCodeOwnersResult =
-          pathCodeOwnersFactory.create(codeOwnerConfig, absolutePath).resolveCodeOwnerConfig();
+  /**
+   * Resolves the the given path code owners from {@link CodeOwnerReference}s to a {@link
+   * CodeOwner}s.
+   *
+   * <p>Non-resolvable code owners are filtered out.
+   *
+   * @param pathCodeOwners the path code owners that should be resolved
+   * @return the resolved code owners
+   */
+  public CodeOwnerResolverResult resolvePathCodeOwners(PathCodeOwners pathCodeOwners) {
+    requireNonNull(pathCodeOwners, "pathCodeOwners");
+
+    try (Timer0.Context ctx = codeOwnerMetrics.resolvePathCodeOwners.start()) {
+      logger.atFine().log(
+          "resolve path code owners (code owner config = %s, path = %s)",
+          pathCodeOwners.getCodeOwnerConfig().key(), pathCodeOwners.getPath());
+      OptionalResultWithMessages<PathCodeOwnersResult> pathCodeOwnersResult =
+          pathCodeOwners.resolveCodeOwnerConfig();
       return resolve(
-          pathCodeOwnersResult.getPathCodeOwners(), pathCodeOwnersResult.hasUnresolvedImports());
+          pathCodeOwnersResult.get().getPathCodeOwners(),
+          pathCodeOwnersResult.get().unresolvedImports(),
+          pathCodeOwnersResult.messages());
     }
   }
 
@@ -167,7 +191,8 @@
    * @return the resolved global code owners of the given project
    */
   public CodeOwnerResolverResult resolveGlobalCodeOwners(Project.NameKey projectName) {
-    return resolve(codeOwnersPluginConfiguration.getGlobalCodeOwners(projectName));
+    return resolve(
+        codeOwnersPluginConfiguration.getProjectConfig(projectName).getGlobalCodeOwners());
   }
 
   /**
@@ -178,45 +203,67 @@
    * @see #resolve(CodeOwnerReference)
    */
   public CodeOwnerResolverResult resolve(Set<CodeOwnerReference> codeOwnerReferences) {
-    return resolve(codeOwnerReferences, /* hasUnresolvedImports= */ false);
+    return resolve(
+        codeOwnerReferences,
+        /* unresolvedImports= */ ImmutableList.of(),
+        /* pathCodeOwnersMessages= */ ImmutableList.of());
   }
 
   /**
    * 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
+   * @param unresolvedImports list of unresolved imports
+   * @param pathCodeOwnersMessages messages that were collected when resolving path code owners
    * @return the {@link CodeOwner} for the given code owner references
    * @see #resolve(CodeOwnerReference)
    */
   private CodeOwnerResolverResult resolve(
-      Set<CodeOwnerReference> codeOwnerReferences, boolean hasUnresolvedImports) {
+      Set<CodeOwnerReference> codeOwnerReferences,
+      List<UnresolvedImport> unresolvedImports,
+      ImmutableList<String> pathCodeOwnersMessages) {
     requireNonNull(codeOwnerReferences, "codeOwnerReferences");
-    AtomicBoolean ownedByAllUsers = new AtomicBoolean(false);
-    AtomicBoolean hasUnresolvedCodeOwners = new AtomicBoolean(false);
-    ImmutableSet<CodeOwner> codeOwners =
-        codeOwnerReferences.stream()
-            .filter(
-                codeOwnerReference -> {
-                  if (ALL_USERS_WILDCARD.equals(codeOwnerReference.email())) {
-                    ownedByAllUsers.set(true);
-                    return false;
-                  }
-                  return true;
-                })
-            .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(), hasUnresolvedCodeOwners.get(), hasUnresolvedImports);
+    requireNonNull(unresolvedImports, "unresolvedImports");
+    requireNonNull(pathCodeOwnersMessages, "pathCodeOwnersMessages");
+
+    try (Timer0.Context ctx = codeOwnerMetrics.resolveCodeOwnerReferences.start()) {
+      AtomicBoolean ownedByAllUsers = new AtomicBoolean(false);
+      AtomicBoolean hasUnresolvedCodeOwners = new AtomicBoolean(false);
+      List<String> messages = new ArrayList<>(pathCodeOwnersMessages);
+      unresolvedImports.forEach(
+          unresolvedImport -> messages.add(unresolvedImportFormatter.format(unresolvedImport)));
+      ImmutableSet<CodeOwner> codeOwners =
+          codeOwnerReferences.stream()
+              .filter(
+                  codeOwnerReference -> {
+                    if (ALL_USERS_WILDCARD.equals(codeOwnerReference.email())) {
+                      ownedByAllUsers.set(true);
+                      return false;
+                    }
+                    return true;
+                  })
+              .map(this::resolveWithMessages)
+              .filter(
+                  resolveResult -> {
+                    messages.addAll(resolveResult.messages());
+                    if (!resolveResult.isPresent()) {
+                      hasUnresolvedCodeOwners.set(true);
+                      return false;
+                    }
+                    return true;
+                  })
+              .map(OptionalResultWithMessages::get)
+              .collect(toImmutableSet());
+      CodeOwnerResolverResult codeOwnerResolverResult =
+          CodeOwnerResolverResult.create(
+              codeOwners,
+              ownedByAllUsers.get(),
+              hasUnresolvedCodeOwners.get(),
+              !unresolvedImports.isEmpty(),
+              messages);
+      logger.atFine().log("resolve result = %s", codeOwnerResolverResult);
+      return codeOwnerResolverResult;
+    }
   }
 
   /**
@@ -240,11 +287,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
@@ -260,33 +308,45 @@
    *     Optional#empty()}
    */
   public Optional<CodeOwner> resolve(CodeOwnerReference codeOwnerReference) {
+    OptionalResultWithMessages<CodeOwner> resolveResult = resolveWithMessages(codeOwnerReference);
+    logger.atFine().log("resolve result = %s", resolveResult);
+    return resolveResult.result();
+  }
+
+  public OptionalResultWithMessages<CodeOwner> resolveWithMessages(
+      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 Optional.empty();
+    List<String> messages = new ArrayList<>();
+    messages.add(String.format("resolving code owner reference %s", codeOwnerReference));
+
+    OptionalResultWithMessages<Boolean> emailDomainAllowedResult = isEmailDomainAllowed(email);
+    messages.addAll(emailDomainAllowedResult.messages());
+    if (!emailDomainAllowedResult.get()) {
+      return OptionalResultWithMessages.createEmpty(messages);
     }
 
-    Optional<AccountState> accountState =
-        lookupEmail(email).flatMap(accountId -> lookupAccount(accountId, email));
-    if (!accountState.isPresent()) {
-      logger.atFine().log("no account for email %s", email);
-      return Optional.empty();
-    }
-    if (!accountState.get().account().isActive()) {
-      logger.atFine().log("account for email %s is inactive", email);
-      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 Optional.empty();
+    OptionalResultWithMessages<AccountState> activeAccountResult =
+        lookupActiveAccountForEmail(email);
+    messages.addAll(activeAccountResult.messages());
+    if (activeAccountResult.isEmpty()) {
+      return OptionalResultWithMessages.createEmpty(messages);
     }
 
-    CodeOwner codeOwner = CodeOwner.create(accountState.get().account().id());
-    logger.atFine().log("resolved to code owner %s", codeOwner);
-    return Optional.of(codeOwner);
+    AccountState accountState = activeAccountResult.get();
+    if (enforceVisibility) {
+      OptionalResultWithMessages<Boolean> isVisibleResult = isVisible(accountState, email);
+      messages.addAll(isVisibleResult.messages());
+      if (!isVisibleResult.get()) {
+        return OptionalResultWithMessages.createEmpty(messages);
+      }
+    } else {
+      messages.add("code owner visibility is not checked");
+    }
+
+    CodeOwner codeOwner = CodeOwner.create(accountState.account().id());
+    messages.add(String.format("resolved to account %s", codeOwner.accountId()));
+    return OptionalResultWithMessages.create(codeOwner, messages);
   }
 
   /** Whether the given account can be seen. */
@@ -297,34 +357,74 @@
   }
 
   /**
-   * Looks up an email and returns the ID of the account to which it belongs.
+   * Looks up an email and returns the ID of the active account to which it belongs.
    *
-   * <p>If the email is ambiguous (it belongs to multiple accounts) it is considered as
-   * non-resolvable and {@link Optional#empty()} is returned.
+   * <p>If the email is ambiguous (it belongs to multiple active accounts) it is considered as
+   * non-resolvable and empty result is returned.
    *
    * @param email the email that should be looked up
    * @return the ID of the account to which the email belongs if was found
    */
-  private Optional<Account.Id> lookupEmail(String email) {
+  private OptionalResultWithMessages<AccountState> lookupActiveAccountForEmail(String email) {
     ImmutableSet<ExternalId> extIds;
     try {
       extIds = externalIds.byEmail(email);
     } catch (IOException e) {
-      throw new StorageException(String.format("cannot resolve code owner email %s", email), e);
+      throw new CodeOwnersInternalServerErrorException(
+          String.format("cannot resolve code owner email %s", email), e);
     }
 
     if (extIds.isEmpty()) {
-      logger.atFine().log(
-          "cannot resolve code owner email %s: no account with this email exists", email);
-      return Optional.empty();
+      return OptionalResultWithMessages.createEmpty(
+          String.format(
+              "cannot resolve code owner email %s: no account with this email exists", email));
     }
 
-    if (extIds.stream().map(ExternalId::accountId).distinct().count() > 1) {
-      logger.atFine().log("cannot resolve code owner email %s: email is ambiguous", email);
-      return Optional.empty();
+    List<String> messages = new ArrayList<>();
+    OptionalResultWithMessages<ImmutableSet<AccountState>> activeAccountsResult =
+        lookupActiveAccounts(extIds, email);
+    ImmutableSet<AccountState> activeAccounts = activeAccountsResult.get();
+    messages.addAll(activeAccountsResult.messages());
+
+    if (activeAccounts.isEmpty()) {
+      messages.add(
+          String.format(
+              "cannot resolve code owner email %s: no active account with this email found",
+              email));
+      return OptionalResultWithMessages.createEmpty(messages);
     }
 
-    return Optional.of(extIds.stream().findFirst().get().accountId());
+    if (activeAccounts.size() > 1) {
+      messages.add(String.format("cannot resolve code owner email %s: email is ambiguous", email));
+      return OptionalResultWithMessages.createEmpty(messages);
+    }
+
+    return OptionalResultWithMessages.create(Iterables.getOnlyElement(activeAccounts));
+  }
+
+  private OptionalResultWithMessages<ImmutableSet<AccountState>> lookupActiveAccounts(
+      ImmutableSet<ExternalId> extIds, String email) {
+    ImmutableSet<OptionalResultWithMessages<AccountState>> accountStateResults =
+        extIds.stream()
+            .map(externalId -> lookupAccount(externalId.accountId(), externalId.email()))
+            .collect(toImmutableSet());
+
+    ImmutableSet.Builder<AccountState> activeAccounts = ImmutableSet.builder();
+    List<String> messages = new ArrayList<>();
+    for (OptionalResultWithMessages<AccountState> accountStateResult : accountStateResults) {
+      messages.addAll(accountStateResult.messages());
+      if (accountStateResult.isPresent()) {
+        AccountState accountState = accountStateResult.get();
+        if (accountState.account().isActive()) {
+          activeAccounts.add(accountState);
+        } else {
+          messages.add(
+              String.format(
+                  "account %s for email %s is inactive", accountState.account().id(), email));
+        }
+      }
+    }
+    return OptionalResultWithMessages.create(activeAccounts.build(), messages);
   }
 
   /**
@@ -335,67 +435,115 @@
    * @param email the email that was resolved to the account ID
    * @return the {@link AccountState} of the account with the given account ID, if it exists
    */
-  private Optional<AccountState> lookupAccount(Account.Id accountId, String email) {
+  private OptionalResultWithMessages<AccountState> lookupAccount(
+      Account.Id accountId, String email) {
     Optional<AccountState> accountState = accountCache.get(accountId);
     if (!accountState.isPresent()) {
-      logger.atFine().log(
-          "cannot resolve code owner email %s: email belongs to account %s,"
-              + " but no account with this ID exists",
-          email, accountId);
-      return Optional.empty();
+      return OptionalResultWithMessages.createEmpty(
+          String.format(
+              "cannot resolve account %s for email %s: account does not exists", accountId, email));
     }
-    return accountState;
+    return OptionalResultWithMessages.create(accountState.get());
   }
 
   /**
-   * 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) {
+  private OptionalResultWithMessages<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());
-      return false;
+      return OptionalResultWithMessages.create(
+          false,
+          String.format(
+              "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()));
     }
 
     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)) {
+          return OptionalResultWithMessages.create(
+              true,
+              String.format(
+                  "email %s is visible to user %s: email is a secondary email that is owned by this"
+                      + " user",
+                  email, user.getLoggableName()));
+        }
+      } 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;
+        return OptionalResultWithMessages.create(
+            true,
+            String.format(
+                "email %s is visible to the calling user %s: email is a secondary email that is"
+                    + " owned by this user",
+                email, currentUser.get().getLoggableName()));
       }
 
-      // 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)) {
-          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());
-          return false;
+        if (user != null) {
+          if (!permissionBackend.user(user).test(GlobalPermission.MODIFY_ACCOUNT)) {
+            return OptionalResultWithMessages.create(
+                false,
+                String.format(
+                    "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 OptionalResultWithMessages.create(
+              true,
+              String.format(
+                  "resolved code owner email %s: account %s is referenced by secondary email"
+                      + " and user %s can see secondary emails",
+                  email, accountState.account().id(), user.getLoggableName()));
+        } else if (!permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)) {
+          return OptionalResultWithMessages.create(
+              false,
+              String.format(
+                  "cannot resolve code owner email %s: account %s is referenced by secondary email"
+                      + " but the calling user %s cannot see secondary emails",
+                  email, accountState.account().id(), currentUser.get().getLoggableName()));
+        } else {
+          return OptionalResultWithMessages.create(
+              true,
+              String.format(
+                  "resolved code owner email %s: account %s is referenced by secondary email"
+                      + " and the calling user %s can see secondary emails",
+                  email, accountState.account().id(), currentUser.get().getLoggableName()));
         }
       } catch (PermissionBackendException e) {
-        throw new StorageException(
+        throw new CodeOwnersInternalServerErrorException(
             String.format(
                 "failed to test the %s global capability", GlobalPermission.MODIFY_ACCOUNT),
             e);
       }
     }
-
-    return true;
+    return OptionalResultWithMessages.create(
+        true,
+        String.format(
+            "account %s is visible to user %s",
+            accountState.account().id(),
+            user != null ? user.getLoggableName() : currentUser.get().getLoggableName()));
   }
 
   /**
@@ -405,29 +553,30 @@
    * @return {@code true} if the domain of the given email is allowed for code owners, otherwise
    *     {@code false}
    */
-  public boolean isEmailDomainAllowed(String email) {
+  public OptionalResultWithMessages<Boolean> isEmailDomainAllowed(String email) {
     requireNonNull(email, "email");
 
     ImmutableSet<String> allowedEmailDomains =
-        codeOwnersPluginConfiguration.getAllowedEmailDomains();
+        codeOwnersPluginConfiguration.getGlobalConfig().getAllowedEmailDomains();
     if (allowedEmailDomains.isEmpty()) {
-      // all domains are allowed
-      return true;
+      return OptionalResultWithMessages.create(true, "all domains are allowed");
     }
 
     if (email.equals(ALL_USERS_WILDCARD)) {
-      return true;
+      return OptionalResultWithMessages.create(true, "all users wildcard is allowed");
     }
 
     int emailAtIndex = email.lastIndexOf('@');
     if (emailAtIndex >= 0 && emailAtIndex < email.length() - 1) {
       String emailDomain = email.substring(emailAtIndex + 1);
-      logger.atFine().log("email domain = %s", emailDomain);
-      return allowedEmailDomains.contains(emailDomain);
+      boolean isEmailDomainAllowed = allowedEmailDomains.contains(emailDomain);
+      return OptionalResultWithMessages.create(
+          isEmailDomainAllowed,
+          String.format(
+              "domain %s of email %s is %s",
+              emailDomain, email, isEmailDomainAllowed ? "allowed" : "not allowed"));
     }
 
-    // email has no domain
-    logger.atFine().log("email %s has no domain", email);
-    return false;
+    return OptionalResultWithMessages.create(false, String.format("email %s has no domain", email));
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResult.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResult.java
index b5b71c0..0744e5b 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResult.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResult.java
@@ -18,8 +18,10 @@
 
 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.gerrit.entities.Account;
+import java.util.List;
 
 /** The result of resolving code owner references via {@link CodeOwnerResolver}. */
 @AutoValue
@@ -49,6 +51,9 @@
   /** Whether there are imports which couldn't be resolved. */
   public abstract boolean hasUnresolvedImports();
 
+  /** Gets messages that were collected while resolving the code owners. */
+  public abstract ImmutableList<String> messages();
+
   /**
    * Whether there are any code owners defined for the path, regardless of whether they can be
    * resolved or not.
@@ -61,12 +66,13 @@
   }
 
   @Override
-  public String toString() {
+  public final String toString() {
     return MoreObjects.toStringHelper(this)
         .add("codeOwners", codeOwners())
         .add("ownedByAllUsers", ownedByAllUsers())
         .add("hasUnresolvedCodeOwners", hasUnresolvedCodeOwners())
         .add("hasUnresolvedImports", hasUnresolvedImports())
+        .add("messages", messages())
         .toString();
   }
 
@@ -75,9 +81,14 @@
       ImmutableSet<CodeOwner> codeOwners,
       boolean ownedByAllUsers,
       boolean hasUnresolvedCodeOwners,
-      boolean hasUnresolvedImports) {
+      boolean hasUnresolvedImports,
+      List<String> messages) {
     return new AutoValue_CodeOwnerResolverResult(
-        codeOwners, ownedByAllUsers, hasUnresolvedCodeOwners, hasUnresolvedImports);
+        codeOwners,
+        ownedByAllUsers,
+        hasUnresolvedCodeOwners,
+        hasUnresolvedImports,
+        ImmutableList.copyOf(messages));
   }
 
   /** Creates a empty {@link CodeOwnerResolverResult} instance. */
@@ -86,6 +97,7 @@
         /* codeOwners= */ ImmutableSet.of(),
         /* ownedByAllUsers= */ false,
         /* hasUnresolvedCodeOwners= */ false,
-        /* hasUnresolvedImports= */ false);
+        /* hasUnresolvedImports= */ false,
+        /* messages= */ ImmutableList.of());
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScore.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScore.java
index 065425d..523c2ee 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScore.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScore.java
@@ -14,6 +14,11 @@
 
 package com.google.gerrit.plugins.codeowners.backend;
 
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gerrit.common.Nullable;
+import java.util.Optional;
+
 /**
  * Scores by which we rate how good we consider a code owner as reviewer/approver for a certain
  * path.
@@ -36,7 +41,30 @@
    * user Y is a better reviewer/approver for '/foo/bar/baz/' than user X as they have a lower
    * distance.
    */
-  DISTANCE(Kind.LOWER_VALUE_IS_BETTER);
+  DISTANCE(Kind.LOWER_VALUE_IS_BETTER, /* weight= */ 1, /* maxValue= */ null),
+
+  /**
+   * Score to take into account whether a code owner is a reviewer.
+   *
+   * <p>Code owners that are reviewers get scored with 1 (see {@link #IS_REVIEWER_SCORING_VALUE}),
+   * while code owners that are not a reviewer get scored with 0 (see {@link
+   * #NO_REVIEWER_SCORING_VALUE}).
+   *
+   * <p>The IS_REVIEWER score has a higher weight than the {@link #DISTANCE} score so that it takes
+   * precedence and code owners that are reviewers are always returned first.
+   */
+  IS_REVIEWER(Kind.GREATER_VALUE_IS_BETTER, /* weight= */ 2, /* maxValue= */ 1);
+
+  /**
+   * Scoring value for the {@link #IS_REVIEWER} score for users that are not a reviewer of the
+   * change.
+   */
+  public static int NO_REVIEWER_SCORING_VALUE = 0;
+
+  /**
+   * Scoring value for the {@link #IS_REVIEWER} score for users that are a reviewer of the change.
+   */
+  public static int IS_REVIEWER_SCORING_VALUE = 1;
 
   /**
    * Score kind.
@@ -57,17 +85,58 @@
    */
   private final Kind kind;
 
-  private CodeOwnerScore(Kind kind) {
+  /**
+   * The weight that this score should have when sorting code owners.
+   *
+   * <p>The higher the weight the larger the impact that this score has on the sorting.
+   */
+  private final double weight;
+
+  /**
+   * The max value that this score can have.
+   *
+   * <p>Not set if max value is not hard-coded, but is different case by case.
+   *
+   * <p>For scores that have a max value set scorings must be created by the {@link
+   * #createScoring()} method, for scores with flexible max values (maxValue = null) scorings must
+   * be created by the {@link #createScoring(int)} method.
+   */
+  @Nullable private final Integer maxValue;
+
+  private CodeOwnerScore(Kind kind, double weight, @Nullable Integer maxValue) {
     this.kind = kind;
+    this.weight = weight;
+    this.maxValue = maxValue;
   }
 
   /**
    * Creates a {@link CodeOwnerScoring.Builder} instance for this score.
    *
+   * <p>Use {@link #createScoring()} instead if the score has a max value set.
+   *
    * @param maxValue the max possible scoring value
    * @return the created {@link CodeOwnerScoring.Builder} instance
    */
   public CodeOwnerScoring.Builder createScoring(int maxValue) {
+    checkState(
+        this.maxValue == null,
+        "score %s has defined a maxValue, setting maxValue not allowed",
+        name());
+    return CodeOwnerScoring.builder(this, maxValue);
+  }
+
+  /**
+   * Creates a {@link CodeOwnerScoring.Builder} instance for this score.
+   *
+   * <p>Use {@link #createScoring(int)} instead if the score doesn't have a max value set.
+   *
+   * @return the created {@link CodeOwnerScoring.Builder} instance
+   */
+  public CodeOwnerScoring.Builder createScoring() {
+    checkState(
+        maxValue != null,
+        "score %s doesn't have a maxValue defined, setting maxValue is required",
+        name());
     return CodeOwnerScoring.builder(this, maxValue);
   }
 
@@ -79,4 +148,12 @@
   boolean isLowerValueBetter() {
     return Kind.LOWER_VALUE_IS_BETTER.equals(kind);
   }
+
+  double weight() {
+    return weight;
+  }
+
+  Optional<Integer> maxValue() {
+    return Optional.ofNullable(maxValue);
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScoring.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScoring.java
index 4694fd8..ce89e07 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScoring.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScoring.java
@@ -110,14 +110,15 @@
   }
 
   /**
-   * Returns a comparator to sort code owners by the scorings collected in this {@link
-   * CodeOwnerScoring} instance.
+   * Computes the weighted scoring for a code owner.
    *
-   * <p>Code owners with higher scoring come first. The order of code owners with the same scoring
-   * is undefined.
+   * <p>The result of {@link #scoring(CodeOwner)} multiplied with the {@code score().weight()}.
+   *
+   * @param codeOwner for which the weighted scoring should be computed
+   * @return the weighted scoring for the code owner
    */
-  public Comparator<CodeOwner> comparingByScoring() {
-    return Comparator.comparingDouble(this::scoring).reversed();
+  public double weightedScoring(CodeOwner codeOwner) {
+    return score().weight() * scoring(codeOwner);
   }
 
   /**
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScorings.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScorings.java
new file mode 100644
index 0000000..84bddff
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScorings.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * Class to sort code owners based on their scorings on different {@link CodeOwnerScore}s.
+ *
+ * <p>To determine the sort order the scorings are weighted based on the {@link
+ * CodeOwnerScore#weight()} of the {@link CodeOwnerScore} on which the scoring was done.
+ */
+@AutoValue
+public abstract class CodeOwnerScorings {
+  /** The scorings that should be taken into account for sorting the code owners. */
+  public abstract ImmutableSet<CodeOwnerScoring> scorings();
+
+  public static CodeOwnerScorings create(CodeOwnerScoring... codeOwnerScorings) {
+    return new AutoValue_CodeOwnerScorings(ImmutableSet.copyOf(codeOwnerScorings));
+  }
+
+  public static CodeOwnerScorings create(Set<CodeOwnerScoring> codeOwnerScorings) {
+    return new AutoValue_CodeOwnerScorings(ImmutableSet.copyOf(codeOwnerScorings));
+  }
+
+  /**
+   * Returns the total scorings for the given code owners.
+   *
+   * @param codeOwners the code owners for which the scorings should be returned
+   */
+  public ImmutableMap<CodeOwner, Double> getScorings(ImmutableSet<CodeOwner> codeOwners) {
+    return codeOwners.stream()
+        .collect(toImmutableMap(Function.identity(), this::sumWeightedScorings));
+  }
+
+  /** Returns the sum of all weighted scorings that available for the given code owner. */
+  private double sumWeightedScorings(CodeOwner codeOwner) {
+    double sum =
+        scorings().stream()
+            .map(scoring -> scoring.weightedScoring(codeOwner))
+            .collect(Collectors.summingDouble(Double::doubleValue));
+    return sum;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSubmitRule.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSubmitRule.java
index 373ec91..df96cc4 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSubmitRule.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSubmitRule.java
@@ -18,15 +18,16 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
-import com.google.gerrit.server.logging.Metadata;
-import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.SubmitRule;
@@ -34,6 +35,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.nio.file.InvalidPathException;
 import java.util.Optional;
 
 /** Submit rule that checks that all files in a change have been approved by their code owners. */
@@ -50,18 +52,24 @@
     }
   }
 
-  private static final SubmitRequirement SUBMIT_REQUIREMENT =
-      SubmitRequirement.builder().setFallbackText("Code Owners").setType("code-owners").build();
+  private static final LegacySubmitRequirement SUBMIT_REQUIREMENT =
+      LegacySubmitRequirement.builder()
+          .setFallbackText("Code Owners")
+          .setType("code-owners")
+          .build();
 
   private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
   private final CodeOwnerApprovalCheck codeOwnerApprovalCheck;
+  private final CodeOwnerMetrics codeOwnerMetrics;
 
   @Inject
   CodeOwnerSubmitRule(
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
-      CodeOwnerApprovalCheck codeOwnerApprovalCheck) {
+      CodeOwnerApprovalCheck codeOwnerApprovalCheck,
+      CodeOwnerMetrics codeOwnerMetrics) {
     this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
     this.codeOwnerApprovalCheck = codeOwnerApprovalCheck;
+    this.codeOwnerMetrics = codeOwnerMetrics;
   }
 
   @Override
@@ -69,14 +77,19 @@
     try {
       requireNonNull(changeData, "changeData");
 
-      try (TraceTimer traceTimer =
-          TraceContext.newTimer(
-              "Run code owner submit rule",
-              Metadata.builder()
-                  .projectName(changeData.project().get())
-                  .changeId(changeData.getId().get())
-                  .build())) {
-        if (codeOwnersPluginConfiguration.isDisabled(changeData.change().getDest())) {
+      if (changeData.change().isClosed()) {
+        return Optional.empty();
+      }
+
+      try (Timer0.Context ctx = codeOwnerMetrics.runCodeOwnerSubmitRule.start()) {
+        codeOwnerMetrics.countCodeOwnerSubmitRuleRuns.increment();
+        logger.atFine().log(
+            "run code owner submit rule (project = %s, change = %d)",
+            changeData.project().get(), changeData.getId().get());
+
+        if (codeOwnersPluginConfiguration
+            .getProjectConfig(changeData.project())
+            .isDisabled(changeData.change().getDest().branch())) {
           logger.atFine().log(
               "code owners functionality is disabled for branch %s", changeData.change().getDest());
           return Optional.empty();
@@ -84,7 +97,19 @@
 
         return Optional.of(getSubmitRecord(changeData.notes()));
       }
+    } catch (RestApiException e) {
+      logger.atFine().withCause(e).log(
+          String.format(
+              "Couldn't evaluate code owner statuses for patch set %d of change %d.",
+              changeData.currentPatchSet().id().get(), changeData.change().getId().get()));
+      return Optional.of(notReady());
     } catch (Throwable t) {
+      // Whether the exception should be treated as RULE_ERROR.
+      // RULE_ERROR must only be returned if the exception is caused by user misconfiguration (e.g.
+      // an invalid OWNERS file), but not for internal server errors.
+      boolean isRuleError = false;
+
+      String cause = t.getClass().getSimpleName();
       String errorMessage = "Failed to evaluate code owner statuses";
       if (changeData != null) {
         errorMessage +=
@@ -92,14 +117,49 @@
                 " for patch set %d of change %d",
                 changeData.currentPatchSet().id().get(), changeData.change().getId().get());
       }
+      Optional<InvalidPathException> invalidPathException =
+          CodeOwnersExceptionHook.getInvalidPathException(t);
+      Optional<InvalidCodeOwnerConfigException> invalidCodeOwnerConfigException =
+          CodeOwners.getInvalidCodeOwnerConfigCause(t);
+      if (invalidPathException.isPresent()) {
+        isRuleError = true;
+        cause = "invalid_path";
+        errorMessage += String.format(" (cause: %s)", invalidPathException.get().getMessage());
+      } else if (invalidCodeOwnerConfigException.isPresent()) {
+        isRuleError = true;
+        codeOwnerMetrics.countInvalidCodeOwnerConfigFiles.increment(
+            invalidCodeOwnerConfigException.get().getProjectName().get(),
+            invalidCodeOwnerConfigException.get().getRef(),
+            invalidCodeOwnerConfigException.get().getCodeOwnerConfigFilePath());
+
+        cause = "invalid_code_owner_config_file";
+        errorMessage +=
+            String.format(" (cause: %s)", invalidCodeOwnerConfigException.get().getMessage());
+
+        Optional<String> invalidCodeOwnerConfigInfoUrl =
+            codeOwnersPluginConfiguration
+                .getProjectConfig(invalidCodeOwnerConfigException.get().getProjectName())
+                .getInvalidCodeOwnerConfigInfoUrl();
+        if (invalidCodeOwnerConfigInfoUrl.isPresent()) {
+          errorMessage +=
+              String.format(".\nFor help check %s", invalidCodeOwnerConfigInfoUrl.get());
+        }
+      }
       errorMessage += ".";
-      logger.atSevere().withCause(t).log(errorMessage);
-      return Optional.of(ruleError(errorMessage));
+
+      if (isRuleError) {
+        codeOwnerMetrics.countCodeOwnerSubmitRuleErrors.increment(cause);
+
+        logger.atWarning().log(errorMessage);
+        return Optional.of(ruleError(errorMessage));
+      }
+      throw new CodeOwnersInternalServerErrorException(errorMessage, t);
     }
   }
 
   private SubmitRecord getSubmitRecord(ChangeNotes changeNotes)
-      throws ResourceConflictException, IOException, PatchListNotAvailableException {
+      throws ResourceConflictException, IOException, PatchListNotAvailableException,
+          DiffNotAvailableException {
     requireNonNull(changeNotes, "changeNotes");
     return codeOwnerApprovalCheck.isSubmittable(changeNotes) ? ok() : notReady();
   }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwners.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwners.java
index 2165fda..b9058d4 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwners.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwners.java
@@ -17,12 +17,13 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Throwables;
-import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.nio.file.Path;
 import java.util.Optional;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 
 /**
@@ -35,42 +36,45 @@
  * that we avoid code repetition in the code owner backends.
  */
 @Singleton
-public class CodeOwners {
+public class CodeOwners implements CodeOwnerConfigLoader {
   private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
+  private final CodeOwnerMetrics codeOwnerMetrics;
 
   @Inject
-  CodeOwners(CodeOwnersPluginConfiguration codeOwnersPluginConfiguration) {
+  CodeOwners(
+      CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
+      CodeOwnerMetrics codeOwnerMetrics) {
     this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
+    this.codeOwnerMetrics = codeOwnerMetrics;
   }
 
-  /**
-   * Retrieves the code owner config for the given key from the given branch revision.
-   *
-   * @param codeOwnerConfigKey the key of the code owner config that should be retrieved
-   * @param revision the branch revision from which the code owner config should be loaded
-   * @return the code owner config for the given key if it exists, otherwise {@link
-   *     Optional#empty()}
-   */
+  @Override
   public Optional<CodeOwnerConfig> get(CodeOwnerConfig.Key codeOwnerConfigKey, ObjectId revision) {
     requireNonNull(codeOwnerConfigKey, "codeOwnerConfigKey");
     requireNonNull(revision, "revision");
+    codeOwnerMetrics.countCodeOwnerConfigReads.increment();
     CodeOwnerBackend codeOwnerBackend =
-        codeOwnersPluginConfiguration.getBackend(codeOwnerConfigKey.branchNameKey());
-    return codeOwnerBackend.getCodeOwnerConfig(codeOwnerConfigKey, revision);
+        codeOwnersPluginConfiguration
+            .getProjectConfig(codeOwnerConfigKey.project())
+            .getBackend(codeOwnerConfigKey.branchNameKey().branch());
+    try (Timer1.Context<String> ctx =
+        codeOwnerMetrics.loadCodeOwnerConfig.start(codeOwnerBackend.getClass().getSimpleName())) {
+      return codeOwnerBackend.getCodeOwnerConfig(codeOwnerConfigKey, revision);
+    }
   }
 
-  /**
-   * Retrieves the code owner config for the given key from the current revision of the branch.
-   *
-   * @param codeOwnerConfigKey the key of the code owner config that should be retrieved
-   * @return the code owner config for the given key if it exists, otherwise {@link
-   *     Optional#empty()}
-   */
+  @Override
   public Optional<CodeOwnerConfig> getFromCurrentRevision(CodeOwnerConfig.Key codeOwnerConfigKey) {
     requireNonNull(codeOwnerConfigKey, "codeOwnerConfigKey");
+    codeOwnerMetrics.countCodeOwnerConfigReads.increment();
     CodeOwnerBackend codeOwnerBackend =
-        codeOwnersPluginConfiguration.getBackend(codeOwnerConfigKey.branchNameKey());
-    return codeOwnerBackend.getCodeOwnerConfig(codeOwnerConfigKey, /* revision= */ null);
+        codeOwnersPluginConfiguration
+            .getProjectConfig(codeOwnerConfigKey.project())
+            .getBackend(codeOwnerConfigKey.branchNameKey().branch());
+    try (Timer1.Context<String> ctx =
+        codeOwnerMetrics.loadCodeOwnerConfig.start(codeOwnerBackend.getClass().getSimpleName())) {
+      return codeOwnerBackend.getCodeOwnerConfig(codeOwnerConfigKey, /* revision= */ null);
+    }
   }
 
   /**
@@ -88,19 +92,22 @@
   public Path getFilePath(CodeOwnerConfig.Key codeOwnerConfigKey) {
     requireNonNull(codeOwnerConfigKey, "codeOwnerConfigKey");
     CodeOwnerBackend codeOwnerBackend =
-        codeOwnersPluginConfiguration.getBackend(codeOwnerConfigKey.branchNameKey());
+        codeOwnersPluginConfiguration
+            .getProjectConfig(codeOwnerConfigKey.project())
+            .getBackend(codeOwnerConfigKey.branchNameKey().branch());
     return codeOwnerBackend.getFilePath(codeOwnerConfigKey);
   }
 
   /**
    * Checks whether the given exception was caused by a non-parseable code owner config ({@link
-   * ConfigInvalidException}). If yes, the {@link ConfigInvalidException} is returned. If no, {@link
-   * Optional#empty()} is returned.
+   * InvalidCodeOwnerConfigException}). If yes, the {@link InvalidCodeOwnerConfigException} is
+   * returned. If no, {@link Optional#empty()} is returned.
    */
-  public static Optional<ConfigInvalidException> getInvalidConfigCause(Throwable e) {
+  public static Optional<InvalidCodeOwnerConfigException> getInvalidCodeOwnerConfigCause(
+      Throwable e) {
     return Throwables.getCausalChain(e).stream()
-        .filter(t -> t instanceof ConfigInvalidException)
-        .map(t -> (ConfigInvalidException) t)
+        .filter(t -> t instanceof InvalidCodeOwnerConfigException)
+        .map(t -> (InvalidCodeOwnerConfigException) t)
         .findFirst();
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersExceptionHook.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersExceptionHook.java
index b4e2931..468f5bb 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersExceptionHook.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersExceptionHook.java
@@ -17,8 +17,12 @@
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.plugins.codeowners.config.InvalidPluginConfigurationException;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.InvalidPluginConfigurationException;
+import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
 import com.google.gerrit.server.ExceptionHook;
+import com.google.inject.Inject;
+import java.nio.file.InvalidPathException;
 import java.util.Optional;
 
 /**
@@ -35,36 +39,108 @@
  * </ul>
  */
 public class CodeOwnersExceptionHook implements ExceptionHook {
+  private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
+  private final CodeOwnerMetrics codeOwnerMetrics;
+
+  @Inject
+  CodeOwnersExceptionHook(
+      CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
+      CodeOwnerMetrics codeOwnerMetric) {
+    this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
+    this.codeOwnerMetrics = codeOwnerMetric;
+  }
+
   @Override
   public boolean skipRetryWithTrace(String actionType, String actionName, Throwable throwable) {
     return isInvalidPluginConfigurationException(throwable)
-        || isInvalidCodeOwnerConfigException(throwable);
+        || isInvalidCodeOwnerConfigException(throwable)
+        || isInvalidPathException(throwable);
   }
 
   @Override
   public ImmutableList<String> getUserMessages(Throwable throwable, @Nullable String traceId) {
-    if (isInvalidPluginConfigurationException(throwable)
-        || isInvalidCodeOwnerConfigException(throwable)) {
-      return ImmutableList.of(throwable.getMessage());
+    Optional<InvalidPluginConfigurationException> invalidPluginConfigurationException =
+        getInvalidPluginConfigurationCause(throwable);
+    if (invalidPluginConfigurationException.isPresent()) {
+      return ImmutableList.of(invalidPluginConfigurationException.get().getMessage());
     }
+
+    Optional<InvalidCodeOwnerConfigException> invalidCodeOwnerConfigException =
+        CodeOwners.getInvalidCodeOwnerConfigCause(throwable);
+    if (invalidCodeOwnerConfigException.isPresent()) {
+      codeOwnerMetrics.countInvalidCodeOwnerConfigFiles.increment(
+          invalidCodeOwnerConfigException.get().getProjectName().get(),
+          invalidCodeOwnerConfigException.get().getRef(),
+          invalidCodeOwnerConfigException.get().getCodeOwnerConfigFilePath());
+
+      ImmutableList.Builder<String> messages = ImmutableList.builder();
+      messages.add(invalidCodeOwnerConfigException.get().getMessage());
+      codeOwnersPluginConfiguration
+          .getProjectConfig(invalidCodeOwnerConfigException.get().getProjectName())
+          .getInvalidCodeOwnerConfigInfoUrl()
+          .ifPresent(
+              invalidCodeOwnerConfigInfoUrl ->
+                  messages.add(String.format("For help check %s", invalidCodeOwnerConfigInfoUrl)));
+      return messages.build();
+    }
+
+    Optional<InvalidPathException> invalidPathException = getInvalidPathException(throwable);
+    if (invalidPathException.isPresent()) {
+      return ImmutableList.of(invalidPathException.get().getMessage());
+    }
+
+    // This must be done last since some of the exceptions we handle above may be wrapped in a
+    // CodeOwnersInternalServerErrorException.
+    Optional<CodeOwnersInternalServerErrorException> codeOwnersInternalServerErrorException =
+        getCodeOwnersInternalServerErrorException(throwable);
+    if (codeOwnersInternalServerErrorException.isPresent()) {
+      return ImmutableList.of(codeOwnersInternalServerErrorException.get().getUserVisibleMessage());
+    }
+
     return ImmutableList.of();
   }
 
   @Override
   public Optional<Status> getStatus(Throwable throwable) {
     if (isInvalidPluginConfigurationException(throwable)
-        || isInvalidCodeOwnerConfigException(throwable)) {
+        || isInvalidCodeOwnerConfigException(throwable)
+        || isInvalidPathException(throwable)) {
       return Optional.of(Status.create(409, "Conflict"));
     }
     return Optional.empty();
   }
 
+  private static Optional<CodeOwnersInternalServerErrorException>
+      getCodeOwnersInternalServerErrorException(Throwable throwable) {
+    return getCause(CodeOwnersInternalServerErrorException.class, throwable);
+  }
+
   private static boolean isInvalidPluginConfigurationException(Throwable throwable) {
+    return getInvalidPluginConfigurationCause(throwable).isPresent();
+  }
+
+  private static Optional<InvalidPluginConfigurationException> getInvalidPluginConfigurationCause(
+      Throwable throwable) {
+    return getCause(InvalidPluginConfigurationException.class, throwable);
+  }
+
+  private static boolean isInvalidPathException(Throwable throwable) {
+    return getInvalidPathException(throwable).isPresent();
+  }
+
+  public static Optional<InvalidPathException> getInvalidPathException(Throwable throwable) {
+    return getCause(InvalidPathException.class, throwable);
+  }
+
+  private static <T extends Throwable> Optional<T> getCause(
+      Class<T> exceptionClass, Throwable throwable) {
     return Throwables.getCausalChain(throwable).stream()
-        .anyMatch(t -> t instanceof InvalidPluginConfigurationException);
+        .filter(exceptionClass::isInstance)
+        .map(exceptionClass::cast)
+        .findFirst();
   }
 
   private static boolean isInvalidCodeOwnerConfigException(Throwable throwable) {
-    return CodeOwners.getInvalidConfigCause(throwable).isPresent();
+    return CodeOwners.getInvalidCodeOwnerConfigCause(throwable).isPresent();
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersExperimentFeaturesConstants.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersExperimentFeaturesConstants.java
new file mode 100644
index 0000000..a0e82ad
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersExperimentFeaturesConstants.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+/**
+ * Constants for {@link com.google.gerrit.server.experiments.ExperimentFeatures} in the code-owners
+ * plugin.
+ */
+public final class CodeOwnersExperimentFeaturesConstants {
+  /**
+   * Whether {@link com.google.gerrit.server.patch.DiffOperations}, and thus the diff cache, should
+   * be used to get changed files, instead of computing the changed files on our own.
+   *
+   * @see ChangedFiles#getOrCompute(com.google.gerrit.entities.Project.NameKey,
+   *     org.eclipse.jgit.lib.ObjectId)
+   */
+  public static final String USE_DIFF_CACHE =
+      "GerritBackendRequestFeature__code_owners_use_diff_cache";
+
+  /**
+   * Private constructor to prevent instantiation of this class.
+   *
+   * <p>The class only contains static fields, hence the class never needs to be instantiated.
+   */
+  private CodeOwnersExperimentFeaturesConstants() {}
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersInternalServerErrorException.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersInternalServerErrorException.java
new file mode 100644
index 0000000..13b1a9d
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersInternalServerErrorException.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+/** Exception signaling an internal server error in the code-owners plugin. */
+public class CodeOwnersInternalServerErrorException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  private static final String USER_MESSAGE = "Internal server in code-owners plugin";
+
+  public CodeOwnersInternalServerErrorException(String message) {
+    super(message);
+  }
+
+  public CodeOwnersInternalServerErrorException(String message, Throwable cause) {
+    super(message, cause);
+  }
+
+  public String getUserVisibleMessage() {
+    return USER_MESSAGE;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java
new file mode 100644
index 0000000..1aa9660
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java
@@ -0,0 +1,207 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.events.ReviewerAddedListener;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginProjectConfigSnapshot;
+import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+/**
+ * Callback that is invoked when a user is added as a reviewer.
+ *
+ * <p>If a code owner was added as reviewer add a change message that lists the files that are owned
+ * by the reviewer.
+ */
+@Singleton
+public class CodeOwnersOnAddReviewer implements ReviewerAddedListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final String TAG_ADD_REVIEWER =
+      ChangeMessagesUtil.AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "code-owners:addReviewer";
+
+  private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
+  private final CodeOwnerApprovalCheck codeOwnerApprovalCheck;
+  private final Provider<CurrentUser> userProvider;
+  private final RetryHelper retryHelper;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final AccountCache accountCache;
+  private final ChangeMessagesUtil changeMessageUtil;
+  private final CodeOwnerMetrics codeOwnerMetrics;
+
+  @Inject
+  CodeOwnersOnAddReviewer(
+      CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
+      CodeOwnerApprovalCheck codeOwnerApprovalCheck,
+      Provider<CurrentUser> userProvider,
+      RetryHelper retryHelper,
+      ChangeNotes.Factory changeNotesFactory,
+      AccountCache accountCache,
+      ChangeMessagesUtil changeMessageUtil,
+      CodeOwnerMetrics codeOwnerMetrics) {
+    this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
+    this.codeOwnerApprovalCheck = codeOwnerApprovalCheck;
+    this.userProvider = userProvider;
+    this.retryHelper = retryHelper;
+    this.changeNotesFactory = changeNotesFactory;
+    this.accountCache = accountCache;
+    this.changeMessageUtil = changeMessageUtil;
+    this.codeOwnerMetrics = codeOwnerMetrics;
+  }
+
+  @Override
+  public void onReviewersAdded(Event event) {
+    Change.Id changeId = Change.id(event.getChange()._number);
+    Project.NameKey projectName = Project.nameKey(event.getChange().project);
+
+    CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig =
+        codeOwnersPluginConfiguration.getProjectConfig(projectName);
+    int maxPathsInChangeMessages = codeOwnersConfig.getMaxPathsInChangeMessages();
+    if (codeOwnersConfig.isDisabled(event.getChange().branch) || maxPathsInChangeMessages <= 0) {
+      return;
+    }
+
+    try (Timer0.Context ctx = codeOwnerMetrics.addChangeMessageOnAddReviewer.start()) {
+      retryHelper
+          .changeUpdate(
+              "addCodeOwnersMessageOnAddReviewer",
+              updateFactory -> {
+                try (BatchUpdate batchUpdate =
+                    updateFactory.create(projectName, userProvider.get(), TimeUtil.nowTs())) {
+                  batchUpdate.addOp(
+                      changeId, new Op(event.getReviewers(), maxPathsInChangeMessages));
+                  batchUpdate.execute();
+                }
+                return null;
+              })
+          .call();
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log(
+          String.format(
+              "Failed to post code-owners change message for reviewer on change %s in project %s.",
+              changeId, projectName));
+    }
+  }
+
+  private class Op implements BatchUpdateOp {
+    private final List<AccountInfo> reviewers;
+    private final int limit;
+
+    Op(List<AccountInfo> reviewers, int limit) {
+      this.reviewers = reviewers;
+      this.limit = limit;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws Exception {
+      String message =
+          reviewers.stream()
+              .map(accountInfo -> Account.id(accountInfo._accountId))
+              .map(
+                  reviewerAccountId ->
+                      buildMessageForReviewer(
+                          ctx.getProject(), ctx.getChange().getId(), reviewerAccountId))
+              .filter(Optional::isPresent)
+              .map(Optional::get)
+              .collect(joining("\n"));
+
+      if (message.isEmpty()) {
+        return false;
+      }
+
+      ChangeMessage changeMessage = ChangeMessagesUtil.newMessage(ctx, message, TAG_ADD_REVIEWER);
+      changeMessageUtil.addChangeMessage(
+          ctx.getUpdate(ctx.getChange().currentPatchSetId()), changeMessage);
+      return true;
+    }
+
+    private Optional<String> buildMessageForReviewer(
+        Project.NameKey projectName, Change.Id changeId, Account.Id reviewerAccountId) {
+      ChangeNotes changeNotes = changeNotesFactory.create(projectName, changeId);
+
+      ImmutableList<Path> ownedPaths;
+      try {
+        // limit + 1, so that we can show an indicator if there are more than <limit> files.
+        ownedPaths =
+            codeOwnerApprovalCheck.getOwnedPaths(
+                changeNotes,
+                changeNotes.getCurrentPatchSet(),
+                reviewerAccountId,
+                /* start= */ 0,
+                limit + 1);
+      } catch (RestApiException e) {
+        logger.atFine().withCause(e).log(
+            "Couldn't compute owned paths of change %s for account %s",
+            changeNotes.getChangeId(), reviewerAccountId.get());
+        return Optional.empty();
+      }
+
+      if (ownedPaths.isEmpty()) {
+        // this reviewer doesn't own any of the modified paths
+        return Optional.empty();
+      }
+
+      Account reviewerAccount = accountCache.getEvenIfMissing(reviewerAccountId).account();
+
+      StringBuilder message = new StringBuilder();
+      message.append(
+          String.format(
+              "%s who was added as reviewer owns the following files:\n",
+              reviewerAccount.getName()));
+
+      if (ownedPaths.size() <= limit) {
+        appendPaths(message, ownedPaths.stream());
+      } else {
+        appendPaths(message, ownedPaths.stream().limit(limit));
+        message.append("(more files)\n");
+      }
+
+      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())));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersUpdate.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersUpdate.java
index e123b84..5e41d70 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersUpdate.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersUpdate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.plugins.codeowners.backend;
 
-import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -96,7 +96,9 @@
   public Optional<CodeOwnerConfig> upsertCodeOwnerConfig(
       CodeOwnerConfig.Key codeOwnerConfigKey, CodeOwnerConfigUpdate codeOwnerConfigUpdate) {
     CodeOwnerBackend codeOwnerBackend =
-        codeOwnersPluginConfiguration.getBackend(codeOwnerConfigKey.branchNameKey());
+        codeOwnersPluginConfiguration
+            .getProjectConfig(codeOwnerConfigKey.project())
+            .getBackend(codeOwnerConfigKey.branchNameKey().branch());
     return codeOwnerBackend.upsertCodeOwnerConfig(
         codeOwnerConfigKey, codeOwnerConfigUpdate, currentUser.orElse(null));
   }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/EnableImplicitApprovals.java b/java/com/google/gerrit/plugins/codeowners/backend/EnableImplicitApprovals.java
new file mode 100644
index 0000000..bcad5e8
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/EnableImplicitApprovals.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+/** Enum to control whether implicit code-owner approvals by the patch set uploader are enabled. */
+public enum EnableImplicitApprovals {
+  /** Implicit code-owner approvals of the the patch set uploader are disabled. */
+  FALSE,
+
+  /**
+   * Implicit code-owner approvals of the patch set uploader are enabled, but only if the configured
+   * required label allows self approvals.
+   */
+  TRUE,
+
+  /**
+   * Implicit code-owner approvals of the patch set uploader are enabled, even if the configured
+   * required label disallows self approvals.
+   */
+  FORCED;
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/FallbackCodeOwners.java b/java/com/google/gerrit/plugins/codeowners/backend/FallbackCodeOwners.java
index 5ad4079..872473b 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/FallbackCodeOwners.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/FallbackCodeOwners.java
@@ -32,5 +32,11 @@
    * 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;
+  ALL_USERS,
+
+  /**
+   * Paths for which no code owners are defined are owned by the project owners. This means changes
+   * to these paths can be approved by the project owners.
+   */
+  PROJECT_OWNERS;
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/FileCodeOwnerStatus.java b/java/com/google/gerrit/plugins/codeowners/backend/FileCodeOwnerStatus.java
index 3d084db..330917d 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/FileCodeOwnerStatus.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/FileCodeOwnerStatus.java
@@ -14,7 +14,13 @@
 
 package com.google.gerrit.plugins.codeowners.backend;
 
+import static java.util.Objects.requireNonNull;
+
 import com.google.auto.value.AutoValue;
+import com.google.gerrit.plugins.codeowners.common.ChangedFile;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
+import java.nio.file.Path;
 import java.util.Optional;
 
 /** Code owner status for a particular file that was changed in a change. */
@@ -55,4 +61,77 @@
     return new AutoValue_FileCodeOwnerStatus(
         changedFile, newPathCodeOwnerStatus, oldPathCodeOwnerStatus);
   }
+
+  public static FileCodeOwnerStatus addition(String path, CodeOwnerStatus codeOwnerStatus) {
+    requireNonNull(path, "path");
+
+    return addition(JgitPath.of(path).getAsAbsolutePath(), codeOwnerStatus);
+  }
+
+  public static FileCodeOwnerStatus addition(Path path, CodeOwnerStatus codeOwnerStatus) {
+    requireNonNull(path, "path");
+    requireNonNull(codeOwnerStatus, "codeOwnerStatus");
+
+    return create(
+        ChangedFile.addition(path),
+        Optional.of(PathCodeOwnerStatus.create(path, codeOwnerStatus)),
+        Optional.empty());
+  }
+
+  public static FileCodeOwnerStatus modification(Path path, CodeOwnerStatus codeOwnerStatus) {
+    requireNonNull(path, "path");
+    requireNonNull(codeOwnerStatus, "codeOwnerStatus");
+
+    return create(
+        ChangedFile.modification(path),
+        Optional.of(PathCodeOwnerStatus.create(path, codeOwnerStatus)),
+        Optional.empty());
+  }
+
+  public static FileCodeOwnerStatus deletion(String path, CodeOwnerStatus codeOwnerStatus) {
+    requireNonNull(path, "path");
+
+    return deletion(JgitPath.of(path).getAsAbsolutePath(), codeOwnerStatus);
+  }
+
+  public static FileCodeOwnerStatus deletion(Path path, CodeOwnerStatus codeOwnerStatus) {
+    requireNonNull(path, "path");
+    requireNonNull(codeOwnerStatus, "codeOwnerStatus");
+
+    return create(
+        ChangedFile.deletion(path),
+        Optional.empty(),
+        Optional.of(PathCodeOwnerStatus.create(path, codeOwnerStatus)));
+  }
+
+  public static FileCodeOwnerStatus rename(
+      String oldPath,
+      CodeOwnerStatus oldPathCodeOwnerStatus,
+      String newPath,
+      CodeOwnerStatus newPathCodeOwnerStatus) {
+    requireNonNull(oldPath, "oldPath");
+    requireNonNull(newPath, "newPath");
+
+    return rename(
+        JgitPath.of(oldPath).getAsAbsolutePath(),
+        oldPathCodeOwnerStatus,
+        JgitPath.of(newPath).getAsAbsolutePath(),
+        newPathCodeOwnerStatus);
+  }
+
+  public static FileCodeOwnerStatus rename(
+      Path oldPath,
+      CodeOwnerStatus oldPathCodeOwnerStatus,
+      Path newPath,
+      CodeOwnerStatus newPathCodeOwnerStatus) {
+    requireNonNull(oldPath, "oldPath");
+    requireNonNull(oldPathCodeOwnerStatus, "oldPathCodeOwnerStatus");
+    requireNonNull(newPath, "newPath");
+    requireNonNull(newPathCodeOwnerStatus, "newPathCodeOwnerStatus");
+
+    return create(
+        ChangedFile.rename(newPath, oldPath),
+        Optional.of(PathCodeOwnerStatus.create(newPath, newPathCodeOwnerStatus)),
+        Optional.of(PathCodeOwnerStatus.create(oldPath, oldPathCodeOwnerStatus)));
+  }
 }
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..650ae50
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/FindOwnersGlobMatcher.java
@@ -0,0 +1,40 @@
+// 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 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 {
+  /** 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) {
+    // always match files in all subdirectories
+    return GlobMatcher.INSTANCE.matches("{**/,}" + glob, relativePath);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/InvalidCodeOwnerConfigCallback.java b/java/com/google/gerrit/plugins/codeowners/backend/InvalidCodeOwnerConfigCallback.java
index fed838a..400432e 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/InvalidCodeOwnerConfigCallback.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/InvalidCodeOwnerConfigCallback.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.plugins.codeowners.backend;
 
 import java.nio.file.Path;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /** Callback interface to let callers handle invalid code owner config files. */
 public interface InvalidCodeOwnerConfigCallback {
@@ -23,8 +22,9 @@
    * Invoked when an invalid code owner config file is found.
    *
    * @param codeOwnerConfigFilePath the path of the invalid code owner config file
-   * @param configInvalidException the parsing exception
+   * @param invalidCodeOwnerConfigException the parsing exception
    */
   void onInvalidCodeOwnerConfig(
-      Path codeOwnerConfigFilePath, ConfigInvalidException configInvalidException);
+      Path codeOwnerConfigFilePath,
+      InvalidCodeOwnerConfigException invalidCodeOwnerConfigException);
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/InvalidCodeOwnerConfigException.java b/java/com/google/gerrit/plugins/codeowners/backend/InvalidCodeOwnerConfigException.java
new file mode 100644
index 0000000..7806c59
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/InvalidCodeOwnerConfigException.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/** Exception that is thrown if there is an invalid code owner config file. */
+public class InvalidCodeOwnerConfigException extends ConfigInvalidException {
+  private static final long serialVersionUID = 1L;
+
+  private final Project.NameKey projectName;
+  private final String ref;
+  private final String codeOwnerConfigFilePath;
+
+  public InvalidCodeOwnerConfigException(
+      String message, Project.NameKey projectName, String ref, String codeOwnerConfigFilePath) {
+    this(message, projectName, ref, codeOwnerConfigFilePath, /* cause= */ null);
+  }
+
+  public InvalidCodeOwnerConfigException(
+      String message,
+      Project.NameKey projectName,
+      String ref,
+      String codeOwnerConfigFilePath,
+      @Nullable Throwable cause) {
+    super(message, cause);
+
+    this.projectName = requireNonNull(projectName, "projectName");
+    this.ref = requireNonNull(ref, "ref");
+    this.codeOwnerConfigFilePath =
+        requireNonNull(codeOwnerConfigFilePath, "codeOwnerConfigFilePath");
+  }
+
+  public Project.NameKey getProjectName() {
+    return projectName;
+  }
+
+  public String getRef() {
+    return ref;
+  }
+
+  public String getCodeOwnerConfigFilePath() {
+    return codeOwnerConfigFilePath;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerApproval.java b/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerApproval.java
new file mode 100644
index 0000000..d6beb5f
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerApproval.java
@@ -0,0 +1,281 @@
+// 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 com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginProjectConfigSnapshot;
+import com.google.gerrit.plugins.codeowners.backend.config.RequiredApproval;
+import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+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.nio.file.Path;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+/**
+ * Callback that is invoked on post review and that extends the change message if a code owner
+ * approval was changed.
+ *
+ * <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 OnCodeOwnerApproval implements OnPostReview {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
+  private final CodeOwnerApprovalCheck codeOwnerApprovalCheck;
+  private final CodeOwnerMetrics codeOwnerMetrics;
+
+  @Inject
+  OnCodeOwnerApproval(
+      CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
+      CodeOwnerApprovalCheck codeOwnerApprovalCheck,
+      CodeOwnerMetrics codeOwnerMetrics) {
+    this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
+    this.codeOwnerApprovalCheck = codeOwnerApprovalCheck;
+    this.codeOwnerMetrics = codeOwnerMetrics;
+  }
+
+  @Override
+  public Optional<String> getChangeMessageAddOn(
+      IdentifiedUser user,
+      ChangeNotes changeNotes,
+      PatchSet patchSet,
+      Map<String, Short> oldApprovals,
+      Map<String, Short> approvals) {
+    CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig =
+        codeOwnersPluginConfiguration.getProjectConfig(changeNotes.getProjectName());
+    int maxPathsInChangeMessage = codeOwnersConfig.getMaxPathsInChangeMessages();
+    if (codeOwnersConfig.isDisabled(changeNotes.getChange().getDest().branch())
+        || maxPathsInChangeMessage <= 0) {
+      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 = codeOwnersConfig.getRequiredApproval();
+
+    if (oldApprovals.get(requiredApproval.labelType().getName()) == null) {
+      // If oldApprovals doesn't contain the label or if the labels value in it is null, the label
+      // was not changed.
+      // This means that the user only voted on unrelated labels.
+      return Optional.empty();
+    }
+
+    try (Timer0.Context ctx = codeOwnerMetrics.extendChangeMessageOnPostReview.start()) {
+      return buildMessageForCodeOwnerApproval(
+          user,
+          changeNotes,
+          patchSet,
+          oldApprovals,
+          approvals,
+          requiredApproval,
+          maxPathsInChangeMessage);
+    }
+  }
+
+  private Optional<String> buildMessageForCodeOwnerApproval(
+      IdentifiedUser user,
+      ChangeNotes changeNotes,
+      PatchSet patchSet,
+      Map<String, Short> oldApprovals,
+      Map<String, Short> approvals,
+      RequiredApproval requiredApproval,
+      int limit) {
+    LabelVote newVote = getNewVote(requiredApproval, approvals);
+
+    ImmutableList<Path> ownedPaths;
+    try {
+      // limit + 1, so that we can show an indicator if there are more than <limit> files.
+      ownedPaths =
+          codeOwnerApprovalCheck.getOwnedPaths(
+              changeNotes,
+              changeNotes.getCurrentPatchSet(),
+              user.getAccountId(),
+              /* start= */ 0,
+              limit + 1);
+    } catch (RestApiException e) {
+      logger.atFine().withCause(e).log(
+          "Couldn't compute owned paths of change %s for account %s",
+          changeNotes.getChangeId(), user.getAccountId().get());
+      return Optional.empty();
+    }
+
+    if (ownedPaths.isEmpty()) {
+      // the user doesn't own any of the modified paths
+      return Optional.empty();
+    }
+
+    if (isIgnoredDueToSelfApproval(user, patchSet, requiredApproval)) {
+      if (isCodeOwnerApprovalNewlyApplied(requiredApproval, oldApprovals, newVote)
+          || isCodeOwnerApprovalUpOrDowngraded(requiredApproval, oldApprovals, newVote)) {
+        return Optional.of(
+            String.format(
+                "The vote %s is ignored as code-owner approval since the label doesn't allow"
+                    + " self approval of the patch set uploader.",
+                newVote));
+      }
+      return Optional.empty();
+    }
+
+    boolean hasImplicitApprovalByUser =
+        codeOwnersPluginConfiguration
+                .getProjectConfig(changeNotes.getProjectName())
+                .areImplicitApprovalsEnabled()
+            && 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 {
+      // non-approval was downgraded (e.g. -1 to -2)
+      return Optional.empty();
+    }
+
+    if (ownedPaths.size() <= limit) {
+      appendPaths(message, ownedPaths.stream());
+    } else {
+      appendPaths(message, ownedPaths.stream().limit(limit));
+      message.append("(more files)\n");
+    }
+
+    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 isIgnoredDueToSelfApproval(
+      IdentifiedUser user, PatchSet patchSet, RequiredApproval requiredApproval) {
+    return patchSet.uploader().equals(user.getAccountId())
+        && requiredApproval.labelType().isIgnoreSelfApproval();
+  }
+
+  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));
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerOverride.java b/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerOverride.java
new file mode 100644
index 0000000..09e3624
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerOverride.java
@@ -0,0 +1,189 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.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.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginProjectConfigSnapshot;
+import com.google.gerrit.plugins.codeowners.backend.config.RequiredApproval;
+import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+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.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Callback that is invoked on post review and that extends the change message if a code owner
+ * override was changed.
+ *
+ * <p>If a code owner override was added, removed or changed, include in the change message that is
+ * being posted on vote, that the vote is a code owner override to let users know about its effect.
+ */
+@Singleton
+class OnCodeOwnerOverride implements OnPostReview {
+  private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
+  private final CodeOwnerMetrics codeOwnerMetrics;
+
+  @Inject
+  OnCodeOwnerOverride(
+      CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
+      CodeOwnerMetrics codeOwnerMetrics) {
+    this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
+    this.codeOwnerMetrics = codeOwnerMetrics;
+  }
+
+  @Override
+  public Optional<String> getChangeMessageAddOn(
+      IdentifiedUser user,
+      ChangeNotes changeNotes,
+      PatchSet patchSet,
+      Map<String, Short> oldApprovals,
+      Map<String, Short> approvals) {
+    CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig =
+        codeOwnersPluginConfiguration.getProjectConfig(changeNotes.getProjectName());
+    if (codeOwnersConfig.isDisabled(changeNotes.getChange().getDest().branch())) {
+      return Optional.empty();
+    }
+
+    // code owner overrides are only relevant for the current patch set
+    if (!changeNotes.getChange().currentPatchSetId().equals(patchSet.id())) {
+      return Optional.empty();
+    }
+
+    ImmutableList<RequiredApproval> appliedOverrideApprovals =
+        codeOwnersConfig.getOverrideApprovals().stream()
+            .sorted(comparing(RequiredApproval::toString))
+            // If oldApprovals doesn't contain the label or if the labels value in it is null, the
+            // label was not changed.
+            .filter(
+                overrideApproval ->
+                    oldApprovals.get(overrideApproval.labelType().getName()) != null)
+            .collect(toImmutableList());
+
+    if (appliedOverrideApprovals.isEmpty()) {
+      return Optional.empty();
+    }
+
+    try (Timer0.Context ctx = codeOwnerMetrics.extendChangeMessageOnPostReview.start()) {
+      List<String> messages = new ArrayList<>();
+      appliedOverrideApprovals.forEach(
+          overrideApproval ->
+              buildMessageForCodeOwnerOverride(
+                      user, patchSet, oldApprovals, approvals, overrideApproval)
+                  .ifPresent(messages::add));
+      if (messages.isEmpty()) {
+        return Optional.empty();
+      }
+      return Optional.of(Joiner.on("\n\n").join(messages));
+    }
+  }
+
+  private Optional<String> buildMessageForCodeOwnerOverride(
+      IdentifiedUser user,
+      PatchSet patchSet,
+      Map<String, Short> oldApprovals,
+      Map<String, Short> approvals,
+      RequiredApproval overrideApproval) {
+    LabelVote newVote = getNewVote(overrideApproval, approvals);
+
+    if (isIgnoredDueToSelfApproval(user, patchSet, overrideApproval)) {
+      if (isCodeOwnerOverrideNewlyApplied(overrideApproval, oldApprovals, newVote)
+          || isCodeOwnerOverrideUpOrDowngraded(overrideApproval, oldApprovals, newVote)) {
+        return Optional.of(
+            String.format(
+                "The vote %s is ignored as code-owner override since the label doesn't allow"
+                    + " self approval of the patch set uploader.",
+                newVote));
+      }
+      return Optional.empty();
+    }
+
+    if (isCodeOwnerOverrideNewlyApplied(overrideApproval, oldApprovals, newVote)) {
+      return Optional.of(
+          String.format(
+              "By voting %s the code-owners submit requirement is overridden by %s",
+              newVote, user.getName()));
+    } else if (isCodeOwnerOverrideRemoved(overrideApproval, oldApprovals, newVote)) {
+      if (newVote.value() == 0) {
+        return Optional.of(
+            String.format(
+                "By removing the %s vote the code-owners submit requirement is no longer overridden"
+                    + " by %s",
+                newVote.label(), user.getName()));
+      }
+      return Optional.of(
+          String.format(
+              "By voting %s the code-owners submit requirement is no longer overridden by %s",
+              newVote, user.getName()));
+    } else if (isCodeOwnerOverrideUpOrDowngraded(overrideApproval, oldApprovals, newVote)) {
+      return Optional.of(
+          String.format(
+              "By voting %s the code-owners submit requirement is still overridden by %s",
+              newVote, user.getName()));
+    }
+    // non-approval was downgraded (e.g. -1 to -2)
+    return Optional.empty();
+  }
+
+  private boolean isIgnoredDueToSelfApproval(
+      IdentifiedUser user, PatchSet patchSet, RequiredApproval requiredApproval) {
+    return patchSet.uploader().equals(user.getAccountId())
+        && requiredApproval.labelType().isIgnoreSelfApproval();
+  }
+
+  private boolean isCodeOwnerOverrideNewlyApplied(
+      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 isCodeOwnerOverrideRemoved(
+      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 isCodeOwnerOverrideUpOrDowngraded(
+      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));
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/OptionalResultWithMessages.java b/java/com/google/gerrit/plugins/codeowners/backend/OptionalResultWithMessages.java
new file mode 100644
index 0000000..66fce67
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/OptionalResultWithMessages.java
@@ -0,0 +1,83 @@
+// 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 java.util.Objects.requireNonNull;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * An optional result of an operation with optional messages.
+ *
+ * @param <T> type of the optional result
+ */
+@AutoValue
+public abstract class OptionalResultWithMessages<T> {
+  /** Gets the result. */
+  public abstract Optional<T> result();
+
+  /** Whether the result is present. */
+  public boolean isPresent() {
+    return result().isPresent();
+  }
+
+  /** Whether the result is empty. */
+  public boolean isEmpty() {
+    return !result().isPresent();
+  }
+
+  /** Returns the result value, if present. Fails if the result is not present. */
+  public T get() {
+    return result().get();
+  }
+
+  /** Gets the messages. */
+  public abstract ImmutableList<String> messages();
+
+  /** Creates a {@link OptionalResultWithMessages} instance without messages. */
+  public static <T> OptionalResultWithMessages<T> create(T result) {
+    return create(result, ImmutableList.of());
+  }
+
+  /** Creates an empty {@link OptionalResultWithMessages} instance with a single message. */
+  public static <T> OptionalResultWithMessages<T> createEmpty(String message) {
+    requireNonNull(message, "message");
+    return createEmpty(ImmutableList.of(message));
+  }
+
+  /** Creates an empty {@link OptionalResultWithMessages} instance with messages. */
+  public static <T> OptionalResultWithMessages<T> createEmpty(List<String> messages) {
+    requireNonNull(messages, "messages");
+    return new AutoValue_OptionalResultWithMessages<>(
+        Optional.empty(), ImmutableList.copyOf(messages));
+  }
+
+  /** Creates a {@link OptionalResultWithMessages} instance with messages. */
+  public static <T> OptionalResultWithMessages<T> create(T result, String message) {
+    requireNonNull(message, "message");
+    return create(result, ImmutableList.of(message));
+  }
+
+  /** Creates a {@link OptionalResultWithMessages} instance with messages. */
+  public static <T> OptionalResultWithMessages<T> create(T result, List<String> messages) {
+    requireNonNull(result, "result");
+    requireNonNull(messages, "messages");
+    return new AutoValue_OptionalResultWithMessages<>(
+        Optional.of(result), ImmutableList.copyOf(messages));
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnerStatus.java b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnerStatus.java
index 9285a52..b5f0976 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnerStatus.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnerStatus.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.plugins.codeowners.backend;
 
+import static java.util.Objects.requireNonNull;
+
 import com.google.auto.value.AutoValue;
-import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import java.nio.file.Path;
 
 /** Code owner status for a particular path that has been modified in a change. */
@@ -41,4 +44,18 @@
   public static PathCodeOwnerStatus create(Path path, CodeOwnerStatus codeOwnerStatus) {
     return new AutoValue_PathCodeOwnerStatus(path, codeOwnerStatus);
   }
+
+  /**
+   * Creates a {@link PathCodeOwnerStatus} instance.
+   *
+   * @param path the path to which the code owner status belongs
+   * @param codeOwnerStatus the code owner status
+   * @return the created {@link PathCodeOwnerStatus} instance
+   */
+  public static PathCodeOwnerStatus create(String path, CodeOwnerStatus codeOwnerStatus) {
+    requireNonNull(path, "path");
+    requireNonNull(codeOwnerStatus, "codeOwnerStatus");
+
+    return create(JgitPath.of(path).getAsAbsolutePath(), codeOwnerStatus);
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java
index 078ba17..8272bb6 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java
@@ -19,22 +19,27 @@
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
-import com.google.gerrit.server.logging.Metadata;
-import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
 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.nio.file.Path;
 import java.util.ArrayDeque;
+import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Queue;
@@ -52,24 +57,29 @@
 
   @Singleton
   public static class Factory {
+    private final CodeOwnerMetrics codeOwnerMetrics;
     private final ProjectCache projectCache;
     private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
     private final CodeOwners codeOwners;
 
     @Inject
     Factory(
+        CodeOwnerMetrics codeOwnerMetrics,
         ProjectCache projectCache,
         CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
         CodeOwners codeOwners) {
+      this.codeOwnerMetrics = codeOwnerMetrics;
       this.projectCache = projectCache;
       this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
       this.codeOwners = codeOwners;
     }
 
-    public PathCodeOwners create(CodeOwnerConfig codeOwnerConfig, Path absolutePath) {
+    public PathCodeOwners createWithoutCache(CodeOwnerConfig codeOwnerConfig, Path absolutePath) {
       requireNonNull(codeOwnerConfig, "codeOwnerConfig");
       return new PathCodeOwners(
+          codeOwnerMetrics,
           projectCache,
+          /* transientCodeOwnerConfigCache= */ null,
           codeOwners,
           codeOwnerConfig,
           absolutePath,
@@ -77,13 +87,21 @@
     }
 
     public Optional<PathCodeOwners> create(
-        CodeOwnerConfig.Key codeOwnerConfigKey, ObjectId revision, Path absolutePath) {
-      return codeOwners
+        TransientCodeOwnerConfigCache transientCodeOwnerConfigCache,
+        CodeOwnerConfig.Key codeOwnerConfigKey,
+        ObjectId revision,
+        Path absolutePath) {
+      requireNonNull(transientCodeOwnerConfigCache, "transientCodeOwnerConfigCache");
+      requireNonNull(codeOwnerConfigKey, "codeOwnerConfigKey");
+      requireNonNull(revision, "revision");
+      return transientCodeOwnerConfigCache
           .get(codeOwnerConfigKey, revision)
           .map(
               codeOwnerConfig ->
                   new PathCodeOwners(
+                      codeOwnerMetrics,
                       projectCache,
+                      transientCodeOwnerConfigCache,
                       codeOwners,
                       codeOwnerConfig,
                       absolutePath,
@@ -109,28 +127,37 @@
      */
     private PathExpressionMatcher getMatcher(CodeOwnerConfig.Key codeOwnerConfigKey) {
       CodeOwnerBackend codeOwnerBackend =
-          codeOwnersPluginConfiguration.getBackend(codeOwnerConfigKey.branchNameKey());
+          codeOwnersPluginConfiguration
+              .getProjectConfig(codeOwnerConfigKey.project())
+              .getBackend(codeOwnerConfigKey.branchNameKey().branch());
       return codeOwnerBackend
           .getPathExpressionMatcher()
           .orElse((pathExpression, relativePath) -> false);
     }
   }
 
+  private final CodeOwnerMetrics codeOwnerMetrics;
   private final ProjectCache projectCache;
+  private final CodeOwnerConfigLoader codeOwnerConfigLoader;
   private final CodeOwners codeOwners;
   private final CodeOwnerConfig codeOwnerConfig;
   private final Path path;
   private final PathExpressionMatcher pathExpressionMatcher;
 
-  private PathCodeOwnersResult pathCodeOwnersResult;
+  private OptionalResultWithMessages<PathCodeOwnersResult> pathCodeOwnersResult;
 
   private PathCodeOwners(
+      CodeOwnerMetrics codeOwnerMetrics,
       ProjectCache projectCache,
+      @Nullable TransientCodeOwnerConfigCache transientCodeOwnerConfigCache,
       CodeOwners codeOwners,
       CodeOwnerConfig codeOwnerConfig,
       Path path,
       PathExpressionMatcher pathExpressionMatcher) {
+    this.codeOwnerMetrics = requireNonNull(codeOwnerMetrics, "codeOwnerMetrics");
     this.projectCache = requireNonNull(projectCache, "projectCache");
+    this.codeOwnerConfigLoader =
+        transientCodeOwnerConfigCache != null ? transientCodeOwnerConfigCache : codeOwners;
     this.codeOwners = requireNonNull(codeOwners, "codeOwners");
     this.codeOwnerConfig = requireNonNull(codeOwnerConfig, "codeOwnerConfig");
     this.path = requireNonNull(path, "path");
@@ -144,6 +171,11 @@
     return codeOwnerConfig;
   }
 
+  /** Returns the absolute path for which code owners were computed. */
+  public Path getPath() {
+    return path;
+  }
+
   /**
    * Resolves the {@link #codeOwnerConfig}.
    *
@@ -180,57 +212,124 @@
    *
    * @return the resolved code owner config
    */
-  public PathCodeOwnersResult resolveCodeOwnerConfig() {
+  public OptionalResultWithMessages<PathCodeOwnersResult> resolveCodeOwnerConfig() {
     if (this.pathCodeOwnersResult != null) {
       return this.pathCodeOwnersResult;
     }
 
-    try (TraceTimer traceTimer =
-        TraceContext.newTimer(
-            "Resolve code owner config",
-            Metadata.builder()
-                .projectName(codeOwnerConfig.key().project().get())
-                .filePath(path.toString())
-                .build())) {
+    try (Timer0.Context ctx = codeOwnerMetrics.resolveCodeOwnerConfig.start()) {
       logger.atFine().log(
           "resolve code owners for %s from code owner config %s", path, codeOwnerConfig.key());
 
+      List<String> messages = new ArrayList<>();
+      messages.add(
+          String.format(
+              "resolve code owners for %s from code owner config %s", path, codeOwnerConfig.key()));
+
+      // Create a code owner config builder to create the resolved code owner config (= code owner
+      // config that is scoped to the path and which has imports resolved)
       CodeOwnerConfig.Builder resolvedCodeOwnerConfigBuilder =
           CodeOwnerConfig.builder(codeOwnerConfig.key(), codeOwnerConfig.revision());
 
-      // Add all data from the importing code owner config.
+      // Add all data from the original code owner config that is relevant for the path
+      // (ignoreParentCodeOwners flag, global code owner sets and matching per-file code owner
+      // sets). Effectively this means we are dropping all non-matching per-file rules.
       resolvedCodeOwnerConfigBuilder.setIgnoreParentCodeOwners(
           codeOwnerConfig.ignoreParentCodeOwners());
       getGlobalCodeOwnerSets(codeOwnerConfig)
           .forEach(resolvedCodeOwnerConfigBuilder::addCodeOwnerSet);
-      getMatchingPerFileCodeOwnerSets(codeOwnerConfig)
-          .forEach(resolvedCodeOwnerConfigBuilder::addCodeOwnerSet);
+      boolean globalCodeOwnersIgnored = false;
+      for (CodeOwnerSet codeOwnerSet :
+          getMatchingPerFileCodeOwnerSets(codeOwnerConfig).collect(toImmutableSet())) {
+        messages.add(
+            String.format(
+                "per-file code owner set with path expressions %s matches",
+                codeOwnerSet.pathExpressions()));
+        resolvedCodeOwnerConfigBuilder.addCodeOwnerSet(codeOwnerSet);
+        if (codeOwnerSet.ignoreGlobalAndParentCodeOwners()) {
+          globalCodeOwnersIgnored = true;
+        }
+      }
 
-      boolean hasUnresolvedImports =
-          !resolveImports(codeOwnerConfig, resolvedCodeOwnerConfigBuilder);
+      // Resolve global imports.
+      ImmutableList.Builder<UnresolvedImport> unresolvedImports = ImmutableList.builder();
+      ImmutableSet<CodeOwnerConfigImport> globalImports = getGlobalImports(0, codeOwnerConfig);
+      OptionalResultWithMessages<List<UnresolvedImport>> unresolvedGlobalImports;
+      if (!globalCodeOwnersIgnored) {
+        unresolvedGlobalImports =
+            resolveImports(codeOwnerConfig.key(), globalImports, resolvedCodeOwnerConfigBuilder);
+      } else {
+        // skip global import with mode GLOBAL_CODE_OWNER_SETS_ONLY,
+        // since we already know that global code owners will be ignored, we do not need to resolve
+        // these imports
+        unresolvedGlobalImports =
+            resolveImports(
+                codeOwnerConfig.key(),
+                globalImports.stream()
+                    .filter(
+                        codeOwnerConfigImport ->
+                            codeOwnerConfigImport.referenceToImportedCodeOwnerConfig().importMode()
+                                != CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY)
+                    .collect(toImmutableSet()),
+                resolvedCodeOwnerConfigBuilder);
+      }
+      messages.addAll(unresolvedGlobalImports.messages());
+      unresolvedImports.addAll(unresolvedGlobalImports.get());
 
-      CodeOwnerConfig resolvedCodeOwnerConfig = resolvedCodeOwnerConfigBuilder.build();
-
-      // Remove global code owner sets if any per-file code owner set has the
-      // ignoreGlobalAndParentCodeOwners flag set to true.
+      // Remove all global code owner sets if any per-file code owner set has the
+      // ignoreGlobalAndParentCodeOwners flag set to true (as in this case they are ignored and
+      // hence not relevant).
       // In this case also set ignoreParentCodeOwners to true, so that we do not need to inspect the
-      // ignoreGlobalAndParentCodeOwners flags again.
-      if (getMatchingPerFileCodeOwnerSets(resolvedCodeOwnerConfig)
-          .anyMatch(CodeOwnerSet::ignoreGlobalAndParentCodeOwners)) {
-        logger.atFine().log("remove global code owner sets and set ignoreParentCodeOwners to true");
-        resolvedCodeOwnerConfig =
-            resolvedCodeOwnerConfig
+      // ignoreGlobalAndParentCodeOwners flags on per-file code owner sets again, but can just rely
+      // on the global ignoreParentCodeOwners flag.
+      Optional<CodeOwnerSet> matchingPerFileCodeOwnerSetThatIgnoresGlobalAndParentCodeOwners =
+          getMatchingPerFileCodeOwnerSets(resolvedCodeOwnerConfigBuilder.build())
+              .filter(CodeOwnerSet::ignoreGlobalAndParentCodeOwners)
+              .findAny();
+      if (matchingPerFileCodeOwnerSetThatIgnoresGlobalAndParentCodeOwners.isPresent()) {
+        logger.atFine().log("remove folder code owner sets and set ignoreParentCodeOwners to true");
+        messages.add(
+            String.format(
+                "found matching per-file code owner set (with path expressions = %s) that ignores"
+                    + " parent code owners, hence ignoring the folder code owners",
+                matchingPerFileCodeOwnerSetThatIgnoresGlobalAndParentCodeOwners
+                    .get()
+                    .pathExpressions()));
+        // We use resolvedCodeOwnerConfigBuilder to build up a code owner config that is scoped to
+        // the path and which has imports resolved. When resolving imports the relevant code owner
+        // sets from the imported code owner configs are added to the builder.
+        // If a per-file rule ignores global and parent code owners we have to drop all global code
+        // owner sets. The problem is that AutoValue doesn't allow us to remove/override code owner
+        // sets that have previously been added to the builder (we cannot call setCodeOwnerSets(...)
+        // after addCodeOwnerSet(...) or codeOwnerSetsBuilder() has been invoked). To override the
+        // code owner sets we build the code owner config and then create a fresh builder from it.
+        // Since the builder is fresh addCodeOwnerSet(...) and codeOwnerSetsBuilder() haven't been
+        // invoked on it yet we can now call setCodeOwnerSets(...).
+        resolvedCodeOwnerConfigBuilder =
+            resolvedCodeOwnerConfigBuilder
+                .build()
                 .toBuilder()
                 .setIgnoreParentCodeOwners()
                 .setCodeOwnerSets(
-                    resolvedCodeOwnerConfig.codeOwnerSets().stream()
+                    resolvedCodeOwnerConfigBuilder.codeOwnerSets().stream()
                         .filter(codeOwnerSet -> !codeOwnerSet.pathExpressions().isEmpty())
-                        .collect(toImmutableSet()))
-                .build();
+                        .collect(toImmutableSet()));
       }
 
+      // Resolve per-file imports.
+      ImmutableSet<CodeOwnerConfigImport> perFileImports =
+          getPerFileImports(
+              0, codeOwnerConfig.key(), resolvedCodeOwnerConfigBuilder.codeOwnerSets());
+      OptionalResultWithMessages<List<UnresolvedImport>> unresolvedPerFileImports =
+          resolveImports(codeOwnerConfig.key(), perFileImports, resolvedCodeOwnerConfigBuilder);
+      messages.addAll(unresolvedPerFileImports.messages());
+      unresolvedImports.addAll(unresolvedPerFileImports.get());
+
       this.pathCodeOwnersResult =
-          PathCodeOwnersResult.create(path, resolvedCodeOwnerConfig, hasUnresolvedImports);
+          OptionalResultWithMessages.create(
+              PathCodeOwnersResult.create(
+                  path, resolvedCodeOwnerConfigBuilder.build(), unresolvedImports.build()),
+              messages);
       logger.atFine().log("path code owners result = %s", pathCodeOwnersResult);
       return this.pathCodeOwnersResult;
     }
@@ -239,22 +338,20 @@
   /**
    * Resolve the imports of the given code owner config.
    *
-   * @param importingCodeOwnerConfig the code owner config for which imports should be resolved
+   * @param keyOfImportingCodeOwnerConfig the key of the importing code owner config
+   * @param codeOwnerConfigImports the code owner configs that should be imported
    * @param resolvedCodeOwnerConfigBuilder the builder for the resolved code owner config
-   * @return whether all imports have been resolved successfully
+   * @return list of unresolved imports, empty list if all imports were successfully resolved
    */
-  private boolean resolveImports(
-      CodeOwnerConfig importingCodeOwnerConfig,
+  private OptionalResultWithMessages<List<UnresolvedImport>> resolveImports(
+      CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig,
+      Set<CodeOwnerConfigImport> codeOwnerConfigImports,
       CodeOwnerConfig.Builder resolvedCodeOwnerConfigBuilder) {
-    boolean hasUnresolvedImports = false;
-    try (TraceTimer traceTimer =
-        TraceContext.newTimer(
-            "Resolve code owner config imports",
-            Metadata.builder()
-                .projectName(codeOwnerConfig.key().project().get())
-                .branchName(codeOwnerConfig.key().ref())
-                .filePath(codeOwnerConfig.key().filePath("<default>").toString())
-                .build())) {
+    ImmutableList.Builder<UnresolvedImport> unresolvedImports = ImmutableList.builder();
+    StringBuilder messageBuilder = new StringBuilder();
+    try (Timer0.Context ctx = codeOwnerMetrics.resolveCodeOwnerConfigImports.start()) {
+      logger.atFine().log("resolve imports of codeOwnerConfig %s", keyOfImportingCodeOwnerConfig);
+
       // To detect cyclic dependencies we keep track of all seen code owner configs.
       Set<CodeOwnerConfig.Key> seenCodeOwnerConfigs = new HashSet<>();
       seenCodeOwnerConfigs.add(codeOwnerConfig.key());
@@ -264,49 +361,54 @@
       Map<BranchNameKey, ObjectId> revisionMap = new HashMap<>();
       revisionMap.put(codeOwnerConfig.key().branchNameKey(), codeOwnerConfig.revision());
 
-      Queue<CodeOwnerConfigReference> codeOwnerConfigsToImport = new ArrayDeque<>();
-      codeOwnerConfigsToImport.addAll(importingCodeOwnerConfig.imports());
-      codeOwnerConfigsToImport.addAll(
-          resolvedCodeOwnerConfigBuilder.codeOwnerSets().stream()
-              .flatMap(codeOwnerSet -> codeOwnerSet.imports().stream())
-              .collect(toImmutableSet()));
-
+      Queue<CodeOwnerConfigImport> codeOwnerConfigsToImport = new ArrayDeque<>();
+      codeOwnerConfigsToImport.addAll(codeOwnerConfigImports);
+      if (!codeOwnerConfigsToImport.isEmpty()) {
+        messageBuilder.append(
+            String.format(
+                "Code owner config %s imports:\n",
+                keyOfImportingCodeOwnerConfig.format(codeOwners)));
+      }
       while (!codeOwnerConfigsToImport.isEmpty()) {
-        CodeOwnerConfigReference codeOwnerConfigReference = codeOwnerConfigsToImport.poll();
+        CodeOwnerConfigImport codeOwnerConfigImport = codeOwnerConfigsToImport.poll();
+        messageBuilder.append(codeOwnerConfigImport.format());
+
+        CodeOwnerConfigReference codeOwnerConfigReference =
+            codeOwnerConfigImport.referenceToImportedCodeOwnerConfig();
         CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
             createKeyForImportedCodeOwnerConfig(
-                importingCodeOwnerConfig.key(), codeOwnerConfigReference);
-        try (TraceTimer traceTimer2 =
-            TraceContext.newTimer(
-                "Resolve code owner config import",
-                Metadata.builder()
-                    .projectName(keyOfImportedCodeOwnerConfig.project().get())
-                    .branchName(keyOfImportedCodeOwnerConfig.ref())
-                    .filePath(
-                        keyOfImportedCodeOwnerConfig
-                            .filePath(codeOwnerConfigReference.fileName())
-                            .toString())
-                    .build())) {
+                keyOfImportingCodeOwnerConfig, codeOwnerConfigReference);
+
+        try (Timer0.Context ctx2 = codeOwnerMetrics.resolveCodeOwnerConfigImport.start()) {
+          logger.atFine().log(
+              "resolve import of code owner config %s", keyOfImportedCodeOwnerConfig);
+
           Optional<ProjectState> projectState =
               projectCache.get(keyOfImportedCodeOwnerConfig.project());
           if (!projectState.isPresent()) {
-            hasUnresolvedImports = true;
-            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(
+                    codeOwnerConfigImport.importingCodeOwnerConfig(),
+                    keyOfImportedCodeOwnerConfig,
+                    codeOwnerConfigReference,
+                    String.format(
+                        "project %s not found", keyOfImportedCodeOwnerConfig.project().get())));
+            messageBuilder.append(
+                codeOwnerConfigImport.formatSubItem("failed to resolve (project not found)\n"));
             continue;
           }
           if (!projectState.get().statePermitsRead()) {
-            hasUnresolvedImports = true;
-            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(
+                    codeOwnerConfigImport.importingCodeOwnerConfig(),
+                    keyOfImportedCodeOwnerConfig,
+                    codeOwnerConfigReference,
+                    String.format(
+                        "state of project %s doesn't permit read",
+                        keyOfImportedCodeOwnerConfig.project().get())));
+            messageBuilder.append(
+                codeOwnerConfigImport.formatSubItem(
+                    "failed to resolve (project state doesn't allow read)\n"));
             continue;
           }
 
@@ -318,17 +420,21 @@
 
           Optional<CodeOwnerConfig> mayBeImportedCodeOwnerConfig =
               revision.isPresent()
-                  ? codeOwners.get(keyOfImportedCodeOwnerConfig, revision.get())
-                  : codeOwners.getFromCurrentRevision(keyOfImportedCodeOwnerConfig);
+                  ? codeOwnerConfigLoader.get(keyOfImportedCodeOwnerConfig, revision.get())
+                  : codeOwnerConfigLoader.getFromCurrentRevision(keyOfImportedCodeOwnerConfig);
 
           if (!mayBeImportedCodeOwnerConfig.isPresent()) {
-            hasUnresolvedImports = true;
-            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(
+                    codeOwnerConfigImport.importingCodeOwnerConfig(),
+                    keyOfImportedCodeOwnerConfig,
+                    codeOwnerConfigReference,
+                    String.format(
+                        "code owner config does not exist (revision = %s)",
+                        revision.map(ObjectId::name).orElse("current"))));
+            messageBuilder.append(
+                codeOwnerConfigImport.formatSubItem(
+                    "failed to resolve (code owner config not found)\n"));
             continue;
           }
 
@@ -351,21 +457,32 @@
                 .forEach(resolvedCodeOwnerConfigBuilder::addCodeOwnerSet);
           }
 
+          ImmutableSet<CodeOwnerSet> matchingPerFileCodeOwnerSets =
+              getMatchingPerFileCodeOwnerSets(importedCodeOwnerConfig).collect(toImmutableSet());
           if (importMode.importPerFileCodeOwnerSets()) {
             logger.atFine().log("import per-file code owners");
-            getMatchingPerFileCodeOwnerSets(importedCodeOwnerConfig)
-                .forEach(resolvedCodeOwnerConfigBuilder::addCodeOwnerSet);
+            matchingPerFileCodeOwnerSets.forEach(
+                codeOwnerSet -> {
+                  messageBuilder.append(
+                      codeOwnerConfigImport.formatSubItem(
+                          String.format(
+                              "per-file code owner set with path expressions %s matches\n",
+                              codeOwnerSet.pathExpressions())));
+                  resolvedCodeOwnerConfigBuilder.addCodeOwnerSet(codeOwnerSet);
+                });
           }
 
           if (importMode.resolveImportsOfImport()
               && seenCodeOwnerConfigs.add(keyOfImportedCodeOwnerConfig)) {
             logger.atFine().log("resolve imports of imported code owner config");
-            Set<CodeOwnerConfigReference> transitiveImports = new HashSet<>();
-            transitiveImports.addAll(importedCodeOwnerConfig.imports());
+            Set<CodeOwnerConfigImport> transitiveImports = new HashSet<>();
             transitiveImports.addAll(
-                importedCodeOwnerConfig.codeOwnerSets().stream()
-                    .flatMap(codeOwnerSet -> codeOwnerSet.imports().stream())
-                    .collect(toImmutableSet()));
+                getGlobalImports(codeOwnerConfigImport.importLevel() + 1, importedCodeOwnerConfig));
+            transitiveImports.addAll(
+                getPerFileImports(
+                    codeOwnerConfigImport.importLevel() + 1,
+                    importedCodeOwnerConfig.key(),
+                    matchingPerFileCodeOwnerSets));
 
             if (importMode == CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY) {
               // If only global code owners should be imported, transitive imports should also only
@@ -377,10 +494,14 @@
               transitiveImports =
                   transitiveImports.stream()
                       .map(
-                          codeOwnerCfgRef ->
-                              CodeOwnerConfigReference.copyWithNewImportMode(
-                                  codeOwnerCfgRef,
-                                  CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY))
+                          codeOwnerCfgImport ->
+                              CodeOwnerConfigImport.create(
+                                  codeOwnerCfgImport.importLevel(),
+                                  codeOwnerCfgImport.importingCodeOwnerConfig(),
+                                  CodeOwnerConfigReference.copyWithNewImportMode(
+                                      codeOwnerCfgImport.referenceToImportedCodeOwnerConfig(),
+                                      CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY),
+                                  codeOwnerCfgImport.codeOwnerSet()))
                       .collect(toSet());
             }
 
@@ -390,7 +511,42 @@
         }
       }
     }
-    return !hasUnresolvedImports;
+    String message = messageBuilder.toString();
+    if (message.endsWith("\n")) {
+      message = message.substring(0, message.length() - 1);
+    }
+    return OptionalResultWithMessages.create(
+        unresolvedImports.build(),
+        !message.isEmpty() ? ImmutableList.of(message) : ImmutableList.of());
+  }
+
+  private ImmutableSet<CodeOwnerConfigImport> getGlobalImports(
+      int importLevel, CodeOwnerConfig codeOwnerConfig) {
+    return codeOwnerConfig.imports().stream()
+        .map(
+            codeOwnerConfigReference ->
+                CodeOwnerConfigImport.create(
+                    importLevel, codeOwnerConfig.key(), codeOwnerConfigReference))
+        .collect(toImmutableSet());
+  }
+
+  private ImmutableSet<CodeOwnerConfigImport> getPerFileImports(
+      int importLevel,
+      CodeOwnerConfig.Key importingCodeOwnerConfig,
+      Set<CodeOwnerSet> codeOwnerSets) {
+    ImmutableSet.Builder<CodeOwnerConfigImport> codeOwnerConfigImports = ImmutableSet.builder();
+    for (CodeOwnerSet codeOwnerSet : codeOwnerSets) {
+      codeOwnerSet.imports().stream()
+          .forEach(
+              codeOwnerConfigReference ->
+                  codeOwnerConfigImports.add(
+                      CodeOwnerConfigImport.create(
+                          importLevel,
+                          importingCodeOwnerConfig,
+                          codeOwnerConfigReference,
+                          codeOwnerSet)));
+    }
+    return codeOwnerConfigImports.build();
   }
 
   public static CodeOwnerConfig.Key createKeyForImportedCodeOwnerConfig(
@@ -465,4 +621,95 @@
     return codeOwnerSet.pathExpressions().stream()
         .anyMatch(pathExpression -> matcher.matches(pathExpression, relativePath));
   }
+
+  @AutoValue
+  abstract static class CodeOwnerConfigImport {
+    /**
+     * The import level.
+     *
+     * <p>{@code 0} for direct import, {@code 1} if imported by a directly imported file, {@code 2},
+     * if imported by a file that was imported by an directly imported file, etc.
+     */
+    public abstract int importLevel();
+
+    /** The key of the code owner config that contains the import. */
+    public abstract CodeOwnerConfig.Key importingCodeOwnerConfig();
+
+    /** The reference to the imported code owner config */
+    public abstract CodeOwnerConfigReference referenceToImportedCodeOwnerConfig();
+
+    /** The code owner set that specified the import, empty if it is a global import. */
+    public abstract Optional<CodeOwnerSet> codeOwnerSet();
+
+    public String format() {
+      if (codeOwnerSet().isPresent()) {
+        return getPrefix()
+            + String.format(
+                "* %s (per-file import, import mode = %s, path expressions = %s)\n",
+                referenceToImportedCodeOwnerConfig().format(),
+                referenceToImportedCodeOwnerConfig().importMode(),
+                codeOwnerSet().get().pathExpressions());
+      }
+      return getPrefix()
+          + String.format(
+              "* %s (global import, import mode = %s)\n",
+              referenceToImportedCodeOwnerConfig().format(),
+              referenceToImportedCodeOwnerConfig().importMode());
+    }
+
+    public String formatSubItem(String message) {
+      return getPrefixForSubItem() + message;
+    }
+
+    private String getPrefix() {
+      return getPrefix(importLevel());
+    }
+
+    private String getPrefixForSubItem() {
+      return getPrefix(importLevel() + 1) + "* ";
+    }
+
+    private String getPrefix(int levels) {
+      // 2 spaces per level
+      //
+      // String.format("%<num>s", "") creates a string with <num> spaces:
+      // * '%' introduces a format sequence
+      // * <num> means that the resulting string should be <num> characters long
+      // * 's' is the character string format code, and ends the format sequence
+      // * the second parameter for String.format, is the string that should be
+      //   prefixed with as many spaces as are needed to make the string <num>
+      //   characters long
+      // * <num> must be > 0, hence we special case the handling of levels == 0
+      return levels > 0 ? String.format("%" + (levels * 2) + "s", "") : "";
+    }
+
+    public static CodeOwnerConfigImport create(
+        int importLevel,
+        CodeOwnerConfig.Key importingCodeOwnerConfig,
+        CodeOwnerConfigReference codeOwnerConfigReference) {
+      return create(
+          importLevel, importingCodeOwnerConfig, codeOwnerConfigReference, Optional.empty());
+    }
+
+    public static CodeOwnerConfigImport create(
+        int importLevel,
+        CodeOwnerConfig.Key importingCodeOwnerConfig,
+        CodeOwnerConfigReference codeOwnerConfigReference,
+        CodeOwnerSet codeOwnerSet) {
+      return create(
+          importLevel,
+          importingCodeOwnerConfig,
+          codeOwnerConfigReference,
+          Optional.of(codeOwnerSet));
+    }
+
+    public static CodeOwnerConfigImport create(
+        int importLevel,
+        CodeOwnerConfig.Key importingCodeOwnerConfig,
+        CodeOwnerConfigReference codeOwnerConfigReference,
+        Optional<CodeOwnerSet> codeOwnerSet) {
+      return new AutoValue_PathCodeOwners_CodeOwnerConfigImport(
+          importLevel, importingCodeOwnerConfig, codeOwnerConfigReference, codeOwnerSet);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResult.java b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResult.java
index a463aef..bc45ded 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResult.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResult.java
@@ -18,9 +18,11 @@
 
 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
@@ -33,8 +35,13 @@
   /** 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 abstract boolean hasUnresolvedImports();
+  public boolean hasUnresolvedImports() {
+    return !unresolvedImports().isEmpty();
+  }
 
   /**
    * Gets the code owners from the code owner config that apply to the path.
@@ -64,17 +71,18 @@
   }
 
   @Override
-  public String toString() {
+  public final String toString() {
     return MoreObjects.toStringHelper(this)
         .add("path", path())
         .add("codeOwnerConfig", codeOwnerConfig())
-        .add("hasUnresolvedImports", hasUnresolvedImports())
+        .add("unresolvedImports", unresolvedImports())
         .toString();
   }
 
-  /** Creates a {@link CodeOwnerResolverResult} instance. */
+  /** Creates a {@link PathCodeOwnersResult} instance. */
   public static PathCodeOwnersResult create(
-      Path path, CodeOwnerConfig codeOwnerConfig, boolean hasUnresolvedImports) {
-    return new AutoValue_PathCodeOwnersResult(path, codeOwnerConfig, hasUnresolvedImports);
+      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/PathCodeOwnersVisitor.java b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersVisitor.java
new file mode 100644
index 0000000..2d69ca2
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersVisitor.java
@@ -0,0 +1,27 @@
+// 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;
+
+/** Callback interface to visit {@link PathCodeOwners}. */
+@FunctionalInterface
+public interface PathCodeOwnersVisitor {
+  /**
+   * Callback for {@link PathCodeOwners}.
+   *
+   * @param pathCodeOwners the path code owners
+   * @return whether further path code owner configs should be visited
+   */
+  boolean visit(PathCodeOwners pathCodeOwners);
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/TransientCodeOwnerConfigCache.java b/java/com/google/gerrit/plugins/codeowners/backend/TransientCodeOwnerConfigCache.java
new file mode 100644
index 0000000..e8a1ab7
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/TransientCodeOwnerConfigCache.java
@@ -0,0 +1,183 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Class to load and cache {@link CodeOwnerConfig}s within a request.
+ *
+ * <p>This cache is transient, which means the code owner configs stay cached only for the lifetime
+ * of the {@code TransientCodeOwnerConfigCache} instance.
+ *
+ * <p><strong>Note</strong>: This class is not thread-safe.
+ */
+public class TransientCodeOwnerConfigCache implements CodeOwnerConfigLoader {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final GitRepositoryManager repoManager;
+  private final CodeOwners codeOwners;
+  private final Optional<Integer> maxCacheSize;
+  private final Counters counters;
+  private final HashMap<CacheKey, Optional<CodeOwnerConfig>> cache = new HashMap<>();
+
+  @Inject
+  TransientCodeOwnerConfigCache(
+      CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
+      GitRepositoryManager repoManager,
+      CodeOwners codeOwners,
+      CodeOwnerMetrics codeOwnerMetrics) {
+    this.repoManager = repoManager;
+    this.codeOwners = codeOwners;
+    this.maxCacheSize =
+        codeOwnersPluginConfiguration.getGlobalConfig().getMaxCodeOwnerConfigCacheSize();
+    this.counters = new Counters(codeOwnerMetrics);
+  }
+
+  /**
+   * Gets the specified code owner config from the cache, if it was previously retrieved. Otherwise
+   * loads and returns the code owner config.
+   */
+  @Override
+  public Optional<CodeOwnerConfig> get(
+      CodeOwnerConfig.Key codeOwnerConfigKey, @Nullable ObjectId revision) {
+    CacheKey cacheKey = CacheKey.create(codeOwnerConfigKey, revision);
+    Optional<CodeOwnerConfig> cachedCodeOwnerConfig = cache.get(cacheKey);
+    if (cachedCodeOwnerConfig != null) {
+      counters.incrementCacheReads();
+      return cachedCodeOwnerConfig;
+    }
+    return loadAndCache(cacheKey);
+  }
+
+  /**
+   * Gets the specified code owner config from the cache, if it was previously retrieved. Otherwise
+   * loads and returns the code owner config.
+   */
+  @Override
+  public Optional<CodeOwnerConfig> getFromCurrentRevision(CodeOwnerConfig.Key codeOwnerConfigKey) {
+    return get(codeOwnerConfigKey, /* revision= */ null);
+  }
+
+  /** Load a code owner config and puts it into the cache. */
+  private Optional<CodeOwnerConfig> loadAndCache(CacheKey cacheKey) {
+    counters.incrementBackendReads();
+    Optional<CodeOwnerConfig> codeOwnerConfig;
+    if (cacheKey.revision().isPresent()) {
+      codeOwnerConfig = codeOwners.get(cacheKey.codeOwnerConfigKey(), cacheKey.revision().get());
+    } else {
+      Optional<ObjectId> revision = getRevision(cacheKey.codeOwnerConfigKey().branchNameKey());
+      if (revision.isPresent()) {
+        codeOwnerConfig = codeOwners.get(cacheKey.codeOwnerConfigKey(), revision.get());
+      } else {
+        // branch does not exists, hence the code owner config also doesn't exist
+        codeOwnerConfig = Optional.empty();
+      }
+    }
+    if (!maxCacheSize.isPresent() || cache.size() < maxCacheSize.get()) {
+      cache.put(cacheKey, codeOwnerConfig);
+    } else if (maxCacheSize.isPresent()) {
+      logger.atWarning().atMostEvery(1, TimeUnit.DAYS).log(
+          "exceeded limit of %s (project = %s)",
+          getClass().getSimpleName(), cacheKey.codeOwnerConfigKey().project());
+    }
+    return codeOwnerConfig;
+  }
+
+  /**
+   * Gets the revision for the given branch.
+   *
+   * <p>Returns {@link Optional#empty()} if the branch doesn't exist.
+   */
+  private Optional<ObjectId> getRevision(BranchNameKey branchNameKey) {
+    try (Repository repo = repoManager.openRepository(branchNameKey.project())) {
+      Ref ref = repo.exactRef(branchNameKey.branch());
+      if (ref == null) {
+        // branch does not exist
+        return Optional.empty();
+      }
+      return Optional.of(ref.getObjectId());
+    } catch (IOException e) {
+      throw new CodeOwnersInternalServerErrorException(
+          String.format(
+              "failed to get revision of branch %s in project %s",
+              branchNameKey.shortName(), branchNameKey.project()),
+          e);
+    }
+  }
+
+  @AutoValue
+  abstract static class CacheKey {
+    /** The key of the code owner config. */
+    public abstract CodeOwnerConfig.Key codeOwnerConfigKey();
+
+    /** The revision from which the code owner config was loaded. */
+    public abstract Optional<ObjectId> revision();
+
+    public static CacheKey create(
+        CodeOwnerConfig.Key codeOwnerConfigKey, @Nullable ObjectId revision) {
+      return new AutoValue_TransientCodeOwnerConfigCache_CacheKey(
+          codeOwnerConfigKey, Optional.ofNullable(revision));
+    }
+  }
+
+  public Counters getCounters() {
+    return counters;
+  }
+
+  public static class Counters {
+    private final CodeOwnerMetrics codeOwnerMetrics;
+
+    private int cacheReadCount;
+    private int backendReadCount;
+
+    private Counters(CodeOwnerMetrics codeOwnerMetrics) {
+      this.codeOwnerMetrics = codeOwnerMetrics;
+    }
+
+    private void incrementCacheReads() {
+      codeOwnerMetrics.countCodeOwnerConfigCacheReads.increment();
+      cacheReadCount++;
+    }
+
+    private void incrementBackendReads() {
+      // we do not increase the countCodeOwnerConfigReads metric here, since this is already done in
+      // CodeOwners
+      backendReadCount++;
+    }
+
+    public int getBackendReadCount() {
+      return backendReadCount;
+    }
+
+    public int getCacheReadCount() {
+      return cacheReadCount;
+    }
+  }
+}
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..bea1e08
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/UnresolvedImport.java
@@ -0,0 +1,57 @@
+// 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;
+
+/** 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();
+
+  @Override
+  public final 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/UnresolvedImportFormatter.java b/java/com/google/gerrit/plugins/codeowners/backend/UnresolvedImportFormatter.java
new file mode 100644
index 0000000..52958d9
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/UnresolvedImportFormatter.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import com.google.gerrit.plugins.codeowners.backend.config.BackendConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.inject.Inject;
+import java.nio.file.Path;
+
+/** Class to format an {@link UnresolvedImport} as a user-readable string. */
+public class UnresolvedImportFormatter {
+  private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
+  private final ProjectCache projectCache;
+  private final BackendConfig backendConfig;
+
+  @Inject
+  UnresolvedImportFormatter(
+      CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
+      ProjectCache projectCache,
+      BackendConfig backendConfig) {
+    this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
+    this.projectCache = projectCache;
+    this.backendConfig = backendConfig;
+  }
+
+  /** Returns a user-readable string representation of the given unresolved import. */
+  public String format(UnresolvedImport unresolvedImport) {
+    return String.format(
+        "The import of %s:%s:%s in %s:%s:%s cannot be resolved: %s",
+        unresolvedImport.keyOfImportedCodeOwnerConfig().project(),
+        unresolvedImport.keyOfImportedCodeOwnerConfig().shortBranchName(),
+        getFilePath(unresolvedImport.keyOfImportedCodeOwnerConfig()),
+        unresolvedImport.keyOfImportingCodeOwnerConfig().project(),
+        unresolvedImport.keyOfImportingCodeOwnerConfig().shortBranchName(),
+        getFilePath(unresolvedImport.keyOfImportingCodeOwnerConfig()),
+        unresolvedImport.message());
+  }
+
+  private Path getFilePath(CodeOwnerConfig.Key codeOwnerConfigKey) {
+    return getBackend(codeOwnerConfigKey).getFilePath(codeOwnerConfigKey);
+  }
+
+  /**
+   * Returns the code owner backend for the given code owner config key.
+   *
+   * <p>If the project of the code owner config key doesn't exist, the default code owner backend is
+   * returned.
+   */
+  private CodeOwnerBackend getBackend(CodeOwnerConfig.Key codeOwnerConfigKey) {
+    if (projectCache.get(codeOwnerConfigKey.project()).isPresent()) {
+      return codeOwnersPluginConfiguration
+          .getProjectConfig(codeOwnerConfigKey.project())
+          .getBackend(codeOwnerConfigKey.branchNameKey().branch());
+    }
+    return backendConfig.getDefaultBackend();
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/config/AbstractRequiredApprovalConfig.java b/java/com/google/gerrit/plugins/codeowners/backend/config/AbstractRequiredApprovalConfig.java
similarity index 67%
rename from java/com/google/gerrit/plugins/codeowners/config/AbstractRequiredApprovalConfig.java
rename to java/com/google/gerrit/plugins/codeowners/backend/config/AbstractRequiredApprovalConfig.java
index 260d9d9..59cce10 100644
--- a/java/com/google/gerrit/plugins/codeowners/config/AbstractRequiredApprovalConfig.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/AbstractRequiredApprovalConfig.java
@@ -12,9 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.config;
+package com.google.gerrit.plugins.codeowners.backend.config;
 
-import static com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
+import static com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
@@ -22,7 +22,6 @@
 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 org.eclipse.jgit.lib.Config;
 
@@ -54,6 +53,12 @@
    * Reads the required approvals for the specified project from the given plugin config with
    * fallback to {@code gerrit.config}.
    *
+   * <p>Inherited required approvals are included into the returned list at the first position (see
+   * {@link Config#getStringList(String, String, String)}).
+   *
+   * <p>The returned list contains duplicates if the exact same require approval is set for
+   * different projects in the line of parent projects.
+   *
    * @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
@@ -63,47 +68,33 @@
     requireNonNull(pluginConfig, "pluginConfig");
 
     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()));
-        }
+    for (String requiredApproval :
+        pluginConfigFactory.getFromGerritConfig(pluginName).getStringList(getConfigKey())) {
+      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();
     }
-
-    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()));
-        }
+    for (String requiredApproval :
+        pluginConfig.getStringList(SECTION_CODE_OWNERS, /* subsection= */ null, getConfigKey())) {
+      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();
     }
-
-    return ImmutableList.of();
+    return requiredApprovalList.build();
   }
 
   /**
@@ -115,15 +106,14 @@
    *     validation errors
    */
   ImmutableList<CommitValidationMessage> validateProjectLevelConfig(
-      ProjectState projectState, String fileName, ProjectLevelConfig.Bare projectLevelConfig) {
+      ProjectState projectState, String fileName, Config projectLevelConfig) {
     requireNonNull(projectState, "projectState");
     requireNonNull(fileName, "fileName");
     requireNonNull(projectLevelConfig, "projectLevelConfig");
 
     String[] requiredApprovals =
-        projectLevelConfig
-            .getConfig()
-            .getStringList(SECTION_CODE_OWNERS, /* subsection= */ null, getConfigKey());
+        projectLevelConfig.getStringList(
+            SECTION_CODE_OWNERS, /* subsection= */ null, getConfigKey());
     ImmutableList.Builder<CommitValidationMessage> validationMessages = ImmutableList.builder();
     for (String requiredApproval : requiredApprovals) {
       try {
diff --git a/java/com/google/gerrit/plugins/codeowners/config/BackendConfig.java b/java/com/google/gerrit/plugins/codeowners/backend/config/BackendConfig.java
similarity index 93%
rename from java/com/google/gerrit/plugins/codeowners/config/BackendConfig.java
rename to java/com/google/gerrit/plugins/codeowners/backend/config/BackendConfig.java
index a820739..cbeb83d 100644
--- a/java/com/google/gerrit/plugins/codeowners/config/BackendConfig.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/BackendConfig.java
@@ -12,9 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.config;
+package com.google.gerrit.plugins.codeowners.backend.config;
 
-import static com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
+import static com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -29,7 +29,6 @@
 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.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
@@ -88,14 +87,13 @@
    *     validation errors
    */
   ImmutableList<CommitValidationMessage> validateProjectLevelConfig(
-      String fileName, ProjectLevelConfig.Bare projectLevelConfig) {
+      String fileName, Config projectLevelConfig) {
     requireNonNull(fileName, "fileName");
     requireNonNull(projectLevelConfig, "projectLevelConfig");
 
     List<CommitValidationMessage> validationMessages = new ArrayList<>();
 
-    String backendName =
-        projectLevelConfig.getConfig().getString(SECTION_CODE_OWNERS, null, KEY_BACKEND);
+    String backendName = projectLevelConfig.getString(SECTION_CODE_OWNERS, null, KEY_BACKEND);
     if (backendName != null) {
       if (!lookupBackend(backendName).isPresent()) {
         validationMessages.add(
@@ -107,9 +105,8 @@
       }
     }
 
-    for (String subsection : projectLevelConfig.getConfig().getSubsections(SECTION_CODE_OWNERS)) {
-      backendName =
-          projectLevelConfig.getConfig().getString(SECTION_CODE_OWNERS, subsection, KEY_BACKEND);
+    for (String subsection : projectLevelConfig.getSubsections(SECTION_CODE_OWNERS)) {
+      backendName = projectLevelConfig.getString(SECTION_CODE_OWNERS, subsection, KEY_BACKEND);
       if (backendName != null) {
         if (!lookupBackend(backendName).isPresent()) {
           validationMessages.add(
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfig.java b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfig.java
new file mode 100644
index 0000000..7bd0971
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfig.java
@@ -0,0 +1,179 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend.config;
+
+import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.annotations.PluginName;
+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.assistedinject.Assisted;
+import java.util.Arrays;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Class to read the {@code code-owners.config} file in the {@code refs/meta/config} branch of a
+ * project with taking inherited config parameters from parent projects into account.
+ *
+ * <p>For inheriting config parameters from parent projects we rely on base config support in JGit's
+ * {@link Config} class.
+ *
+ * <p>For single-value parameters (string, boolean, enum, int, long) this means:
+ *
+ * <ul>
+ *   <li>If a parameter is not set, it is read from the parent project.
+ *   <li>If a parameter is set, it overrides any value that is set in the parent project.
+ * </ul>
+ *
+ * <p>For multi-value parameters (string list) this means:
+ *
+ * <ul>
+ *   <li>If a parameter is not set, the values are read from the parent projects.
+ *   <li>If any value for the parameter is set, it is added to the inherited value list (the
+ *       inherited value list is extended).
+ *   <li>If the exact same value is set for different projects in the line of parent projects this
+ *       value appears multiple times in the value list (list may contain duplicates).
+ *   <li>The inherited value list cannot be overridden (this means the inherited values cannot be
+ *       unset/overridden).
+ * </ul>
+ *
+ * <p>Please note that this inheritance behavior is different from what {@link
+ * com.google.gerrit.server.config.PluginConfigFactory} does. {@code PluginConfigFactory} has 2
+ * modes:
+ *
+ * <ul>
+ *   <li>merge = false: Inherited list values are overridden.
+ *   <li>merge = true: Inherited list values are extended the same way as in this class, but for
+ *       single-value parameters the inherited value from the parent project takes precedence.
+ * </ul>
+ *
+ * <p>For the {@code code-owners.config} we want that:
+ *
+ * <ul>
+ *   <li>Single-value parameters override inherited settings so that they can be controlled per
+ *       project (e.g. whether validation of OWNERS files should be done).
+ *   <li>Multi-value parameters cannot be overridden, but only extended (e.g. this allows to enforce
+ *       global code owners or exempted users globally).
+ * </ul>
+ */
+public class CodeOwnersPluginConfig {
+  public interface Factory {
+    CodeOwnersPluginConfig create(Project.NameKey projectName);
+  }
+
+  private static final String CONFIG_EXTENSION = ".config";
+
+  private final String pluginName;
+  private final ProjectCache projectCache;
+  private final Project.NameKey projectName;
+  private Config config;
+
+  @Inject
+  CodeOwnersPluginConfig(
+      @PluginName String pluginName,
+      ProjectCache projectCache,
+      @Assisted Project.NameKey projectName) {
+    this.pluginName = pluginName;
+    this.projectCache = projectCache;
+    this.projectName = projectName;
+  }
+
+  public Config get() {
+    if (config == null) {
+      config = load();
+    }
+    return config;
+  }
+
+  /**
+   * Load the {@code code-owners.config} file of the project and sets all parent {@code
+   * code-owners.config}s as base configs.
+   *
+   * @throws IllegalStateException if the project doesn't exist
+   */
+  private Config load() {
+    try {
+      ProjectState projectState =
+          projectCache.get(projectName).orElseThrow(noSuchProject(projectName));
+      String fileName = pluginName + CONFIG_EXTENSION;
+
+      Config mergedConfig = null;
+
+      // Iterate in-order from All-Projects through the project hierarchy to this project. For each
+      // project read the code-owners.config and set the parent code-owners.config as base config.
+      for (ProjectState p : projectState.treeInOrder()) {
+        Config currentConfig = p.getConfig(fileName).get();
+        if (mergedConfig == null) {
+          mergedConfig = currentConfig;
+        } else {
+          mergedConfig = createConfigWithBase(currentConfig, mergedConfig);
+        }
+      }
+      return mergedConfig;
+    } catch (NoSuchProjectException e) {
+      throw new IllegalStateException(
+          String.format(
+              "cannot get %s plugin config for non-existing project %s", pluginName, projectName),
+          e);
+    }
+  }
+
+  /**
+   * Creates a copy of the given {@code config} with the given {@code baseConfig} as base config.
+   *
+   * <p>JGit doesn't allow to set a base config on an existing {@link Config}. Hence create a new
+   * (empty) config with the base config and then copy over all sections and subsection.
+   *
+   * @param config config that should be copied
+   * @param baseConfig config that should be set as base config
+   */
+  private Config createConfigWithBase(Config config, Config baseConfig) {
+    // Create a new Config with the parent Config as base config.
+    Config configWithBase = new Config(baseConfig);
+
+    // Copy all sections and subsections from the given config.
+    for (String section : config.getSections()) {
+      for (String name : config.getNames(section)) {
+        configWithBase.setStringList(
+            section,
+            /* subsection = */ null,
+            name,
+            Arrays.asList(config.getStringList(section, /* subsection = */ null, name)));
+      }
+
+      for (String subsection : config.getSubsections(section)) {
+        Set<String> allNames = config.getNames(section, subsection);
+        if (allNames.isEmpty()) {
+          // Set empty subsection.
+          configWithBase.setString(section, subsection, /* name= */ null, /* value= */ null);
+        } else {
+          for (String name : allNames) {
+            configWithBase.setStringList(
+                section,
+                subsection,
+                name,
+                Arrays.asList(config.getStringList(section, subsection, name)));
+          }
+        }
+      }
+    }
+
+    return configWithBase;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigValidator.java b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigValidator.java
similarity index 70%
rename from java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigValidator.java
rename to java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigValidator.java
index 8f20fc1..c6181b5 100644
--- a/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigValidator.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigValidator.java
@@ -12,22 +12,20 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.config;
+package com.google.gerrit.plugins.codeowners.backend.config;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.annotations.PluginName;
-import com.google.gerrit.plugins.codeowners.JgitPath;
 import com.google.gerrit.plugins.codeowners.backend.ChangedFiles;
+import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.gerrit.server.events.CommitReceivedEvent;
-import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 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.ProjectConfig;
 import com.google.gerrit.server.project.ProjectLevelConfig;
 import com.google.gerrit.server.project.ProjectState;
@@ -37,17 +35,15 @@
 import java.util.ArrayList;
 import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 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
-class CodeOwnersPluginConfigValidator implements CommitValidationListener {
+public class CodeOwnersPluginConfigValidator implements CommitValidationListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final String pluginName;
-  private final GitRepositoryManager repoManager;
   private final ProjectConfig.Factory projectConfigFactory;
   private final ProjectState.Factory projectStateFactory;
   private final ChangedFiles changedFiles;
@@ -60,7 +56,6 @@
   @Inject
   CodeOwnersPluginConfigValidator(
       @PluginName String pluginName,
-      GitRepositoryManager repoManager,
       ProjectConfig.Factory projectConfigFactory,
       ProjectState.Factory projectStateFactory,
       ChangedFiles changedFiles,
@@ -70,7 +65,6 @@
       RequiredApprovalConfig requiredApprovalConfig,
       OverrideApprovalConfig overrideApprovalConfig) {
     this.pluginName = pluginName;
-    this.repoManager = repoManager;
     this.projectConfigFactory = projectConfigFactory;
     this.projectStateFactory = projectStateFactory;
     this.changedFiles = changedFiles;
@@ -85,70 +79,79 @@
   public ImmutableList<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
       throws CommitValidationException {
     String fileName = pluginName + ".config";
-    Project.NameKey project = receiveEvent.project.getNameKey();
 
     try {
       if (!receiveEvent.refName.equals(RefNames.REFS_CONFIG)
-          || !isFileChanged(project, receiveEvent.commit, fileName)) {
+          || !isFileChanged(receiveEvent, fileName)) {
         // the code-owners.config file in refs/meta/config was not modified, hence we do not need to
         // validate it
         return ImmutableList.of();
       }
 
-      ProjectState projectState = getProjectState(project, receiveEvent.commit);
-      ProjectLevelConfig.Bare cfg = loadConfig(project, fileName, receiveEvent.commit);
-      validateConfig(projectState, fileName, cfg);
+      ProjectState projectState = getProjectState(receiveEvent);
+      ProjectLevelConfig.Bare cfg = loadConfig(receiveEvent, fileName);
+      ImmutableList<CommitValidationMessage> validationMessages =
+          validateConfig(projectState, fileName, cfg.getConfig());
+      if (!validationMessages.isEmpty()) {
+        throw new CommitValidationException(
+            exceptionMessage(fileName, cfg.getRevision()), validationMessages);
+      }
       return ImmutableList.of();
-    } catch (IOException | ConfigInvalidException | PatchListNotAvailableException e) {
+    } catch (IOException | ConfigInvalidException 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);
+              fileName,
+              receiveEvent.commit.getName(),
+              RefNames.REFS_CONFIG,
+              receiveEvent.project.getNameKey());
+      logger.atSevere().withCause(e).log(errorMessage);
       throw new CommitValidationException(errorMessage, e);
     }
   }
 
-  private ProjectState getProjectState(Project.NameKey projectName, RevCommit commit)
+  private ProjectState getProjectState(CommitReceivedEvent receiveEvent)
       throws IOException, ConfigInvalidException {
-    try (Repository repo = repoManager.openRepository(projectName)) {
-      ProjectConfig projectConfig = projectConfigFactory.create(projectName);
-      projectConfig.load(repo, commit);
-      return projectStateFactory.create(projectConfig.getCacheable());
-    }
+    ProjectConfig projectConfig = projectConfigFactory.create(receiveEvent.project.getNameKey());
+    projectConfig.load(receiveEvent.revWalk, receiveEvent.commit);
+    return projectStateFactory.create(projectConfig.getCacheable());
   }
 
   /**
    * Whether the given file was changed in the given revision.
    *
-   * @param project the name of the project
-   * @param revision the revision
+   * @param receiveEvent the receive event
    * @param fileName the name of the file
    */
-  private boolean isFileChanged(Project.NameKey project, ObjectId revision, String fileName)
-      throws IOException, PatchListNotAvailableException {
-    return changedFiles.compute(project, revision).stream()
+  private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
+      throws IOException {
+    return changedFiles
+        .compute(
+            receiveEvent.project.getNameKey(),
+            receiveEvent.repoConfig,
+            receiveEvent.revWalk,
+            receiveEvent.commit,
+            MergeCommitStrategy.ALL_CHANGED_FILES)
+        .stream()
         .anyMatch(changedFile -> changedFile.hasNewPath(JgitPath.of(fileName).getAsAbsolutePath()));
   }
 
   /**
    * Loads the configuration from the file and revision.
    *
-   * @param project the project name
+   * @param receiveEvent the receive event
    * @param fileName the name of the config file
-   * @param revision the revision from which the configuration should be loaded
    * @return the loaded configuration
    * @throws CommitValidationException thrown if the configuration is invalid and cannot be parsed
    */
-  private ProjectLevelConfig.Bare loadConfig(
-      Project.NameKey project, String fileName, ObjectId revision)
+  private ProjectLevelConfig.Bare loadConfig(CommitReceivedEvent receiveEvent, String fileName)
       throws CommitValidationException, IOException {
     ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare(fileName);
-    try (Repository git = repoManager.openRepository(project)) {
-      cfg.load(project, git, revision);
+    try {
+      cfg.load(receiveEvent.project.getNameKey(), receiveEvent.revWalk, receiveEvent.commit);
     } catch (ConfigInvalidException e) {
       throw new CommitValidationException(
-          exceptionMessage(fileName, revision),
+          exceptionMessage(fileName, receiveEvent.commit),
           new CommitValidationMessage(e.getMessage(), ValidationMessage.Type.ERROR));
     }
     return cfg;
@@ -160,11 +163,10 @@
    * @param projectState the project state
    * @param fileName the name of the config file
    * @param cfg the project-level code-owners configuration that should be validated
-   * @throws CommitValidationException throw if there are any validation errors
+   * @return list of messages with validation issues, empty list if there are no issues
    */
-  private void validateConfig(
-      ProjectState projectState, String fileName, ProjectLevelConfig.Bare cfg)
-      throws CommitValidationException {
+  public ImmutableList<CommitValidationMessage> validateConfig(
+      ProjectState projectState, String fileName, Config cfg) {
     List<CommitValidationMessage> validationMessages = new ArrayList<>();
     validationMessages.addAll(backendConfig.validateProjectLevelConfig(fileName, cfg));
     validationMessages.addAll(generalConfig.validateProjectLevelConfig(fileName, cfg));
@@ -173,10 +175,7 @@
         requiredApprovalConfig.validateProjectLevelConfig(projectState, fileName, cfg));
     validationMessages.addAll(
         overrideApprovalConfig.validateProjectLevelConfig(projectState, fileName, cfg));
-    if (!validationMessages.isEmpty()) {
-      throw new CommitValidationException(
-          exceptionMessage(fileName, cfg.getRevision()), validationMessages);
-    }
+    return ImmutableList.copyOf(validationMessages);
   }
 
   /**
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfiguration.java b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfiguration.java
new file mode 100644
index 0000000..a216eb1
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfiguration.java
@@ -0,0 +1,76 @@
+// 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.config;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.cache.PerThreadCache;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/**
+ * The configuration of the code-owners plugin.
+ *
+ * <p>The global configuration of the code-owners plugin is stored in the {@code gerrit.config} file
+ * in the {@code plugin.code-owners} subsection.
+ *
+ * <p>In addition there is configuration on project level that is stored in {@code
+ * code-owners.config} files that are stored in the {@code refs/meta/config} branches of the
+ * projects.
+ *
+ * <p>Parameters that are not set for a project are inherited from the parent project.
+ */
+@Singleton
+public class CodeOwnersPluginConfiguration {
+  public static final String SECTION_CODE_OWNERS = "codeOwners";
+
+  private static final String GLOBAL_CONFIG_IDENTIFIER = "GLOBAL_CONFIG";
+
+  private final CodeOwnersPluginGlobalConfigSnapshot.Factory
+      codeOwnersPluginGlobalConfigSnapshotFactory;
+  private final CodeOwnersPluginProjectConfigSnapshot.Factory
+      codeOwnersPluginProjectConfigSnapshotFactory;
+
+  @Inject
+  CodeOwnersPluginConfiguration(
+      CodeOwnersPluginGlobalConfigSnapshot.Factory codeOwnersPluginGlobalConfigSnapshotFactory,
+      CodeOwnersPluginProjectConfigSnapshot.Factory codeOwnersPluginProjectConfigSnapshotFactory) {
+    this.codeOwnersPluginGlobalConfigSnapshotFactory = codeOwnersPluginGlobalConfigSnapshotFactory;
+    this.codeOwnersPluginProjectConfigSnapshotFactory =
+        codeOwnersPluginProjectConfigSnapshotFactory;
+  }
+
+  /** Returns the global code-owner plugin configuration. */
+  public CodeOwnersPluginGlobalConfigSnapshot getGlobalConfig() {
+    return PerThreadCache.getOrCompute(
+        PerThreadCache.Key.create(
+            CodeOwnersPluginGlobalConfigSnapshot.class, GLOBAL_CONFIG_IDENTIFIER),
+        () -> codeOwnersPluginGlobalConfigSnapshotFactory.create());
+  }
+
+  /**
+   * Returns the code-owner plugin configuration for the given project.
+   *
+   * <p>Callers must ensure that the project of the specified branch exists. If the project doesn't
+   * exist the call fails with {@link IllegalStateException}.
+   */
+  public CodeOwnersPluginProjectConfigSnapshot getProjectConfig(Project.NameKey projectName) {
+    requireNonNull(projectName, "projectName");
+    return PerThreadCache.getOrCompute(
+        PerThreadCache.Key.create(CodeOwnersPluginProjectConfigSnapshot.class, projectName),
+        () -> codeOwnersPluginProjectConfigSnapshotFactory.create(projectName));
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshot.java b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshot.java
new file mode 100644
index 0000000..c466b8a
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshot.java
@@ -0,0 +1,144 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend.config;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.inject.Inject;
+import java.util.Optional;
+
+/** Snapshot of the global code-owners plugin configuration. */
+public class CodeOwnersPluginGlobalConfigSnapshot {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  @VisibleForTesting
+  static final String KEY_ENABLE_EXPERIMENTAL_REST_ENDPOINTS = "enableExperimentalRestEndpoints";
+
+  @VisibleForTesting static final int DEFAULT_MAX_CODE_OWNER_CONFIG_CACHE_SIZE = 10000;
+
+  private static final String KEY_MAX_CODE_OWNER_CONFIG_CACHE_SIZE = "maxCodeOwnerConfigCacheSize";
+
+  public interface Factory {
+    CodeOwnersPluginGlobalConfigSnapshot create();
+  }
+
+  private final String pluginName;
+  private final PluginConfigFactory pluginConfigFactory;
+  private final GeneralConfig generalConfig;
+
+  @Nullable private ImmutableSet<String> allowedEmailDomains;
+  @Nullable private Boolean enabledExperimentalRestEndpoints;
+  @Nullable private Optional<Integer> maxCodeOwnerConfigCacheSize;
+
+  @Inject
+  CodeOwnersPluginGlobalConfigSnapshot(
+      @PluginName String pluginName,
+      PluginConfigFactory pluginConfigFactory,
+      GeneralConfig generalConfig) {
+    this.pluginName = pluginName;
+    this.pluginConfigFactory = pluginConfigFactory;
+    this.generalConfig = generalConfig;
+  }
+
+  /**
+   * Returns the email domains that are allowed to be used for code owners.
+   *
+   * @return the email domains that are allowed to be used for code owners, an empty set if all
+   *     email domains are allowed (if {@code plugin.code-owners.allowedEmailDomain} is not set or
+   *     set to an empty value)
+   */
+  public ImmutableSet<String> getAllowedEmailDomains() {
+    if (allowedEmailDomains == null) {
+      allowedEmailDomains = generalConfig.getAllowedEmailDomains();
+    }
+    return allowedEmailDomains;
+  }
+
+  /**
+   * Checks whether experimental REST endpoints are enabled.
+   *
+   * @throws MethodNotAllowedException thrown if experimental REST endpoints are disabled
+   */
+  public void checkExperimentalRestEndpointsEnabled() throws MethodNotAllowedException {
+    if (!areExperimentalRestEndpointsEnabled()) {
+      throw new MethodNotAllowedException("experimental code owners REST endpoints are disabled");
+    }
+  }
+
+  /** Whether experimental REST endpoints are enabled. */
+  public boolean areExperimentalRestEndpointsEnabled() {
+    if (enabledExperimentalRestEndpoints == null) {
+      enabledExperimentalRestEndpoints = readEnabledExperimentalRestEndpoints();
+    }
+    return enabledExperimentalRestEndpoints;
+  }
+
+  private boolean readEnabledExperimentalRestEndpoints() {
+    try {
+      return pluginConfigFactory
+          .getFromGerritConfig(pluginName)
+          .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.",
+          pluginConfigFactory
+              .getFromGerritConfig(pluginName)
+              .getString(KEY_ENABLE_EXPERIMENTAL_REST_ENDPOINTS),
+          pluginName,
+          KEY_ENABLE_EXPERIMENTAL_REST_ENDPOINTS);
+      return false;
+    }
+  }
+
+  /**
+   * Gets the maximum size for the {@link
+   * com.google.gerrit.plugins.codeowners.backend.TransientCodeOwnerConfigCache}.
+   *
+   * @return the maximum cache size, {@link Optional#empty()} if the cache size is not limited
+   */
+  public Optional<Integer> getMaxCodeOwnerConfigCacheSize() {
+    if (maxCodeOwnerConfigCacheSize == null) {
+      maxCodeOwnerConfigCacheSize = readMaxCodeOwnerConfigCacheSize();
+    }
+    return maxCodeOwnerConfigCacheSize;
+  }
+
+  private Optional<Integer> readMaxCodeOwnerConfigCacheSize() {
+    try {
+      int maxCodeOwnerConfigCacheSize =
+          pluginConfigFactory
+              .getFromGerritConfig(pluginName)
+              .getInt(
+                  KEY_MAX_CODE_OWNER_CONFIG_CACHE_SIZE, DEFAULT_MAX_CODE_OWNER_CONFIG_CACHE_SIZE);
+      return maxCodeOwnerConfigCacheSize > 0
+          ? Optional.of(maxCodeOwnerConfigCacheSize)
+          : Optional.empty();
+    } catch (IllegalArgumentException e) {
+      logger.atWarning().withCause(e).log(
+          "Value '%s' in gerrit.config (parameter plugin.%s.%s) is invalid.",
+          pluginConfigFactory
+              .getFromGerritConfig(pluginName)
+              .getString(KEY_MAX_CODE_OWNER_CONFIG_CACHE_SIZE),
+          pluginName,
+          KEY_MAX_CODE_OWNER_CONFIG_CACHE_SIZE);
+      return Optional.empty();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshot.java b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshot.java
new file mode 100644
index 0000000..ea7c88b
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshot.java
@@ -0,0 +1,620 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend.config;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.common.collect.Iterables;
+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.LabelType;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnersInternalServerErrorException;
+import com.google.gerrit.plugins.codeowners.backend.EnableImplicitApprovals;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerConfigValidationPolicy;
+import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
+import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Config;
+
+/** Snapshot of the project-specific code-owners plugin configuration. */
+public class CodeOwnersPluginProjectConfigSnapshot {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    CodeOwnersPluginProjectConfigSnapshot create(Project.NameKey projectName);
+  }
+
+  private final ProjectCache projectCache;
+  private final Emails emails;
+  private final BackendConfig backendConfig;
+  private final GeneralConfig generalConfig;
+  private final OverrideApprovalConfig overrideApprovalConfig;
+  private final RequiredApprovalConfig requiredApprovalConfig;
+  private final StatusConfig statusConfig;
+  private final Project.NameKey projectName;
+  private final Config pluginConfig;
+
+  @Nullable private Optional<String> fileExtension;
+  @Nullable private Boolean codeOwnerConfigsReadOnly;
+  @Nullable private Boolean exemptPureReverts;
+  @Nullable private Boolean rejectNonResolvableCodeOwners;
+  @Nullable private Boolean rejectNonResolvableImports;
+
+  @Nullable
+  private CodeOwnerConfigValidationPolicy codeOwnerConfigValidationPolicyForCommitReceived;
+
+  @Nullable private CodeOwnerConfigValidationPolicy codeOwnerConfigValidationPolicyForSubmit;
+  @Nullable private MergeCommitStrategy mergeCommitStrategy;
+  @Nullable private FallbackCodeOwners fallbackCodeOwners;
+  @Nullable private Integer maxPathsInChangeMessages;
+  @Nullable private ImmutableSet<CodeOwnerReference> globalCodeOwners;
+  @Nullable private ImmutableSet<Account.Id> exemptedAccounts;
+  @Nullable private Optional<String> overrideInfoUrl;
+  @Nullable private Optional<String> invalidCodeOwnerConfigInfoUrl;
+  private Map<String, Boolean> disabledByBranch = new HashMap<>();
+  @Nullable private Boolean isDisabled;
+  private Map<String, CodeOwnerBackend> backendByBranch = new HashMap<>();
+  @Nullable private CodeOwnerBackend backend;
+  @Nullable private Boolean implicitApprovalsEnabled;
+  @Nullable private RequiredApproval requiredApproval;
+  @Nullable private ImmutableSortedSet<RequiredApproval> overrideApprovals;
+
+  @Inject
+  CodeOwnersPluginProjectConfigSnapshot(
+      CodeOwnersPluginConfig.Factory codeOwnersPluginConfigFactory,
+      ProjectCache projectCache,
+      Emails emails,
+      BackendConfig backendConfig,
+      GeneralConfig generalConfig,
+      OverrideApprovalConfig overrideApprovalConfig,
+      RequiredApprovalConfig requiredApprovalConfig,
+      StatusConfig statusConfig,
+      @Assisted Project.NameKey projectName) {
+    this.projectCache = projectCache;
+    this.emails = emails;
+    this.backendConfig = backendConfig;
+    this.generalConfig = generalConfig;
+    this.overrideApprovalConfig = overrideApprovalConfig;
+    this.requiredApprovalConfig = requiredApprovalConfig;
+    this.statusConfig = statusConfig;
+    this.projectName = projectName;
+    this.pluginConfig = codeOwnersPluginConfigFactory.create(projectName).get();
+  }
+
+  /** Gets the file extension of code owner config files, if any configured. */
+  public Optional<String> getFileExtension() {
+    if (fileExtension == null) {
+      fileExtension = generalConfig.getFileExtension(pluginConfig);
+    }
+    return fileExtension;
+  }
+
+  /** Whether code owner configs are read-only. */
+  public boolean areCodeOwnerConfigsReadOnly() {
+    if (codeOwnerConfigsReadOnly == null) {
+      codeOwnerConfigsReadOnly = generalConfig.getReadOnly(projectName, pluginConfig);
+    }
+    return codeOwnerConfigsReadOnly;
+  }
+
+  /** Whether pure revert changes are exempted from needing code owner approvals for submit. */
+  public boolean arePureRevertsExempted() {
+    if (exemptPureReverts == null) {
+      exemptPureReverts = generalConfig.getExemptPureReverts(projectName, pluginConfig);
+    }
+    return exemptPureReverts;
+  }
+
+  /**
+   * Whether newly added non-resolvable code owners should be rejected on commit received and
+   * submit.
+   *
+   * @param branchName the branch for which it should be checked whether non-resolvable code owners
+   *     should be rejected
+   */
+  public boolean rejectNonResolvableCodeOwners(String branchName) {
+    if (rejectNonResolvableCodeOwners == null) {
+      rejectNonResolvableCodeOwners = readRejectNonResolvableCodeOwners(branchName);
+    }
+    return rejectNonResolvableCodeOwners;
+  }
+
+  private boolean readRejectNonResolvableCodeOwners(String branchName) {
+    requireNonNull(branchName, "branchName");
+
+    Optional<Boolean> branchSpecificFlag =
+        generalConfig.getRejectNonResolvableCodeOwnersForBranch(
+            BranchNameKey.create(projectName, branchName), pluginConfig);
+    if (branchSpecificFlag.isPresent()) {
+      return branchSpecificFlag.get();
+    }
+
+    return generalConfig.getRejectNonResolvableCodeOwners(projectName, pluginConfig);
+  }
+
+  /**
+   * Whether newly added non-resolvable imports should be rejected on commit received and submit.
+   *
+   * @param branchName the branch for which it should be checked whether non-resolvable imports
+   *     should be rejected
+   */
+  public boolean rejectNonResolvableImports(String branchName) {
+    if (rejectNonResolvableImports == null) {
+      rejectNonResolvableImports = readRejectNonResolvableImports(branchName);
+    }
+    return rejectNonResolvableImports;
+  }
+
+  private boolean readRejectNonResolvableImports(String branchName) {
+    requireNonNull(branchName, "branchName");
+
+    Optional<Boolean> branchSpecificFlag =
+        generalConfig.getRejectNonResolvableImportsForBranch(
+            BranchNameKey.create(projectName, branchName), pluginConfig);
+    if (branchSpecificFlag.isPresent()) {
+      return branchSpecificFlag.get();
+    }
+
+    return generalConfig.getRejectNonResolvableImports(projectName, pluginConfig);
+  }
+
+  /**
+   * Whether code owner configs should be validated when a commit is received.
+   *
+   * @param branchName the branch for which it should be checked whether code owner configs should
+   *     be validated on commit received
+   */
+  public CodeOwnerConfigValidationPolicy getCodeOwnerConfigValidationPolicyForCommitReceived(
+      String branchName) {
+    if (codeOwnerConfigValidationPolicyForCommitReceived == null) {
+      codeOwnerConfigValidationPolicyForCommitReceived =
+          readCodeOwnerConfigValidationPolicyForCommitReceived(branchName);
+    }
+    return codeOwnerConfigValidationPolicyForCommitReceived;
+  }
+
+  private CodeOwnerConfigValidationPolicy readCodeOwnerConfigValidationPolicyForCommitReceived(
+      String branchName) {
+    requireNonNull(branchName, "branchName");
+
+    Optional<CodeOwnerConfigValidationPolicy> branchSpecificPolicy =
+        generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+            BranchNameKey.create(projectName, branchName), pluginConfig);
+    if (branchSpecificPolicy.isPresent()) {
+      return branchSpecificPolicy.get();
+    }
+
+    return generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceived(
+        projectName, pluginConfig);
+  }
+
+  /**
+   * Whether code owner configs should be validated when a change is submitted.
+   *
+   * @param branchName the branch for which it should be checked whether code owner configs should
+   *     be validated on submit
+   */
+  public CodeOwnerConfigValidationPolicy getCodeOwnerConfigValidationPolicyForSubmit(
+      String branchName) {
+    if (codeOwnerConfigValidationPolicyForSubmit == null) {
+      codeOwnerConfigValidationPolicyForSubmit =
+          readCodeOwnerConfigValidationPolicyForSubmit(branchName);
+    }
+    return codeOwnerConfigValidationPolicyForSubmit;
+  }
+
+  private CodeOwnerConfigValidationPolicy readCodeOwnerConfigValidationPolicyForSubmit(
+      String branchName) {
+    requireNonNull(branchName, "branchName");
+
+    Optional<CodeOwnerConfigValidationPolicy> branchSpecificPolicy =
+        generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+            BranchNameKey.create(projectName, branchName), pluginConfig);
+    if (branchSpecificPolicy.isPresent()) {
+      return branchSpecificPolicy.get();
+    }
+
+    return generalConfig.getCodeOwnerConfigValidationPolicyForSubmit(projectName, pluginConfig);
+  }
+
+  /** Gets the merge commit strategy. */
+  public MergeCommitStrategy getMergeCommitStrategy() {
+    if (mergeCommitStrategy == null) {
+      mergeCommitStrategy = generalConfig.getMergeCommitStrategy(projectName, pluginConfig);
+    }
+    return mergeCommitStrategy;
+  }
+
+  /** Gets the fallback code owners. */
+  public FallbackCodeOwners getFallbackCodeOwners() {
+    if (fallbackCodeOwners == null) {
+      fallbackCodeOwners = generalConfig.getFallbackCodeOwners(projectName, pluginConfig);
+    }
+    return fallbackCodeOwners;
+  }
+
+  /** Gets the max paths in change messages. */
+  public int getMaxPathsInChangeMessages() {
+    if (maxPathsInChangeMessages == null) {
+      maxPathsInChangeMessages =
+          generalConfig.getMaxPathsInChangeMessages(projectName, pluginConfig);
+    }
+    return maxPathsInChangeMessages;
+  }
+
+  /** Gets the global code owners. */
+  public ImmutableSet<CodeOwnerReference> getGlobalCodeOwners() {
+    if (globalCodeOwners == null) {
+      globalCodeOwners = generalConfig.getGlobalCodeOwners(pluginConfig);
+    }
+    return globalCodeOwners;
+  }
+
+  /** Gets the accounts that are exempted from requiring code owner approvals. */
+  public ImmutableSet<Account.Id> getExemptedAccounts() {
+    if (exemptedAccounts == null) {
+      exemptedAccounts = lookupExemptedAccounts();
+    }
+    return exemptedAccounts;
+  }
+
+  private ImmutableSet<Account.Id> lookupExemptedAccounts() {
+    ImmutableSet<String> exemptedUsers = generalConfig.getExemptedUsers(pluginConfig);
+
+    try {
+      ImmutableSetMultimap<String, Account.Id> exemptedAccounts =
+          emails.getAccountsFor(exemptedUsers.toArray(new String[0]));
+
+      exemptedUsers.stream()
+          .filter(exemptedUser -> !exemptedAccounts.containsKey(exemptedUser))
+          .forEach(
+              exemptedUser ->
+                  logger.atWarning().log(
+                      "Ignoring exempted user %s for project %s: not found",
+                      exemptedUser, projectName));
+
+      return ImmutableSet.copyOf(exemptedAccounts.values());
+    } catch (IOException e) {
+      throw new CodeOwnersInternalServerErrorException(
+          String.format(
+              "Failed to resolve exempted users %s on project %s", exemptedUsers, projectName),
+          e);
+    }
+  }
+
+  /** Gets the override info URL that is configured. */
+  public Optional<String> getOverrideInfoUrl() {
+    if (overrideInfoUrl == null) {
+      overrideInfoUrl = generalConfig.getOverrideInfoUrl(pluginConfig);
+    }
+    return overrideInfoUrl;
+  }
+
+  /** Gets the invalid code owner config info URL that is configured. */
+  public Optional<String> getInvalidCodeOwnerConfigInfoUrl() {
+    if (invalidCodeOwnerConfigInfoUrl == null) {
+      invalidCodeOwnerConfigInfoUrl = generalConfig.getInvalidCodeOwnerConfigInfoUrl(pluginConfig);
+    }
+    return invalidCodeOwnerConfigInfoUrl;
+  }
+
+  /**
+   * Whether the code owners functionality is disabled for the given branch.
+   *
+   * <p>The configuration is evaluated in the following order:
+   *
+   * <ul>
+   *   <li>disabled configuration for the branch (with inheritance)
+   *   <li>disabled configuration for the project (with inheritance)
+   *   <li>hard-coded default (not disabled)
+   * </ul>
+   *
+   * <p>The first disabled configuration that exists counts and the evaluation is stopped.
+   *
+   * @param branchName the branch for which it should be checked whether the code owners
+   *     functionality is disabled
+   * @return {@code true} if the code owners functionality is disabled for the given branch,
+   *     otherwise {@code false}
+   */
+  public boolean isDisabled(String branchName) {
+    requireNonNull(branchName, "branchName");
+
+    BranchNameKey branchNameKey = BranchNameKey.create(projectName, branchName);
+    return disabledByBranch.computeIfAbsent(
+        branchNameKey.branch(),
+        b -> {
+          boolean isDisabled = statusConfig.isDisabledForBranch(pluginConfig, branchNameKey);
+          if (isDisabled) {
+            return true;
+          }
+          return isDisabled();
+        });
+  }
+
+  /**
+   * Whether the code owners functionality is disabled for the given project.
+   *
+   * <p>The configuration is evaluated in the following order:
+   *
+   * <ul>
+   *   <li>disabled configuration for the project (with inheritance)
+   *   <li>hard-coded default (not disabled)
+   * </ul>
+   *
+   * <p>The first disabled configuration that exists counts and the evaluation is stopped.
+   *
+   * @return {@code true} if the code owners functionality is disabled, otherwise {@code false}
+   */
+  public boolean isDisabled() {
+    if (isDisabled == null) {
+      isDisabled = statusConfig.isDisabledForProject(pluginConfig, projectName);
+    }
+    return isDisabled;
+  }
+
+  /**
+   * Returns the configured {@link CodeOwnerBackend} for the given branch.
+   *
+   * <p>The code owner backend configuration is evaluated in the following order:
+   *
+   * <ul>
+   *   <li>backend configuration for branch (with inheritance, first by full branch name, then by
+   *       short branch name)
+   *   <li>backend configuration for project (with inheritance)
+   *   <li>default backend (first globally configured backend, then hard-coded default backend)
+   * </ul>
+   *
+   * <p>The first code owner backend configuration that exists counts and the evaluation is stopped.
+   *
+   * @param branchName the branch for which the configured code owner backend should be returned
+   * @return the {@link CodeOwnerBackend} that should be used for the branch
+   */
+  public CodeOwnerBackend getBackend(String branchName) {
+    requireNonNull(branchName, "branchName");
+
+    BranchNameKey branchNameKey = BranchNameKey.create(projectName, branchName);
+    return backendByBranch.computeIfAbsent(
+        branchNameKey.branch(),
+        b -> {
+          Optional<CodeOwnerBackend> codeOwnerBackend =
+              backendConfig.getBackendForBranch(
+                  pluginConfig, BranchNameKey.create(projectName, branchName));
+          if (codeOwnerBackend.isPresent()) {
+            return codeOwnerBackend.get();
+          }
+          return getBackend();
+        });
+  }
+
+  /**
+   * Returns the configured {@link CodeOwnerBackend}.
+   *
+   * <p>The code owner backend configuration is evaluated in the following order:
+   *
+   * <ul>
+   *   <li>backend configuration for project (with inheritance)
+   *   <li>default backend (first globally configured backend, then hard-coded default backend)
+   * </ul>
+   *
+   * <p>The first code owner backend configuration that exists counts and the evaluation is stopped.
+   *
+   * @return the {@link CodeOwnerBackend} that should be used
+   */
+  public CodeOwnerBackend getBackend() {
+    if (backend == null) {
+      backend = readBackend();
+    }
+    return backend;
+  }
+
+  private CodeOwnerBackend readBackend() {
+    // check if a project specific backend is configured
+    Optional<CodeOwnerBackend> codeOwnerBackend =
+        backendConfig.getBackendForProject(pluginConfig, projectName);
+    if (codeOwnerBackend.isPresent()) {
+      return codeOwnerBackend.get();
+    }
+
+    // fall back to the default backend
+    return backendConfig.getDefaultBackend();
+  }
+
+  /**
+   * Checks whether implicit code owner approvals are enabled.
+   *
+   * <p>If enabled, an implict code owner approval from the change owner is assumed if the last
+   * patch set was uploaded by the change owner.
+   */
+  public boolean areImplicitApprovalsEnabled() {
+    if (implicitApprovalsEnabled == null) {
+      implicitApprovalsEnabled = readImplicitApprovalsEnabled();
+    }
+    return implicitApprovalsEnabled;
+  }
+
+  private boolean readImplicitApprovalsEnabled() {
+    EnableImplicitApprovals enableImplicitApprovals =
+        generalConfig.getEnableImplicitApprovals(projectName, pluginConfig);
+    switch (enableImplicitApprovals) {
+      case FALSE:
+        logger.atFine().log("implicit approvals on project %s are disabled", projectName);
+        return false;
+      case TRUE:
+        LabelType requiredLabel = getRequiredApproval().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",
+              projectName, requiredLabel);
+          return false;
+        }
+        return true;
+      case FORCED:
+        logger.atFine().log("implicit approvals on project %s are enforced", projectName);
+        return true;
+    }
+    throw new IllegalStateException(
+        String.format(
+            "unknown value %s for enableImplicitApprovals configuration in project %s",
+            enableImplicitApprovals, projectName));
+  }
+
+  /**
+   * Returns the approval that is required from code owners to approve the files in a change.
+   *
+   * <p>Defines which approval counts as code owner approval.
+   *
+   * <p>The code owner required approval configuration is evaluated in the following order:
+   *
+   * <ul>
+   *   <li>required approval configuration for project (with inheritance)
+   *   <li>globally configured required approval
+   *   <li>hard-coded default required approval
+   * </ul>
+   *
+   * <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.
+   *
+   * @return the required code owner approval that should be used
+   */
+  public RequiredApproval getRequiredApproval() {
+    if (requiredApproval == null) {
+      requiredApproval = readRequiredApproval();
+    }
+    return requiredApproval;
+  }
+
+  private RequiredApproval readRequiredApproval() {
+    ImmutableList<RequiredApproval> configuredRequiredApprovalConfig =
+        getConfiguredRequiredApproval(requiredApprovalConfig);
+    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
+    ProjectState projectState =
+        projectCache.get(projectName).orElseThrow(illegalState(projectName));
+    return requiredApprovalConfig.createDefault(projectState);
+  }
+
+  /**
+   * Returns the approvals that are required to override the code owners submit check for a change.
+   *
+   * <p>If multiple approvals are returned, any of them is sufficient to override the code owners
+   * submit check.
+   *
+   * <p>The override approval configuration is read from:
+   *
+   * <ul>
+   *   <li>the override approval configuration for project (with inheritance)
+   *   <li>the globally configured override approval
+   * </ul>
+   *
+   * <p>Override approvals that are configured on project-level extend the inherited override
+   * approval configuration.
+   *
+   * <p>The returned override approvals are sorted alphabetically by their string representation
+   * (e.g. {@code Owners-Override+1}).
+   *
+   * @return the override approvals that should be used, an empty set if no override approval is
+   *     configured, in this case the override functionality is disabled
+   */
+  public ImmutableSortedSet<RequiredApproval> getOverrideApprovals() {
+    if (overrideApprovals == null) {
+      overrideApprovals = readOverrideApprovals();
+    }
+    return overrideApprovals;
+  }
+
+  private ImmutableSortedSet<RequiredApproval> readOverrideApprovals() {
+    try {
+      return ImmutableSortedSet.copyOf(
+          filterOutDuplicateRequiredApprovals(
+              getConfiguredRequiredApproval(overrideApprovalConfig)));
+    } catch (InvalidPluginConfigurationException e) {
+      logger.atWarning().withCause(e).log(
+          "Ignoring invalid override approval configuration for project %s."
+              + " Overrides are disabled.",
+          projectName.get());
+    }
+
+    return ImmutableSortedSet.of();
+  }
+
+  /**
+   * Filters out duplicate required approvals from the input list.
+   *
+   * <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 Collection<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 requiredApprovalsByLabel.values();
+  }
+
+  /**
+   * Gets the required approvals that are configured.
+   *
+   * @param requiredApprovalConfig the config from which the required approvals should be read
+   * @return the required approvals that are configured, an empty list if no required approvals are
+   *     configured
+   */
+  private ImmutableList<RequiredApproval> getConfiguredRequiredApproval(
+      AbstractRequiredApprovalConfig requiredApprovalConfig) {
+    ProjectState projectState =
+        projectCache.get(projectName).orElseThrow(illegalState(projectName));
+    return requiredApprovalConfig.get(projectState, pluginConfig);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersProjectConfigFile.java b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersProjectConfigFile.java
new file mode 100644
index 0000000..1f51e93
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersProjectConfigFile.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend.config;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.git.meta.VersionedMetaData;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Reads/writes the code-owners project configuration from/to the {@code code-owners.config} file in
+ * the {@code refs/meta/config} branch.
+ */
+public class CodeOwnersProjectConfigFile extends VersionedMetaData {
+  public static final String FILE_NAME = "code-owners.config";
+
+  private boolean isLoaded = false;
+  private Config config;
+
+  @Override
+  protected String getRefName() {
+    return RefNames.REFS_CONFIG;
+  }
+
+  /**
+   * Returns the loaded code owners config.
+   *
+   * <p>Fails if loading was not done yet.
+   */
+  public Config getConfig() {
+    checkLoaded();
+    return config;
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    if (revision != null) {
+      config = readConfig(FILE_NAME);
+    } else {
+      config = new Config();
+    }
+    isLoaded = true;
+  }
+
+  @Override
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
+    checkLoaded();
+    saveConfig(FILE_NAME, config);
+    return true;
+  }
+
+  private void checkLoaded() {
+    checkState(isLoaded, "%s not loaded yet", FILE_NAME);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/config/ConfigModule.java b/java/com/google/gerrit/plugins/codeowners/backend/config/ConfigModule.java
similarity index 94%
rename from java/com/google/gerrit/plugins/codeowners/config/ConfigModule.java
rename to java/com/google/gerrit/plugins/codeowners/backend/config/ConfigModule.java
index ec439ad..5d8522b 100644
--- a/java/com/google/gerrit/plugins/codeowners/config/ConfigModule.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/ConfigModule.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.config;
+package com.google.gerrit.plugins.codeowners.backend.config;
 
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java b/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java
new file mode 100644
index 0000000..7ab6706
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java
@@ -0,0 +1,878 @@
+// 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.config;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
+import com.google.gerrit.plugins.codeowners.backend.EnableImplicitApprovals;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerConfigValidationPolicy;
+import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
+import com.google.gerrit.server.config.PluginConfig;
+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.RefPatternMatcher;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.regex.PatternSyntaxException;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Class to read the general code owners configuration from {@code gerrit.config} and from {@code
+ * code-owners.config} in {@code refs/meta/config}.
+ *
+ * <p>Default values are configured in {@code gerrit.config}.
+ *
+ * <p>The default values can be overridden on project-level in {@code code-owners.config} in {@code
+ * refs/meta/config}.
+ *
+ * <p>Projects that have no configuration inherit the configuration from their parent projects.
+ */
+@Singleton
+public class GeneralConfig {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public static final String SECTION_VALIDATION = "validation";
+
+  public static final String KEY_FILE_EXTENSION = "fileExtension";
+  public static final String KEY_READ_ONLY = "readOnly";
+  public static final String KEY_EXEMPT_PURE_REVERTS = "exemptPureReverts";
+  public static final String KEY_FALLBACK_CODE_OWNERS = "fallbackCodeOwners";
+  public static final String KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED =
+      "enableValidationOnCommitReceived";
+  public static final String KEY_ENABLE_VALIDATION_ON_SUBMIT = "enableValidationOnSubmit";
+  public static final String KEY_MAX_PATHS_IN_CHANGE_MESSAGES = "maxPathsInChangeMessages";
+  public static final String KEY_MERGE_COMMIT_STRATEGY = "mergeCommitStrategy";
+  public static final String KEY_GLOBAL_CODE_OWNER = "globalCodeOwner";
+  public static final String KEY_EXEMPTED_USER = "exemptedUser";
+  public static final String KEY_ENABLE_IMPLICIT_APPROVALS = "enableImplicitApprovals";
+  public static final String KEY_OVERRIDE_INFO_URL = "overrideInfoUrl";
+  public static final String KEY_INVALID_CODE_OWNER_CONFIG_INFO_URL =
+      "invalidCodeOwnerConfigInfoUrl";
+  public static final String KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS =
+      "rejectNonResolvableCodeOwners";
+  public static final String KEY_REJECT_NON_RESOLVABLE_IMPORTS = "rejectNonResolvableImports";
+
+  public static final int DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES = 50;
+
+  private static final String KEY_ALLOWED_EMAIL_DOMAIN = "allowedEmailDomain";
+
+  private final String pluginName;
+  private final PluginConfig pluginConfigFromGerritConfig;
+
+  @Inject
+  GeneralConfig(@PluginName String pluginName, PluginConfigFactory pluginConfigFactory) {
+    this.pluginName = pluginName;
+    this.pluginConfigFromGerritConfig = pluginConfigFactory.getFromGerritConfig(pluginName);
+  }
+
+  /**
+   * Validates the backend configuration in the given project level configuration.
+   *
+   * @param fileName the name of the config file
+   * @param projectLevelConfig the project level plugin configuration
+   * @return list of validation messages for validation errors, empty list if there are no
+   *     validation errors
+   */
+  ImmutableList<CommitValidationMessage> validateProjectLevelConfig(
+      String fileName, Config projectLevelConfig) {
+    requireNonNull(fileName, "fileName");
+    requireNonNull(projectLevelConfig, "projectLevelConfig");
+
+    List<CommitValidationMessage> validationMessages = new ArrayList<>();
+
+    try {
+      projectLevelConfig.getEnum(
+          SECTION_CODE_OWNERS,
+          /* subsection= */ null,
+          KEY_MERGE_COMMIT_STRATEGY,
+          MergeCommitStrategy.ALL_CHANGED_FILES);
+    } catch (IllegalArgumentException e) {
+      validationMessages.add(
+          new CommitValidationMessage(
+              String.format(
+                  "Merge commit strategy '%s' that is configured in %s (parameter %s.%s) is invalid.",
+                  projectLevelConfig.getString(
+                      SECTION_CODE_OWNERS, /* subsection= */ null, KEY_MERGE_COMMIT_STRATEGY),
+                  fileName,
+                  SECTION_CODE_OWNERS,
+                  KEY_MERGE_COMMIT_STRATEGY),
+              ValidationMessage.Type.ERROR));
+    }
+
+    try {
+      projectLevelConfig.getEnum(
+          SECTION_CODE_OWNERS,
+          /* subsection= */ 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.getString(
+                      SECTION_CODE_OWNERS, /* subsection= */ null, KEY_FALLBACK_CODE_OWNERS),
+                  fileName,
+                  SECTION_CODE_OWNERS,
+                  KEY_FALLBACK_CODE_OWNERS),
+              ValidationMessage.Type.ERROR));
+    }
+
+    try {
+      projectLevelConfig.getInt(
+          SECTION_CODE_OWNERS,
+          /* subsection= */ 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.getString(
+                      SECTION_CODE_OWNERS,
+                      /* subsection= */ null,
+                      KEY_MAX_PATHS_IN_CHANGE_MESSAGES),
+                  fileName,
+                  SECTION_CODE_OWNERS,
+                  KEY_MAX_PATHS_IN_CHANGE_MESSAGES),
+              ValidationMessage.Type.ERROR));
+    }
+
+    return ImmutableList.copyOf(validationMessages);
+  }
+
+  /**
+   * Gets the file extension that should be used for code owner config files in the given project.
+   *
+   * @param pluginConfig the plugin config from which the file extension should be read.
+   * @return the file extension that should be used for code owner config files in the given
+   *     project, {@link Optional#empty()} if no file extension should be used
+   */
+  Optional<String> getFileExtension(Config pluginConfig) {
+    return getStringValue(pluginConfig, KEY_FILE_EXTENSION);
+  }
+
+  /**
+   * Returns the email domains that are allowed to be used for code owners.
+   *
+   * @return the email domains that are allowed to be used for code owners, an empty set if all
+   *     email domains are allowed (if {@code plugin.code-owners.allowedEmailDomain} is not set or
+   *     set to an empty value)
+   */
+  ImmutableSet<String> getAllowedEmailDomains() {
+    return Arrays.stream(pluginConfigFromGerritConfig.getStringList(KEY_ALLOWED_EMAIL_DOMAIN))
+        .filter(emailDomain -> !Strings.isNullOrEmpty(emailDomain))
+        .distinct()
+        .collect(toImmutableSet());
+  }
+
+  /**
+   * Gets the read-only configuration from the given plugin config with fallback to {@code
+   * gerrit.config}.
+   *
+   * <p>The read-only configuration controls whether code owner config files are read-only and all
+   * modifications of code owner config files should be rejected.
+   *
+   * @param project the project for which the read-only configuration should be read
+   * @param pluginConfig the plugin config from which the read-only configuration should be read.
+   * @return whether code owner config files are read-only
+   */
+  boolean getReadOnly(Project.NameKey project, Config pluginConfig) {
+    return getBooleanConfig(project, pluginConfig, KEY_READ_ONLY, /* defaultValue= */ false);
+  }
+
+  /**
+   * Gets the exempt-pure-reverts configuration from the given plugin config with fallback to {@code
+   * gerrit.config}.
+   *
+   * <p>The exempt-pure-reverts configuration controls whether pure revert changes are exempted from
+   * needing code owner approvals for submit.
+   *
+   * @param project the project for which the exempt-pure-revert configuration should be read
+   * @param pluginConfig the plugin config from which the read-only configuration should be read.
+   * @return whether pure reverts are exempted from needing code owner approvals for submit
+   */
+  boolean getExemptPureReverts(Project.NameKey project, Config pluginConfig) {
+    return getBooleanConfig(
+        project, pluginConfig, KEY_EXEMPT_PURE_REVERTS, /* defaultValue= */ false);
+  }
+
+  /**
+   * Gets the reject-non-resolvable-code-owners configuration from the given plugin config for the
+   * specified project with fallback to {@code gerrit.config}.
+   *
+   * <p>The reject-non-resolvable-code-owners configuration controls whether code owner config files
+   * with newly added non-resolvable code owners should be rejected on commit received and on
+   * submit.
+   *
+   * @param project the project for which the reject-non-resolvable-code-owners configuration should
+   *     be read
+   * @param pluginConfig the plugin config from which the reject-non-resolvable-code-owners
+   *     configuration should be read.
+   * @return whether code owner config files with newly added non-resolvable code owners should be
+   *     rejected on commit received and on submit
+   */
+  boolean getRejectNonResolvableCodeOwners(Project.NameKey project, Config pluginConfig) {
+    return getBooleanConfig(
+        project, pluginConfig, KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS, /* defaultValue= */ true);
+  }
+
+  /**
+   * Gets the reject-non-resolvable-code-owners configuration from the given plugin config for the
+   * specified branch with fallback to {@code gerrit.config}.
+   *
+   * <p>If multiple branch-specific configurations match the specified branch, it is undefined which
+   * of the matching branch configurations takes precedence.
+   *
+   * <p>The reject-non-resolvable-code-owners configuration controls whether code owner config files
+   * with newly added non-resolvable code owners should be rejected on commit received and on
+   * submit.
+   *
+   * @param branchNameKey the branch and project for which the reject-non-resolvable-code-owners
+   *     configuration should be read
+   * @param pluginConfig the plugin config from which the reject-non-resolvable-code-owners
+   *     configuration should be read.
+   * @return whether code owner config files with newly added non-resolvable code owners should be
+   *     rejected on commit received and on submit
+   */
+  Optional<Boolean> getRejectNonResolvableCodeOwnersForBranch(
+      BranchNameKey branchNameKey, Config pluginConfig) {
+    return getCodeOwnerConfigValidationFlagForBranch(
+        KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS, branchNameKey, pluginConfig);
+  }
+
+  /**
+   * Gets the reject-non-resolvable-imports configuration from the given plugin config for the
+   * specified project with fallback to {@code gerrit.config}.
+   *
+   * <p>The reject-non-resolvable-imports configuration controls whether code owner config files
+   * with newly added non-resolvable imports should be rejected on commit received and on submit.
+   *
+   * @param project the project for which the reject-non-resolvable-imports configuration should be
+   *     read
+   * @param pluginConfig the plugin config from which the reject-non-resolvable-imports
+   *     configuration should be read.
+   * @return whether code owner config files with newly added non-resolvable imports should be
+   *     rejected on commit received and on submit
+   */
+  boolean getRejectNonResolvableImports(Project.NameKey project, Config pluginConfig) {
+    return getBooleanConfig(
+        project, pluginConfig, KEY_REJECT_NON_RESOLVABLE_IMPORTS, /* defaultValue= */ true);
+  }
+
+  /**
+   * Gets the reject-non-resolvable-imports configuration from the given plugin config for the
+   * specified branch with fallback to {@code gerrit.config}.
+   *
+   * <p>The reject-non-resolvable-imports configuration controls whether code owner config files
+   * with newly added non-resolvable imports should be rejected on commit received and on submit.
+   *
+   * @param branchNameKey the branch and project for which the reject-non-resolvable-imports
+   *     configuration should be read
+   * @param pluginConfig the plugin config from which the reject-non-resolvable-imports
+   *     configuration should be read.
+   * @return whether code owner config files with newly added non-resolvable imports should be
+   *     rejected on commit received and on submit
+   */
+  Optional<Boolean> getRejectNonResolvableImportsForBranch(
+      BranchNameKey branchNameKey, Config pluginConfig) {
+    return getCodeOwnerConfigValidationFlagForBranch(
+        KEY_REJECT_NON_RESOLVABLE_IMPORTS, branchNameKey, pluginConfig);
+  }
+
+  private boolean getBooleanConfig(
+      Project.NameKey project, Config pluginConfig, String key, boolean defaultValue) {
+    requireNonNull(project, "project");
+    requireNonNull(pluginConfig, "pluginConfig");
+    requireNonNull(key, "key");
+
+    String value = pluginConfig.getString(SECTION_CODE_OWNERS, /* subsection= */ null, key);
+    if (value != null) {
+      try {
+        return pluginConfig.getBoolean(
+            SECTION_CODE_OWNERS, /* subsection= */ null, key, defaultValue);
+      } catch (IllegalArgumentException e) {
+        logger.atWarning().withCause(e).log(
+            "Ignoring invalid value %s for %s in '%s.config' of project %s."
+                + " Falling back to global config.",
+            value, key, pluginName, project.get());
+      }
+    }
+
+    try {
+      return pluginConfigFromGerritConfig.getBoolean(key, defaultValue);
+    } catch (IllegalArgumentException e) {
+      logger.atWarning().withCause(e).log(
+          "Ignoring invalid value %s for %s in gerrit.config (parameter"
+              + " plugin.%s.%s). Falling back to default value %s.",
+          pluginConfigFromGerritConfig.getString(key), key, pluginName, key, defaultValue);
+      return defaultValue;
+    }
+  }
+
+  /**
+   * 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, /* subsection= */ null, KEY_FALLBACK_CODE_OWNERS);
+    if (fallbackCodeOwnersString != null) {
+      try {
+        return pluginConfig.getEnum(
+            SECTION_CODE_OWNERS,
+            /* subsection= */ null,
+            KEY_FALLBACK_CODE_OWNERS,
+            FallbackCodeOwners.NONE);
+      } catch (IllegalArgumentException e) {
+        logger.atWarning().withCause(e).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().withCause(e).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, /* subsection= */ null, KEY_MAX_PATHS_IN_CHANGE_MESSAGES);
+    if (maxPathInChangeMessagesString != null) {
+      try {
+        return pluginConfig.getInt(
+            SECTION_CODE_OWNERS,
+            /* subsection= */ null,
+            KEY_MAX_PATHS_IN_CHANGE_MESSAGES,
+            DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
+      } catch (IllegalArgumentException e) {
+        logger.atWarning().withCause(e).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().withCause(e).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 for
+   * the specified project 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 project the project for which the enable validation on commit received 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,
+        CodeOwnerConfigValidationPolicy.TRUE);
+  }
+
+  /**
+   * Gets the enable validation on commit received configuration from the given plugin config for
+   * the specified branch.
+   *
+   * <p>If multiple branch-specific configurations match the specified branch, it is undefined which
+   * of the matching branch configurations takes precedence.
+   *
+   * <p>The enable validation on commit received controls whether code owner config files should be
+   * validated when a commit is received.
+   *
+   * @param branchNameKey the branch and project for which the enable validation on commit received
+   *     configuration should be read
+   * @param pluginConfig the plugin config from which the enable validation on commit received
+   *     configuration should be read
+   * @return the enable validation on commit received configuration that is configured for the
+   *     branch, {@link Optional#empty()} if no branch specific configuration exists
+   */
+  Optional<CodeOwnerConfigValidationPolicy>
+      getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+          BranchNameKey branchNameKey, Config pluginConfig) {
+    return getCodeOwnerConfigValidationPolicyForBranch(
+        KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED, branchNameKey, pluginConfig);
+  }
+
+  /**
+   * Gets the enable validation on submit configuration from the given plugin config for the
+   * specified project 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 project the project for which the enable validation on submit configuration should be
+   *     read
+   * @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,
+        CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  /**
+   * Gets the enable validation on submit configuration from the given plugin config for the
+   * specified branch.
+   *
+   * <p>If multiple branch-specific configurations match the specified branch, it is undefined which
+   * of the matching branch configurations takes precedence.
+   *
+   * <p>The enable validation on submit controls whether code owner config files should be validated
+   * when a change is submitted.
+   *
+   * @param branchNameKey the branch and project for which the enable validation on submit
+   *     configuration should be read
+   * @param pluginConfig the plugin config from which the enable validation on submit configuration
+   *     should be read
+   * @return the enable validation on submit configuration that is configured for the branch, {@link
+   *     Optional#empty()} if no branch specific configuration exists
+   */
+  Optional<CodeOwnerConfigValidationPolicy> getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+      BranchNameKey branchNameKey, Config pluginConfig) {
+    return getCodeOwnerConfigValidationPolicyForBranch(
+        KEY_ENABLE_VALIDATION_ON_SUBMIT, branchNameKey, pluginConfig);
+  }
+
+  private Optional<CodeOwnerConfigValidationPolicy> getCodeOwnerConfigValidationPolicyForBranch(
+      String key, BranchNameKey branchNameKey, Config pluginConfig) {
+    requireNonNull(key, "key");
+    requireNonNull(branchNameKey, "branchNameKey");
+    requireNonNull(pluginConfig, "pluginConfig");
+
+    Optional<String> validationSectionForBranch =
+        getValidationSectionForBranch(branchNameKey, pluginConfig);
+    if (!validationSectionForBranch.isPresent()) {
+      return Optional.empty();
+    }
+
+    return getCodeOwnerConfigValidationPolicyForBranch(
+        validationSectionForBranch.get(), key, branchNameKey.project(), pluginConfig);
+  }
+
+  private Optional<Boolean> getCodeOwnerConfigValidationFlagForBranch(
+      String key, BranchNameKey branchNameKey, Config pluginConfig) {
+    requireNonNull(key, "key");
+    requireNonNull(branchNameKey, "branchNameKey");
+    requireNonNull(pluginConfig, "pluginConfig");
+
+    Optional<String> validationSectionForBranch =
+        getValidationSectionForBranch(branchNameKey, pluginConfig);
+    if (!validationSectionForBranch.isPresent()) {
+      return Optional.empty();
+    }
+
+    return getCodeOwnerConfigValidationFlagForBranch(
+        validationSectionForBranch.get(), key, branchNameKey.project(), pluginConfig);
+  }
+
+  private CodeOwnerConfigValidationPolicy getCodeOwnerConfigValidationPolicy(
+      String key,
+      Project.NameKey project,
+      Config pluginConfig,
+      CodeOwnerConfigValidationPolicy defaultValue) {
+    requireNonNull(key, "key");
+    requireNonNull(project, "project");
+    requireNonNull(pluginConfig, "pluginConfig");
+
+    String codeOwnerConfigValidationPolicyString =
+        pluginConfig.getString(SECTION_CODE_OWNERS, /* subsection= */ null, key);
+    if (codeOwnerConfigValidationPolicyString != null) {
+      try {
+        return pluginConfig.getEnum(SECTION_CODE_OWNERS, /* subsection= */ null, key, defaultValue);
+      } catch (IllegalArgumentException e) {
+        logger.atWarning().withCause(e).log(
+            "Ignoring invalid value %s for the code owner config validation policy in '%s.config'"
+                + " of project %s (parameter %s.%s). Falling back to global config.",
+            codeOwnerConfigValidationPolicyString,
+            pluginName,
+            project.get(),
+            SECTION_CODE_OWNERS,
+            key);
+      }
+    }
+
+    try {
+      return pluginConfigFromGerritConfig.getEnum(key, defaultValue);
+    } catch (IllegalArgumentException e) {
+      logger.atWarning().withCause(e).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), pluginName, key, defaultValue);
+      return defaultValue;
+    }
+  }
+
+  private Optional<String> getValidationSectionForBranch(
+      BranchNameKey branchNameKey, Config pluginConfig) {
+    ImmutableSet<String> matchingValidationSubsections =
+        pluginConfig.getSubsections(SECTION_VALIDATION).stream()
+            .filter(
+                refPattern -> {
+                  try {
+                    return RefPatternMatcher.getMatcher(refPattern)
+                        .match(branchNameKey.branch(), /* user= */ null);
+                  } catch (PatternSyntaxException e) {
+                    logger.atWarning().withCause(e).log(
+                        "invalid ref pattern %s for subsection %s.%s in %s.config of project %s",
+                        refPattern,
+                        SECTION_VALIDATION,
+                        refPattern,
+                        pluginName,
+                        branchNameKey.project());
+                    return false;
+                  }
+                })
+            .collect(toImmutableSet());
+
+    if (matchingValidationSubsections.isEmpty()) {
+      return Optional.empty();
+    }
+
+    String matchingValidationSubsection = matchingValidationSubsections.asList().get(0);
+    if (matchingValidationSubsections.size() > 1) {
+      logger.atWarning().log(
+          "branch %s matches multiple %s subsections in %s.config of project %s: %s,"
+              + " subsection %s takes precedence",
+          branchNameKey.branch(),
+          SECTION_VALIDATION,
+          pluginName,
+          branchNameKey.project(),
+          matchingValidationSubsections,
+          matchingValidationSubsection);
+    }
+    return Optional.of(matchingValidationSubsection);
+  }
+
+  private Optional<CodeOwnerConfigValidationPolicy> getCodeOwnerConfigValidationPolicyForBranch(
+      String branchSubsection, String key, Project.NameKey project, Config pluginConfig) {
+    requireNonNull(branchSubsection, "branchSubsection");
+    requireNonNull(key, "key");
+    requireNonNull(project, "project");
+    requireNonNull(pluginConfig, "pluginConfig");
+
+    String codeOwnerConfigValidationPolicyString =
+        pluginConfig.getString(SECTION_VALIDATION, branchSubsection, key);
+    if (codeOwnerConfigValidationPolicyString != null) {
+      try {
+        return Optional.of(
+            pluginConfig.getEnum(
+                SECTION_VALIDATION, branchSubsection, key, CodeOwnerConfigValidationPolicy.TRUE));
+      } catch (IllegalArgumentException e) {
+        logger.atWarning().withCause(e).log(
+            "Ignoring invalid value %s for the code owner config validation policy in '%s.config'"
+                + " of project %s (parameter %s.%s.%s). Falling back to project-level setting.",
+            codeOwnerConfigValidationPolicyString,
+            pluginName,
+            project.get(),
+            SECTION_VALIDATION,
+            branchSubsection,
+            key);
+      }
+    }
+    return Optional.empty();
+  }
+
+  private Optional<Boolean> getCodeOwnerConfigValidationFlagForBranch(
+      String branchSubsection, String key, Project.NameKey project, Config pluginConfig) {
+    requireNonNull(branchSubsection, "branchSubsection");
+    requireNonNull(key, "key");
+    requireNonNull(project, "project");
+    requireNonNull(pluginConfig, "pluginConfig");
+
+    String codeOwnerConfigValidationFlagString =
+        pluginConfig.getString(SECTION_VALIDATION, branchSubsection, key);
+    if (codeOwnerConfigValidationFlagString != null) {
+      try {
+        return Optional.of(
+            pluginConfig.getBoolean(SECTION_VALIDATION, branchSubsection, key, true));
+      } catch (IllegalArgumentException e) {
+        logger.atWarning().withCause(e).log(
+            "Ignoring invalid value %s for %s.%s.%s in '%s.config' of project %s."
+                + " Falling back to project-level setting.",
+            codeOwnerConfigValidationFlagString,
+            SECTION_VALIDATION,
+            branchSubsection,
+            key,
+            pluginName,
+            project.get());
+      }
+    }
+    return Optional.empty();
+  }
+
+  /**
+   * Gets the merge commit strategy from the given plugin config with fallback to {@code
+   * gerrit.config}.
+   *
+   * <p>The merge commit strategy defines for merge commits which files require code owner
+   * approvals.
+   *
+   * @param project the name of the project for which the merge commit strategy should be read
+   * @param pluginConfig the plugin config from which the merge commit strategy should be read
+   * @return the merge commit strategy that should be used
+   */
+  MergeCommitStrategy getMergeCommitStrategy(Project.NameKey project, Config pluginConfig) {
+    requireNonNull(project, "project");
+    requireNonNull(pluginConfig, "pluginConfig");
+
+    String mergeCommitStrategyString =
+        pluginConfig.getString(
+            SECTION_CODE_OWNERS, /* subsection= */ null, KEY_MERGE_COMMIT_STRATEGY);
+    if (mergeCommitStrategyString != null) {
+      try {
+        return pluginConfig.getEnum(
+            SECTION_CODE_OWNERS,
+            /* subsection= */ null,
+            KEY_MERGE_COMMIT_STRATEGY,
+            MergeCommitStrategy.ALL_CHANGED_FILES);
+      } catch (IllegalArgumentException e) {
+        logger.atWarning().withCause(e).log(
+            "Ignoring invalid value %s for merge commit stategy in '%s.config' of project %s."
+                + " Falling back to global config or default value.",
+            mergeCommitStrategyString, pluginName, project.get());
+      }
+    }
+
+    try {
+      return pluginConfigFromGerritConfig.getEnum(
+          KEY_MERGE_COMMIT_STRATEGY, MergeCommitStrategy.ALL_CHANGED_FILES);
+    } catch (IllegalArgumentException e) {
+      logger.atWarning().withCause(e).log(
+          "Ignoring invalid value %s for merge commit stategy in gerrit.config (parameter plugin.%s.%s)."
+              + " Falling back to default value %s.",
+          pluginConfigFromGerritConfig.getString(KEY_MERGE_COMMIT_STRATEGY),
+          pluginName,
+          KEY_MERGE_COMMIT_STRATEGY,
+          MergeCommitStrategy.ALL_CHANGED_FILES);
+      return MergeCommitStrategy.ALL_CHANGED_FILES;
+    }
+  }
+
+  /**
+   * Gets whether an implicit code owner approvals are enabled from the given plugin config with
+   * fallback to {@code gerrit.config}.
+   *
+   * <p>If enabled, an implict code owner approval from the change owner is assumed if the last
+   * patch set was uploaded by the change owner.
+   *
+   * @param project the name of the project for which the configuration should be read
+   * @param pluginConfig the plugin config from which the configuration should be read.
+   * @return whether an implicit code owner approval from the last uploader is assumed
+   */
+  EnableImplicitApprovals getEnableImplicitApprovals(Project.NameKey project, Config pluginConfig) {
+    requireNonNull(project, "project");
+    requireNonNull(pluginConfig, "pluginConfig");
+
+    String enableImplicitApprovalsString =
+        pluginConfig.getString(
+            SECTION_CODE_OWNERS, /* subsection= */ null, KEY_ENABLE_IMPLICIT_APPROVALS);
+    if (enableImplicitApprovalsString != null) {
+      try {
+        return pluginConfig.getEnum(
+            SECTION_CODE_OWNERS,
+            /* subsection= */ null,
+            KEY_ENABLE_IMPLICIT_APPROVALS,
+            EnableImplicitApprovals.FALSE);
+      } catch (IllegalArgumentException e) {
+        logger.atWarning().withCause(e).log(
+            "Ignoring invalid value %s for enabling implicit approvals in '%s.config' of project"
+                + " %s. Falling back to global config or default value.",
+            enableImplicitApprovalsString, pluginName, project.get());
+      }
+    }
+
+    try {
+      return pluginConfigFromGerritConfig.getEnum(
+          KEY_ENABLE_IMPLICIT_APPROVALS, EnableImplicitApprovals.FALSE);
+    } catch (IllegalArgumentException e) {
+      logger.atWarning().withCause(e).log(
+          "Ignoring invalid value %s for enabling implict approvals in gerrit.config (parameter"
+              + " plugin.%s.%s). Falling back to default value %s.",
+          pluginConfigFromGerritConfig.getString(KEY_ENABLE_IMPLICIT_APPROVALS),
+          pluginName,
+          KEY_ENABLE_IMPLICIT_APPROVALS,
+          EnableImplicitApprovals.FALSE);
+      return EnableImplicitApprovals.FALSE;
+    }
+  }
+
+  /**
+   * Gets the users which are configured as global code owners from the given plugin config with
+   * fallback to {@code gerrit.config}.
+   *
+   * @param pluginConfig the plugin config from which the global code owners should be read.
+   * @return the users which are configured as global code owners
+   */
+  ImmutableSet<CodeOwnerReference> getGlobalCodeOwners(Config pluginConfig) {
+    requireNonNull(pluginConfig, "pluginConfig");
+    return getMultiValue(pluginConfig, KEY_GLOBAL_CODE_OWNER)
+        .map(CodeOwnerReference::create)
+        .collect(toImmutableSet());
+  }
+
+  /**
+   * Gets the users which are exempted from requiring code owner approvals.
+   *
+   * <p>If a user is exempted from requiring code owner approvals changes that are uploaded by this
+   * user are automatically code-owner approved.
+   *
+   * @param pluginConfig the plugin config from which the exempted users should be read.
+   * @return the users which are exempted from requiring code owner approvals
+   */
+  ImmutableSet<String> getExemptedUsers(Config pluginConfig) {
+    requireNonNull(pluginConfig, "pluginConfig");
+    return getMultiValue(pluginConfig, KEY_EXEMPTED_USER).collect(toImmutableSet());
+  }
+
+  /**
+   * Gets an URL that leads to an information page about overrides.
+   *
+   * <p>The URL is retrieved from the given plugin config, with fallback to the {@code
+   * gerrit.config}.
+   *
+   * @param pluginConfig the plugin config from which the override info URL should be read.
+   * @return URL that leads to an information page about overrides, {@link Optional#empty()} if no
+   *     such URL is configured
+   */
+  Optional<String> getOverrideInfoUrl(Config pluginConfig) {
+    return getStringValue(pluginConfig, KEY_OVERRIDE_INFO_URL);
+  }
+
+  /**
+   * Gets an URL that leads to an information page about invalid code owner config files.
+   *
+   * <p>The URL is retrieved from the given plugin config, with fallback to the {@code
+   * gerrit.config}.
+   *
+   * @param pluginConfig the plugin config from which the invalid code owner config info URL should
+   *     be read.
+   * @return URL that leads to an information page about invalid code owner config files, {@link
+   *     Optional#empty()} if no such URL is configured
+   */
+  Optional<String> getInvalidCodeOwnerConfigInfoUrl(Config pluginConfig) {
+    return getStringValue(pluginConfig, KEY_INVALID_CODE_OWNER_CONFIG_INFO_URL);
+  }
+
+  private Optional<String> getStringValue(Config pluginConfig, String key) {
+    requireNonNull(pluginConfig, "pluginConfig");
+
+    String value = pluginConfig.getString(SECTION_CODE_OWNERS, /* subsection= */ null, key);
+    if (value != null) {
+      return Optional.of(value);
+    }
+
+    return Optional.ofNullable(pluginConfigFromGerritConfig.getString(key));
+  }
+
+  /**
+   * Gets the values for a parameter that can be set multiple times with taking inherited values
+   * from {@code gerrit.config} into account.
+   *
+   * <p>The inherited values from {@code gerrit.config} are included into the returned list at the
+   * first position. This matches the behavior in {@link Config#getStringList(String, String,
+   * String)} that includes inherited values from the base config into the result list at the first
+   * position too.
+   *
+   * <p>The returned stream contains duplicates if the exact same value is set for different
+   * projects in the line of parent projects.
+   */
+  private Stream<String> getMultiValue(Config pluginConfig, String key) {
+    return Streams.concat(
+            Arrays.stream(pluginConfigFromGerritConfig.getStringList(key)),
+            Arrays.stream(
+                pluginConfig.getStringList(SECTION_CODE_OWNERS, /* subsection= */ null, key)))
+        .filter(Objects::nonNull)
+        .filter(value -> !value.trim().isEmpty());
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/config/InvalidPluginConfigurationException.java b/java/com/google/gerrit/plugins/codeowners/backend/config/InvalidPluginConfigurationException.java
similarity index 94%
rename from java/com/google/gerrit/plugins/codeowners/config/InvalidPluginConfigurationException.java
rename to java/com/google/gerrit/plugins/codeowners/backend/config/InvalidPluginConfigurationException.java
index dd78ada..d80b7cf 100644
--- a/java/com/google/gerrit/plugins/codeowners/config/InvalidPluginConfigurationException.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/InvalidPluginConfigurationException.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.config;
+package com.google.gerrit.plugins.codeowners.backend.config;
 
 /**
  * Exception that is thrown if a configuration parameter of the code-owners plugin has an invalid
diff --git a/java/com/google/gerrit/plugins/codeowners/config/OverrideApprovalConfig.java b/java/com/google/gerrit/plugins/codeowners/backend/config/OverrideApprovalConfig.java
similarity index 96%
rename from java/com/google/gerrit/plugins/codeowners/config/OverrideApprovalConfig.java
rename to java/com/google/gerrit/plugins/codeowners/backend/config/OverrideApprovalConfig.java
index 3b4f56c..2a815a8 100644
--- a/java/com/google/gerrit/plugins/codeowners/config/OverrideApprovalConfig.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/OverrideApprovalConfig.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.config;
+package com.google.gerrit.plugins.codeowners.backend.config;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.extensions.annotations.PluginName;
diff --git a/java/com/google/gerrit/plugins/codeowners/config/RequiredApproval.java b/java/com/google/gerrit/plugins/codeowners/backend/config/RequiredApproval.java
similarity index 94%
rename from java/com/google/gerrit/plugins/codeowners/config/RequiredApproval.java
rename to java/com/google/gerrit/plugins/codeowners/backend/config/RequiredApproval.java
index e361ea6..4cbdba0 100644
--- a/java/com/google/gerrit/plugins/codeowners/config/RequiredApproval.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/RequiredApproval.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.config;
+package com.google.gerrit.plugins.codeowners.backend.config;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
@@ -20,6 +20,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ComparisonChain;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSetApproval;
@@ -39,7 +40,7 @@
  * </ul>
  */
 @AutoValue
-public abstract class RequiredApproval {
+public abstract class RequiredApproval implements Comparable<RequiredApproval> {
   /** The label on which an approval is required. */
   public abstract LabelType labelType();
 
@@ -59,6 +60,11 @@
   }
 
   @Override
+  public final int compareTo(RequiredApproval other) {
+    return ComparisonChain.start().compare(toString(), other.toString()).result();
+  }
+
+  @Override
   public final String toString() {
     return labelType().getName() + "+" + value();
   }
diff --git a/java/com/google/gerrit/plugins/codeowners/config/RequiredApprovalConfig.java b/java/com/google/gerrit/plugins/codeowners/backend/config/RequiredApprovalConfig.java
similarity index 94%
rename from java/com/google/gerrit/plugins/codeowners/config/RequiredApprovalConfig.java
rename to java/com/google/gerrit/plugins/codeowners/backend/config/RequiredApprovalConfig.java
index e49904d..3902655 100644
--- a/java/com/google/gerrit/plugins/codeowners/config/RequiredApprovalConfig.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/RequiredApprovalConfig.java
@@ -12,9 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.config;
+package com.google.gerrit.plugins.codeowners.backend.config;
 
-import static com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
+import static com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.extensions.annotations.PluginName;
diff --git a/java/com/google/gerrit/plugins/codeowners/config/StatusConfig.java b/java/com/google/gerrit/plugins/codeowners/backend/config/StatusConfig.java
similarity index 83%
rename from java/com/google/gerrit/plugins/codeowners/config/StatusConfig.java
rename to java/com/google/gerrit/plugins/codeowners/backend/config/StatusConfig.java
index 4648271..c3d4dee 100644
--- a/java/com/google/gerrit/plugins/codeowners/config/StatusConfig.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/StatusConfig.java
@@ -12,12 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.config;
+package com.google.gerrit.plugins.codeowners.backend.config;
 
-import static com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
+import static com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
@@ -27,7 +28,6 @@
 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.RefPatternMatcher;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -77,21 +77,21 @@
    *     validation errors
    */
   ImmutableList<CommitValidationMessage> validateProjectLevelConfig(
-      String fileName, ProjectLevelConfig.Bare projectLevelConfig) {
+      String fileName, Config projectLevelConfig) {
     requireNonNull(fileName, "fileName");
     requireNonNull(projectLevelConfig, "projectLevelConfig");
 
     List<CommitValidationMessage> validationMessages = new ArrayList<>();
 
     try {
-      projectLevelConfig.getConfig().getBoolean(SECTION_CODE_OWNERS, null, KEY_DISABLED, false);
+      projectLevelConfig.getBoolean(SECTION_CODE_OWNERS, null, KEY_DISABLED, false);
     } catch (IllegalArgumentException e) {
       validationMessages.add(
           new CommitValidationMessage(
               String.format(
                   "Disabled value '%s' that is configured in %s.config (parameter %s.%s) is"
                       + " invalid.",
-                  projectLevelConfig.getConfig().getString(SECTION_CODE_OWNERS, null, KEY_DISABLED),
+                  projectLevelConfig.getString(SECTION_CODE_OWNERS, null, KEY_DISABLED),
                   pluginName,
                   SECTION_CODE_OWNERS,
                   KEY_DISABLED),
@@ -99,9 +99,7 @@
     }
 
     for (String refPattern :
-        projectLevelConfig
-            .getConfig()
-            .getStringList(SECTION_CODE_OWNERS, null, KEY_DISABLED_BRANCH)) {
+        projectLevelConfig.getStringList(SECTION_CODE_OWNERS, null, KEY_DISABLED_BRANCH)) {
       try {
         RefPatternMatcher.getMatcher(refPattern).match("refs/heads/master", null);
       } catch (PatternSyntaxException e) {
@@ -182,31 +180,30 @@
     requireNonNull(pluginConfig, "pluginConfig");
     requireNonNull(branch, "branch");
 
-    String disabledBranches =
-        pluginConfig.getString(SECTION_CODE_OWNERS, null, KEY_DISABLED_BRANCH);
-    if (disabledBranches != null) {
-      // a value for KEY_DISABLED_BRANCH is set on project-level
-      return isDisabledForBranch(
-          pluginConfig.getStringList(SECTION_CODE_OWNERS, null, KEY_DISABLED_BRANCH),
-          branch.branch(),
-          "Disabled branch '%s' that is configured for project "
-              + branch.project()
-              + " in "
-              + pluginName
-              + ".config (parameter "
-              + SECTION_CODE_OWNERS
-              + "."
-              + KEY_DISABLED_BRANCH
-              + ") is invalid.");
+    // check if the branch is disabled in gerrit.config
+    boolean isDisabled =
+        isDisabledForBranch(
+            pluginConfigFromGerritConfig.getStringList(KEY_DISABLED_BRANCH),
+            branch.branch(),
+            "Disabled branch '%s' that is configured for in gerrit.config (parameter plugin."
+                + pluginName
+                + "."
+                + KEY_DISABLED_BRANCH
+                + ") is invalid.");
+    if (isDisabled) {
+      return true;
     }
 
-    // there is no project-level configuration for KEY_DISABLED_BRANCH, check if it's set in
-    // gerrit.config
+    // check if the branch is disabled on project level
     return isDisabledForBranch(
-        pluginConfigFromGerritConfig.getStringList(KEY_DISABLED_BRANCH),
+        pluginConfig.getStringList(SECTION_CODE_OWNERS, null, KEY_DISABLED_BRANCH),
         branch.branch(),
-        "Disabled branch '%s' that is configured for in gerrit.config (parameter plugin."
+        "Disabled branch '%s' that is configured for project "
+            + branch.project()
+            + " in "
             + pluginName
+            + ".config (parameter "
+            + SECTION_CODE_OWNERS
             + "."
             + KEY_DISABLED_BRANCH
             + ") is invalid.");
@@ -215,7 +212,7 @@
   private boolean isDisabledForBranch(
       String[] refPatternList, String branch, String warningMsgForInvalidRefPattern) {
     for (String refPattern : refPatternList) {
-      if (refPattern == null) {
+      if (Strings.isNullOrEmpty(refPattern)) {
         continue;
       }
       try {
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 ab08201..0e923d7 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersBackend.java
@@ -17,9 +17,10 @@
 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.CodeOwnerConfigFile;
+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.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -44,6 +45,7 @@
   @Inject
   FindOwnersBackend(
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
+      CodeOwnerConfigFile.Factory codeOwnerConfigFileFactory,
       FindOwnersCodeOwnerConfigParser codeOwnerConfigParser,
       GitRepositoryManager repoManager,
       @GerritPersonIdent PersonIdent serverIdent,
@@ -56,12 +58,13 @@
         metaDataUpdateInternalFactory,
         retryHelper,
         CODE_OWNER_CONFIG_FILE_NAME,
+        codeOwnerConfigFileFactory,
         codeOwnerConfigParser);
   }
 
   @Override
   public Optional<PathExpressionMatcher> getPathExpressionMatcher() {
-    return Optional.of(GlobMatcher.INSTANCE);
+    return Optional.of(FindOwnersGlobMatcher.INSTANCE);
   }
 
   @Override
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 7a4261a..5ddbb9a 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParser.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParser.java
@@ -48,7 +48,7 @@
  * OWNERS} files as they are used by the {@code find-owners} plugin.
  *
  * <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
+ * https://gerrit.googlesource.com/plugins/find-owners/+/HEAD/src/main/resources/Documentation/syntax.md
  *
  * <p>Comment lines are silently ignored.
  *
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/proto/ProtoBackend.java b/java/com/google/gerrit/plugins/codeowners/backend/proto/ProtoBackend.java
index e671c6f..b273ab2 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/proto/ProtoBackend.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/proto/ProtoBackend.java
@@ -17,9 +17,10 @@
 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.CodeOwnerConfigFile;
 import com.google.gerrit.plugins.codeowners.backend.PathExpressionMatcher;
 import com.google.gerrit.plugins.codeowners.backend.SimplePathExpressionMatcher;
-import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -52,6 +53,7 @@
       @GerritPersonIdent PersonIdent serverIdent,
       MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
       RetryHelper retryHelper,
+      CodeOwnerConfigFile.Factory codeOwnerConfigFileFactory,
       ProtoCodeOwnerConfigParser codeOwnerConfigParser) {
     super(
         codeOwnersPluginConfiguration,
@@ -60,6 +62,7 @@
         metaDataUpdateInternalFactory,
         retryHelper,
         CODE_OWNER_CONFIG_FILE_NAME,
+        codeOwnerConfigFileFactory,
         codeOwnerConfigParser);
   }
 
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/proto/ProtoCodeOwnerConfigParser.java b/java/com/google/gerrit/plugins/codeowners/backend/proto/ProtoCodeOwnerConfigParser.java
index 8b15ae9..a42d760 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/proto/ProtoCodeOwnerConfigParser.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/proto/ProtoCodeOwnerConfigParser.java
@@ -133,7 +133,7 @@
       for (CodeOwnerSet codeOwnerSet : codeOwnerConfig.codeOwnerSets()) {
         checkState(
             !codeOwnerSet.ignoreGlobalAndParentCodeOwners(),
-            "ignoreGlobaleAndParentCodeOwners is not supported");
+            "ignoreGlobalAndParentCodeOwners is not supported");
         OwnerSet.Builder ownerSetProtoBuilder = ownersConfigProtoBuilder.addOwnerSetsBuilder();
         ownerSetProtoBuilder.addAllPathExpressions(codeOwnerSet.pathExpressions());
         codeOwnerSet.codeOwners().stream()
diff --git a/java/com/google/gerrit/plugins/codeowners/common/BUILD b/java/com/google/gerrit/plugins/codeowners/common/BUILD
new file mode 100644
index 0000000..d4a7385
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/common/BUILD
@@ -0,0 +1,11 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:plugin.bzl", "PLUGIN_DEPS_NEVERLINK")
+
+java_library(
+    name = "common",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = PLUGIN_DEPS_NEVERLINK + [
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/util",
+    ],
+)
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/ChangedFile.java b/java/com/google/gerrit/plugins/codeowners/common/ChangedFile.java
similarity index 74%
rename from java/com/google/gerrit/plugins/codeowners/backend/ChangedFile.java
rename to java/com/google/gerrit/plugins/codeowners/common/ChangedFile.java
index 82655cf..267e3c7 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/ChangedFile.java
+++ b/java/com/google/gerrit/plugins/codeowners/common/ChangedFile.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.backend;
+package com.google.gerrit.plugins.codeowners.common;
 
 import static com.google.common.base.Preconditions.checkState;
 import static java.util.Objects.requireNonNull;
@@ -22,8 +22,9 @@
 import com.google.common.collect.Maps;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Patch;
-import com.google.gerrit.plugins.codeowners.JgitPath;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import java.nio.file.Path;
 import java.util.Optional;
 import org.eclipse.jgit.diff.DiffEntry;
@@ -161,4 +162,58 @@
   private static Optional<Path> convertPathFromPatchListEntry(@Nullable String path) {
     return Optional.ofNullable(path).map(newName -> JgitPath.of(newName).getAsAbsolutePath());
   }
+
+  /**
+   * Creates a {@link ChangedFile} instance from a {@link FileDiffOutput}.
+   *
+   * @param fileDiffOutput the file diff output
+   */
+  public static ChangedFile create(FileDiffOutput fileDiffOutput) {
+    requireNonNull(fileDiffOutput, "fileDiffOutput");
+
+    return new AutoValue_ChangedFile(
+        convertPathFromFileDiffOutput(fileDiffOutput.newPath()),
+        convertPathFromFileDiffOutput(fileDiffOutput.oldPath()),
+        CHANGE_TYPE.get(fileDiffOutput.changeType()));
+  }
+
+  /** Converts the given string path to an absolute path. */
+  private static Optional<Path> convertPathFromFileDiffOutput(Optional<String> path) {
+    requireNonNull(path, "path");
+    return path.map(p -> JgitPath.of(p).getAsAbsolutePath());
+  }
+
+  public static ChangedFile create(
+      Optional<String> newPath, Optional<String> oldPath, ChangeType changeType) {
+    requireNonNull(changeType, "changeType");
+
+    return new AutoValue_ChangedFile(
+        newPath.map(JgitPath::of).map(JgitPath::getAsAbsolutePath),
+        oldPath.map(JgitPath::of).map(JgitPath::getAsAbsolutePath),
+        changeType);
+  }
+
+  public static ChangedFile addition(Path newPath) {
+    requireNonNull(newPath, "newPath");
+
+    return new AutoValue_ChangedFile(Optional.of(newPath), Optional.empty(), ChangeType.ADD);
+  }
+
+  public static ChangedFile modification(Path path) {
+    requireNonNull(path, "path");
+
+    return new AutoValue_ChangedFile(Optional.of(path), Optional.of(path), ChangeType.MODIFY);
+  }
+
+  public static ChangedFile deletion(Path path) {
+    requireNonNull(path, "path");
+
+    return new AutoValue_ChangedFile(Optional.empty(), Optional.of(path), ChangeType.DELETE);
+  }
+
+  public static ChangedFile rename(Path newPath, Path oldPath) {
+    requireNonNull(newPath, "newPath");
+
+    return new AutoValue_ChangedFile(Optional.of(newPath), Optional.of(oldPath), ChangeType.RENAME);
+  }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/common/CodeOwnerConfigValidationPolicy.java b/java/com/google/gerrit/plugins/codeowners/common/CodeOwnerConfigValidationPolicy.java
new file mode 100644
index 0000000..7b35079
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/common/CodeOwnerConfigValidationPolicy.java
@@ -0,0 +1,70 @@
+// 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.common;
+
+/** Policy that should be used to validate code owner config files. */
+public enum CodeOwnerConfigValidationPolicy {
+  /**
+   * The code owner config file validation is enabled and invalid code owner config files are
+   * rejected.
+   *
+   * <p>If the code owners functionality is disabled, no validation is performed.
+   */
+  TRUE,
+
+  /**
+   * The code owner config file validation is disabled. Invalid code owner config files are not
+   * rejected.
+   */
+  FALSE,
+
+  /**
+   * Code owner config files are validated, but invalid code owner config files are not rejected.
+   *
+   * <p>If the code owners functionality is disabled, no dry-run validation is performed.
+   */
+  DRY_RUN,
+
+  /**
+   * Code owner config files are validated even if the code owners functionality is disabled.
+   *
+   * <p>This option is useful when the code owner config validation should be enabled as preparation
+   * to enabling the code owners functionality.
+   */
+  FORCED,
+
+  /**
+   * Code owner config files are validated even if the code owners functionality is disabled, but
+   * invalid code owner config files are not rejected.
+   *
+   * <p>This option is useful when the code owner config validation should be enabled as preparation
+   * to enabling the code owners functionality.
+   */
+  FORCED_DRY_RUN;
+
+  public boolean isDryRun() {
+    return this == CodeOwnerConfigValidationPolicy.DRY_RUN
+        || this == CodeOwnerConfigValidationPolicy.FORCED_DRY_RUN;
+  }
+
+  public boolean runValidation() {
+    return this != CodeOwnerConfigValidationPolicy.FALSE;
+  }
+
+  public boolean isForced() {
+    return this == CodeOwnerConfigValidationPolicy.FORCED
+        || this == CodeOwnerConfigValidationPolicy.FORCED_DRY_RUN;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerStatus.java b/java/com/google/gerrit/plugins/codeowners/common/CodeOwnerStatus.java
similarity index 95%
rename from java/com/google/gerrit/plugins/codeowners/api/CodeOwnerStatus.java
rename to java/com/google/gerrit/plugins/codeowners/common/CodeOwnerStatus.java
index 1873b23..3af1c44 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerStatus.java
+++ b/java/com/google/gerrit/plugins/codeowners/common/CodeOwnerStatus.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.api;
+package com.google.gerrit.plugins.codeowners.common;
 
 /** Code owner status for a path in a change. */
 public enum CodeOwnerStatus {
diff --git a/java/com/google/gerrit/plugins/codeowners/api/MergeCommitStrategy.java b/java/com/google/gerrit/plugins/codeowners/common/MergeCommitStrategy.java
similarity index 91%
rename from java/com/google/gerrit/plugins/codeowners/api/MergeCommitStrategy.java
rename to java/com/google/gerrit/plugins/codeowners/common/MergeCommitStrategy.java
index 96894a9..ca08938 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/MergeCommitStrategy.java
+++ b/java/com/google/gerrit/plugins/codeowners/common/MergeCommitStrategy.java
@@ -12,12 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.api;
+package com.google.gerrit.plugins.codeowners.common;
 
 /** Strategy that defines for merge commits which files require code owner approvals. */
 public enum MergeCommitStrategy {
   /**
-   * All files which differ between the merge commmit that is being reviewed and its first parent
+   * All files which differ between the merge commit that is being reviewed and its first parent
    * commit (which is the HEAD of the destination branch) require code owner approvals.
    *
    * <p>Using this strategy is the safest option, but requires code owners to also approve files
@@ -30,7 +30,7 @@
   ALL_CHANGED_FILES,
 
   /**
-   * Only files which differ between the merge commmit that is being reviewed and the auto merge
+   * Only files which differ between the merge commit that is being reviewed and the auto merge
    * commit (the result of automatically merging the 2 parent commits, may contain Git conflict
    * markers) require code owner approvals.
    *
diff --git a/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfiguration.java b/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfiguration.java
deleted file mode 100644
index 1a7985d..0000000
--- a/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfiguration.java
+++ /dev/null
@@ -1,512 +0,0 @@
-// 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.config;
-
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
-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;
-import com.google.gerrit.plugins.codeowners.api.CodeOwnerConfigValidationPolicy;
-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;
-
-/**
- * The configuration of the code-owners plugin.
- *
- * <p>The global configuration of the code-owners plugin is stored in the {@code gerrit.config} file
- * in the {@code plugin.code-owners} subsection.
- *
- * <p>In addition there is configuration on project level that is stored in {@code
- * code-owners.config} files that are stored in the {@code refs/meta/config} branches of the
- * projects.
- *
- * <p>Parameters that are not set for a project are inherited from the parent project.
- */
-@Singleton
-public class CodeOwnersPluginConfiguration {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  @VisibleForTesting public static final String SECTION_CODE_OWNERS = "codeOwners";
-
-  @VisibleForTesting
-  static final String KEY_ENABLE_EXPERIMENTAL_REST_ENDPOINTS = "enableExperimentalRestEndpoints";
-
-  private final String pluginName;
-  private final PluginConfigFactory pluginConfigFactory;
-  private final ProjectCache projectCache;
-  private final GeneralConfig generalConfig;
-  private final StatusConfig statusConfig;
-  private final BackendConfig backendConfig;
-  private final RequiredApprovalConfig requiredApprovalConfig;
-  private final OverrideApprovalConfig overrideApprovalConfig;
-
-  @Inject
-  CodeOwnersPluginConfiguration(
-      @PluginName String pluginName,
-      PluginConfigFactory pluginConfigFactory,
-      ProjectCache projectCache,
-      GeneralConfig generalConfig,
-      StatusConfig statusConfig,
-      BackendConfig backendConfig,
-      RequiredApprovalConfig requiredApprovalConfig,
-      OverrideApprovalConfig overrideApprovalConfig) {
-    this.pluginName = pluginName;
-    this.pluginConfigFactory = pluginConfigFactory;
-    this.projectCache = projectCache;
-    this.generalConfig = generalConfig;
-    this.statusConfig = statusConfig;
-    this.backendConfig = backendConfig;
-    this.requiredApprovalConfig = requiredApprovalConfig;
-    this.overrideApprovalConfig = overrideApprovalConfig;
-  }
-
-  /**
-   * Gets the file extension that is configured for the given project.
-   *
-   * @param project the project for which the configured file extension should be returned
-   * @return the file extension that is configured for the given project
-   */
-  public Optional<String> getFileExtension(Project.NameKey project) {
-    requireNonNull(project, "project");
-    return generalConfig.getFileExtension(getPluginConfig(project));
-  }
-
-  /**
-   * Checks whether code owner configs in the given project are read-only.
-   *
-   * @param project the project for it should be checked whether code owner configs are read-only
-   * @return whether code owner configs in the given project are read-only
-   */
-  public boolean areCodeOwnerConfigsReadOnly(Project.NameKey project) {
-    requireNonNull(project, "project");
-    return generalConfig.getReadOnly(getPluginConfig(project));
-  }
-
-  /**
-   * Whether code owner configs should be validated when a commit is received.
-   *
-   * @param project the project for it should be checked whether code owner configs should be
-   *     validated when a commit is received
-   * @return whether code owner configs should be validated when a commit is received
-   */
-  public CodeOwnerConfigValidationPolicy getCodeOwnerConfigValidationPolicyForCommitReceived(
-      Project.NameKey project) {
-    requireNonNull(project, "project");
-    return generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceived(
-        project, getPluginConfig(project));
-  }
-
-  /**
-   * 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
-   * @return the merge commit strategy for the given project
-   */
-  public MergeCommitStrategy getMergeCommitStrategy(Project.NameKey project) {
-    requireNonNull(project, "project");
-    return generalConfig.getMergeCommitStrategy(project, getPluginConfig(project));
-  }
-
-  /**
-   * 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));
-  }
-
-  /**
-   * 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
-   * @return whether an implicit code owner approval from the last uploader is assumed
-   */
-  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));
-  }
-
-  /**
-   * Gets the global code owners of the given project.
-   *
-   * @param project the project for which the global code owners should be returned
-   * @return the global code owners of the given project
-   */
-  public ImmutableSet<CodeOwnerReference> getGlobalCodeOwners(Project.NameKey project) {
-    requireNonNull(project, "project");
-    return generalConfig.getGlobalCodeOwners(getPluginConfig(project));
-  }
-
-  /**
-   * Gets the override info URL that is configured for the given project.
-   *
-   * @param project the project for which the configured override info URL should be returned
-   * @return the override info URL that is configured for the given project
-   */
-  public Optional<String> getOverrideInfoUrl(Project.NameKey project) {
-    requireNonNull(project, "project");
-    return generalConfig.getOverrideInfoUrl(getPluginConfig(project));
-  }
-
-  /**
-   * Returns the email domains that are allowed to be used for code owners.
-   *
-   * @return the email domains that are allowed to be used for code owners, an empty set if all
-   *     email domains are allowed (if {@code plugin.code-owners.allowedEmailDomain} is not set or
-   *     set to an empty value)
-   */
-  public ImmutableSet<String> getAllowedEmailDomains() {
-    return generalConfig.getAllowedEmailDomains();
-  }
-
-  /**
-   * Whether the code owners functionality is disabled for the given branch.
-   *
-   * <p>Callers must ensure that the project of the specified branch exists. If the project doesn't
-   * exist the call fails with {@link IllegalStateException}.
-   *
-   * <p>The configuration is evaluated in the following order:
-   *
-   * <ul>
-   *   <li>disabled configuration for the branch (with inheritance)
-   *   <li>disabled configuration for the project (with inheritance)
-   *   <li>hard-coded default (not disabled)
-   * </ul>
-   *
-   * <p>The first disabled configuration that exists counts and the evaluation is stopped.
-   *
-   * @param branchNameKey the branch and project for which it should be checked whether the code
-   *     owners functionality is disabled
-   * @return {@code true} if the code owners functionality is disabled for the given branch,
-   *     otherwise {@code false}
-   */
-  public boolean isDisabled(BranchNameKey branchNameKey) {
-    requireNonNull(branchNameKey, "branchNameKey");
-
-    Config pluginConfig = getPluginConfig(branchNameKey.project());
-
-    boolean isDisabled = statusConfig.isDisabledForBranch(pluginConfig, branchNameKey);
-    if (isDisabled) {
-      return true;
-    }
-
-    return isDisabled(branchNameKey.project());
-  }
-
-  /**
-   * Whether the code owners functionality is disabled for the given project.
-   *
-   * <p>Callers must ensure that the project of the specified branch exists. If the project doesn't
-   * exist the call fails with {@link IllegalStateException}.
-   *
-   * <p>The configuration is evaluated in the following order:
-   *
-   * <ul>
-   *   <li>disabled configuration for the project (with inheritance)
-   *   <li>hard-coded default (not disabled)
-   * </ul>
-   *
-   * <p>The first disabled configuration that exists counts and the evaluation is stopped.
-   *
-   * @param project the project for which it should be checked whether the code owners functionality
-   *     is disabled
-   * @return {@code true} if the code owners functionality is disabled for the given project,
-   *     otherwise {@code false}
-   */
-  public boolean isDisabled(Project.NameKey project) {
-    requireNonNull(project, "project");
-
-    Config pluginConfig = getPluginConfig(project);
-    return statusConfig.isDisabledForProject(pluginConfig, project);
-  }
-
-  /**
-   * Returns the configured {@link CodeOwnerBackend} for the given branch.
-   *
-   * <p>Callers must ensure that the project of the specified branch exists. If the project doesn't
-   * exist the call fails with {@link IllegalStateException}.
-   *
-   * <p>The code owner backend configuration is evaluated in the following order:
-   *
-   * <ul>
-   *   <li>backend configuration for branch (with inheritance, first by full branch name, then by
-   *       short branch name)
-   *   <li>backend configuration for project (with inheritance)
-   *   <li>default backend (first globally configured backend, then hard-coded default backend)
-   * </ul>
-   *
-   * <p>The first code owner backend configuration that exists counts and the evaluation is stopped.
-   *
-   * @param branchNameKey project and branch for which the configured code owner backend should be
-   *     returned
-   * @return the {@link CodeOwnerBackend} that should be used for the branch
-   */
-  public CodeOwnerBackend getBackend(BranchNameKey branchNameKey) {
-    Config pluginConfig = getPluginConfig(branchNameKey.project());
-
-    // check if a branch specific backend is configured
-    Optional<CodeOwnerBackend> codeOwnerBackend =
-        backendConfig.getBackendForBranch(pluginConfig, branchNameKey);
-    if (codeOwnerBackend.isPresent()) {
-      return codeOwnerBackend.get();
-    }
-
-    return getBackend(branchNameKey.project());
-  }
-
-  /**
-   * Returns the configured {@link CodeOwnerBackend} for the given project.
-   *
-   * <p>Callers must ensure that the project exists. If the project doesn't exist the call fails
-   * with {@link IllegalStateException}.
-   *
-   * <p>The code owner backend configuration is evaluated in the following order:
-   *
-   * <ul>
-   *   <li>backend configuration for project (with inheritance)
-   *   <li>default backend (first globally configured backend, then hard-coded default backend)
-   * </ul>
-   *
-   * <p>The first code owner backend configuration that exists counts and the evaluation is stopped.
-   *
-   * @param project project for which the configured code owner backend should be returned
-   * @return the {@link CodeOwnerBackend} that should be used for the project
-   */
-  public CodeOwnerBackend getBackend(Project.NameKey project) {
-    Config pluginConfig = getPluginConfig(project);
-
-    // check if a project specific backend is configured
-    Optional<CodeOwnerBackend> codeOwnerBackend =
-        backendConfig.getBackendForProject(pluginConfig, project);
-    if (codeOwnerBackend.isPresent()) {
-      return codeOwnerBackend.get();
-    }
-
-    // fall back to the default backend
-    return backendConfig.getDefaultBackend();
-  }
-
-  /**
-   * Returns the approval that is required from code owners to approve the files in a change of the
-   * given project.
-   *
-   * <p>Defines which approval counts as code owner approval.
-   *
-   * <p>Callers must ensure that the project of the specified branch exists. If the project doesn't
-   * exist the call fails with {@link IllegalStateException}.
-   *
-   * <p>The code owner required approval configuration is evaluated in the following order:
-   *
-   * <ul>
-   *   <li>required approval configuration for project (with inheritance)
-   *   <li>globally configured required approval
-   *   <li>hard-coded default required approval
-   * </ul>
-   *
-   * <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) {
-    ImmutableList<RequiredApproval> configuredRequiredApprovalConfig =
-        getConfiguredRequiredApproval(requiredApprovalConfig, project);
-    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
-    ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
-    return requiredApprovalConfig.createDefault(projectState);
-  }
-
-  /**
-   * 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}.
-   *
-   * <p>The override approval configuration is evaluated in the following order:
-   *
-   * <ul>
-   *   <li>override approval configuration for project (with inheritance)
-   *   <li>globally configured override approval
-   * </ul>
-   *
-   * <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 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 ImmutableSet<RequiredApproval> getOverrideApproval(Project.NameKey project) {
-    try {
-      return filterOutDuplicateRequiredApprovals(
-          getConfiguredRequiredApproval(overrideApprovalConfig, project));
-    } catch (InvalidPluginConfigurationException e) {
-      logger.atWarning().withCause(e).log(
-          "Ignoring invalid override approval configuration for project %s."
-              + " Overrides are disabled.",
-          project.get());
-    }
-
-    return ImmutableSet.of();
-  }
-
-  /**
-   * Filters out duplicate required approvals from the input list.
-   *
-   * <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 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));
-    return requiredApprovalConfig.get(projectState, pluginConfig);
-  }
-
-  /**
-   * Checks whether experimental REST endpoints are enabled.
-   *
-   * @throws MethodNotAllowedException thrown if experimental REST endpoints are disabled
-   */
-  public void checkExperimentalRestEndpointsEnabled() throws MethodNotAllowedException {
-    if (!areExperimentalRestEndpointsEnabled()) {
-      throw new MethodNotAllowedException("experimental code owners REST endpoints are disabled");
-    }
-  }
-
-  /** Whether experimental REST endpoints are enabled. */
-  public boolean areExperimentalRestEndpointsEnabled() {
-    try {
-      return pluginConfigFactory
-          .getFromGerritConfig(pluginName)
-          .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.",
-          pluginConfigFactory
-              .getFromGerritConfig(pluginName)
-              .getString(KEY_ENABLE_EXPERIMENTAL_REST_ENDPOINTS),
-          pluginName,
-          KEY_ENABLE_EXPERIMENTAL_REST_ENDPOINTS);
-      return false;
-    }
-  }
-
-  /**
-   * Reads and returns the config from the {@code code-owners.config} file in {@code
-   * refs/meta/config} branch of the given project.
-   *
-   * @param project the project for which the code owners configurations should be returned
-   * @return the code owners configurations for the given project
-   */
-  private Config getPluginConfig(Project.NameKey project) {
-    try {
-      return pluginConfigFactory.getProjectPluginConfigWithInheritance(project, pluginName);
-    } catch (NoSuchProjectException e) {
-      throw new IllegalStateException(
-          String.format(
-              "cannot get %s plugin config for non-existing project %s", pluginName, project),
-          e);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/plugins/codeowners/config/GeneralConfig.java b/java/com/google/gerrit/plugins/codeowners/config/GeneralConfig.java
deleted file mode 100644
index 85fd19a..0000000
--- a/java/com/google/gerrit/plugins/codeowners/config/GeneralConfig.java
+++ /dev/null
@@ -1,413 +0,0 @@
-// 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.config;
-
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
-import static java.util.Objects.requireNonNull;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.annotations.PluginName;
-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;
-import com.google.gerrit.server.git.validators.ValidationMessage;
-import com.google.gerrit.server.project.ProjectLevelConfig;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Optional;
-import org.eclipse.jgit.lib.Config;
-
-/**
- * Class to read the general code owners configuration from {@code gerrit.config} and from {@code
- * code-owners.config} in {@code refs/meta/config}.
- *
- * <p>Default values are configured in {@code gerrit.config}.
- *
- * <p>The default values can be overridden on project-level in {@code code-owners.config} in {@code
- * refs/meta/config}.
- *
- * <p>Projects that have no configuration inherit the configuration from their parent projects.
- */
-@Singleton
-public class GeneralConfig {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  @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_MERGE_COMMIT_STRATEGY = "mergeCommitStrategy";
-  @VisibleForTesting public static final String KEY_GLOBAL_CODE_OWNER = "globalCodeOwner";
-
-  @VisibleForTesting
-  public static final String KEY_ENABLE_IMPLICIT_APPROVALS = "enableImplicitApprovals";
-
-  @VisibleForTesting public static final String KEY_OVERRIDE_INFO_URL = "overrideInfoUrl";
-
-  private final String pluginName;
-  private final PluginConfig pluginConfigFromGerritConfig;
-
-  @Inject
-  GeneralConfig(@PluginName String pluginName, PluginConfigFactory pluginConfigFactory) {
-    this.pluginName = pluginName;
-    this.pluginConfigFromGerritConfig = pluginConfigFactory.getFromGerritConfig(pluginName);
-  }
-
-  /**
-   * Validates the backend configuration in the given project level configuration.
-   *
-   * @param fileName the name of the config file
-   * @param projectLevelConfig the project level plugin configuration
-   * @return list of validation messages for validation errors, empty list if there are no
-   *     validation errors
-   */
-  ImmutableList<CommitValidationMessage> validateProjectLevelConfig(
-      String fileName, ProjectLevelConfig.Bare projectLevelConfig) {
-    requireNonNull(fileName, "fileName");
-    requireNonNull(projectLevelConfig, "projectLevelConfig");
-
-    List<CommitValidationMessage> validationMessages = new ArrayList<>();
-
-    try {
-      projectLevelConfig
-          .getConfig()
-          .getEnum(
-              SECTION_CODE_OWNERS,
-              null,
-              KEY_MERGE_COMMIT_STRATEGY,
-              MergeCommitStrategy.ALL_CHANGED_FILES);
-    } catch (IllegalArgumentException e) {
-      validationMessages.add(
-          new CommitValidationMessage(
-              String.format(
-                  "Merge commit strategy '%s' that is configured in %s (parameter %s.%s) is invalid.",
-                  projectLevelConfig
-                      .getConfig()
-                      .getString(SECTION_CODE_OWNERS, null, KEY_MERGE_COMMIT_STRATEGY),
-                  fileName,
-                  SECTION_CODE_OWNERS,
-                  KEY_MERGE_COMMIT_STRATEGY),
-              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));
-    }
-
-    return ImmutableList.copyOf(validationMessages);
-  }
-
-  /**
-   * Gets the file extension that should be used for code owner config files in the given project.
-   *
-   * @param pluginConfig the plugin config from which the file extension should be read.
-   * @return the file extension that should be used for code owner config files in the given
-   *     project, {@link Optional#empty()} if no file extension should be used
-   */
-  Optional<String> getFileExtension(Config pluginConfig) {
-    requireNonNull(pluginConfig, "pluginConfig");
-
-    String fileExtension = pluginConfig.getString(SECTION_CODE_OWNERS, null, KEY_FILE_EXTENSION);
-    if (fileExtension != null) {
-      return Optional.of(fileExtension);
-    }
-
-    return Optional.ofNullable(pluginConfigFromGerritConfig.getString(KEY_FILE_EXTENSION));
-  }
-
-  /**
-   * Returns the email domains that are allowed to be used for code owners.
-   *
-   * @return the email domains that are allowed to be used for code owners, an empty set if all
-   *     email domains are allowed (if {@code plugin.code-owners.allowedEmailDomain} is not set or
-   *     set to an empty value)
-   */
-  ImmutableSet<String> getAllowedEmailDomains() {
-    return Arrays.stream(pluginConfigFromGerritConfig.getStringList(KEY_ALLOWED_EMAIL_DOMAIN))
-        .filter(emailDomain -> !Strings.isNullOrEmpty(emailDomain))
-        .distinct()
-        .collect(toImmutableSet());
-  }
-
-  /**
-   * Gets the read-only configuration from the given plugin config with fallback to {@code
-   * gerrit.config}.
-   *
-   * <p>The read-only controls whether code owner config files are read-only and all modifications
-   * of code owner config files should be rejected.
-   *
-   * @param pluginConfig the plugin config from which the read-only configuration should be read.
-   * @return whether code owner config files are read-only
-   */
-  boolean getReadOnly(Config pluginConfig) {
-    requireNonNull(pluginConfig, "pluginConfig");
-
-    if (pluginConfig.getString(SECTION_CODE_OWNERS, null, KEY_READ_ONLY) != null) {
-      return pluginConfig.getBoolean(SECTION_CODE_OWNERS, null, KEY_READ_ONLY, false);
-    }
-
-    return pluginConfigFromGerritConfig.getBoolean(KEY_READ_ONLY, false);
-  }
-
-  /**
-   * 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 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 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);
-    if (codeOwnerConfigValidationPolicyString != null) {
-      try {
-        return pluginConfig.getEnum(
-            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'"
-                + " of project %s. Falling back to global config.",
-            codeOwnerConfigValidationPolicyString, pluginName, project.get());
-      }
-    }
-
-    try {
-      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),
-          pluginName,
-          key,
-          CodeOwnerConfigValidationPolicy.TRUE);
-      return CodeOwnerConfigValidationPolicy.TRUE;
-    }
-  }
-
-  /**
-   * Gets the merge commit strategy from the given plugin config with fallback to {@code
-   * gerrit.config}.
-   *
-   * <p>The merge commit strategy defines for merge commits which files require code owner
-   * approvals.
-   *
-   * @param project the name of the project for which the merge commit strategy should be read
-   * @param pluginConfig the plugin config from which the merge commit strategy should be read
-   * @return the merge commit strategy that should be used
-   */
-  MergeCommitStrategy getMergeCommitStrategy(Project.NameKey project, Config pluginConfig) {
-    requireNonNull(project, "project");
-    requireNonNull(pluginConfig, "pluginConfig");
-
-    String mergeCommitStrategyString =
-        pluginConfig.getString(SECTION_CODE_OWNERS, null, KEY_MERGE_COMMIT_STRATEGY);
-    if (mergeCommitStrategyString != null) {
-      try {
-        return pluginConfig.getEnum(
-            SECTION_CODE_OWNERS,
-            null,
-            KEY_MERGE_COMMIT_STRATEGY,
-            MergeCommitStrategy.ALL_CHANGED_FILES);
-      } catch (IllegalArgumentException e) {
-        logger.atWarning().log(
-            "Ignoring invalid value %s for merge commit stategy in '%s.config' of project %s."
-                + " Falling back to global config or default value.",
-            mergeCommitStrategyString, pluginName, project.get());
-      }
-    }
-
-    try {
-      return pluginConfigFromGerritConfig.getEnum(
-          KEY_MERGE_COMMIT_STRATEGY, MergeCommitStrategy.ALL_CHANGED_FILES);
-    } catch (IllegalArgumentException e) {
-      logger.atWarning().log(
-          "Ignoring invalid value %s for merge commit stategy in gerrit.config (parameter plugin.%s.%s)."
-              + " Falling back to default value %s.",
-          pluginConfigFromGerritConfig.getString(KEY_MERGE_COMMIT_STRATEGY),
-          pluginName,
-          KEY_MERGE_COMMIT_STRATEGY,
-          MergeCommitStrategy.ALL_CHANGED_FILES);
-      return MergeCommitStrategy.ALL_CHANGED_FILES;
-    }
-  }
-
-  /**
-   * Gets whether an implicit code owner approval from the last uploader is assumed from the given
-   * plugin config with fallback to {@code gerrit.config}.
-   *
-   * @param pluginConfig the plugin config from which the configuration should be read.
-   * @return whether an implicit code owner approval from the last uploader is assumed
-   */
-  boolean getEnableImplicitApprovals(Config pluginConfig) {
-    requireNonNull(pluginConfig, "pluginConfig");
-
-    return pluginConfig.getBoolean(
-        SECTION_CODE_OWNERS,
-        null,
-        KEY_ENABLE_IMPLICIT_APPROVALS,
-        pluginConfigFromGerritConfig.getBoolean(KEY_ENABLE_IMPLICIT_APPROVALS, false));
-  }
-
-  /**
-   * Gets the users which are configured as global code owners from the given plugin config with
-   * fallback to {@code gerrit.config}.
-   *
-   * @param pluginConfig the plugin config from which the global code owners should be read.
-   * @return the users which are configured as global code owners
-   */
-  ImmutableSet<CodeOwnerReference> getGlobalCodeOwners(Config pluginConfig) {
-    requireNonNull(pluginConfig, "pluginConfig");
-
-    if (pluginConfig.getString(SECTION_CODE_OWNERS, null, KEY_GLOBAL_CODE_OWNER) != null) {
-      return Arrays.stream(
-              pluginConfig.getStringList(SECTION_CODE_OWNERS, null, KEY_GLOBAL_CODE_OWNER))
-          .map(CodeOwnerReference::create)
-          .collect(toImmutableSet());
-    }
-
-    return Arrays.stream(pluginConfigFromGerritConfig.getStringList(KEY_GLOBAL_CODE_OWNER))
-        .map(CodeOwnerReference::create)
-        .collect(toImmutableSet());
-  }
-
-  /**
-   * Gets an URL that leads to an information page about overrides.
-   *
-   * <p>The URL is retrieved from the given plugin config, with fallback to the {@code
-   * gerrit.config}.
-   *
-   * @param pluginConfig the plugin config from which the override info URL should be read.
-   * @return URL that leads to an information page about overrides, {@link Optional#empty()} if no
-   *     such URL is configured
-   */
-  Optional<String> getOverrideInfoUrl(Config pluginConfig) {
-    requireNonNull(pluginConfig, "pluginConfig");
-
-    String fileExtension = pluginConfig.getString(SECTION_CODE_OWNERS, null, KEY_OVERRIDE_INFO_URL);
-    if (fileExtension != null) {
-      return Optional.of(fileExtension);
-    }
-
-    return Optional.ofNullable(pluginConfigFromGerritConfig.getString(KEY_OVERRIDE_INFO_URL));
-  }
-}
diff --git a/java/com/google/gerrit/plugins/codeowners/metrics/BUILD b/java/com/google/gerrit/plugins/codeowners/metrics/BUILD
new file mode 100644
index 0000000..1f1ad14
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/metrics/BUILD
@@ -0,0 +1,9 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:plugin.bzl", "PLUGIN_DEPS_NEVERLINK")
+
+java_library(
+    name = "metrics",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = PLUGIN_DEPS_NEVERLINK,
+)
diff --git a/java/com/google/gerrit/plugins/codeowners/metrics/CodeOwnerMetrics.java b/java/com/google/gerrit/plugins/codeowners/metrics/CodeOwnerMetrics.java
new file mode 100644
index 0000000..89fac59
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/metrics/CodeOwnerMetrics.java
@@ -0,0 +1,254 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.metrics;
+
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter3;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.Histogram0;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/** Metrics of the code-owners plugin. */
+@Singleton
+public class CodeOwnerMetrics {
+  // latency metrics
+  public final Timer0 addChangeMessageOnAddReviewer;
+  public final Timer0 computeChangedFiles;
+  public final Timer0 computeFileStatus;
+  public final Timer0 computeFileStatuses;
+  public final Timer0 computeOwnedPaths;
+  public final Timer0 computePatchSetApprovals;
+  public final Timer0 extendChangeMessageOnPostReview;
+  public final Timer0 getAutoMerge;
+  public final Timer0 getChangedFiles;
+  public final Timer0 prepareFileStatusComputation;
+  public final Timer0 prepareFileStatusComputationForAccount;
+  public final Timer0 resolveCodeOwnerConfig;
+  public final Timer0 resolveCodeOwnerConfigImport;
+  public final Timer0 resolveCodeOwnerConfigImports;
+  public final Timer0 resolveCodeOwnerReferences;
+  public final Timer0 resolvePathCodeOwners;
+  public final Timer0 runCodeOwnerSubmitRule;
+
+  // code owner config metrics
+  public final Histogram0 codeOwnerConfigBackendReadsPerChange;
+  public final Histogram0 codeOwnerConfigCacheReadsPerChange;
+  public final Timer1<String> loadCodeOwnerConfig;
+  public final Timer0 readCodeOwnerConfig;
+  public final Timer1<String> parseCodeOwnerConfig;
+
+  // counter metrics
+  public final Counter0 countCodeOwnerConfigReads;
+  public final Counter0 countCodeOwnerConfigCacheReads;
+  public final Counter3<ValidationTrigger, ValidationResult, Boolean>
+      countCodeOwnerConfigValidations;
+  public final Counter1<String> countCodeOwnerSubmitRuleErrors;
+  public final Counter0 countCodeOwnerSubmitRuleRuns;
+  public final Counter1<Boolean> countCodeOwnerSuggestions;
+  public final Counter3<String, String, String> countInvalidCodeOwnerConfigFiles;
+
+  private final MetricMaker metricMaker;
+
+  @Inject
+  CodeOwnerMetrics(MetricMaker metricMaker) {
+    this.metricMaker = metricMaker;
+
+    // latency metrics
+    this.addChangeMessageOnAddReviewer =
+        createLatencyTimer(
+            "add_change_message_on_add_reviewer",
+            "Latency for adding a change message with the owned path when a code owner is added as"
+                + " a reviewer");
+    this.computeChangedFiles =
+        createLatencyTimer("compute_changed_files", "Latency for computing changed files");
+    this.computeFileStatus =
+        createLatencyTimer(
+            "compute_file_status", "Latency for computing the file status of one file");
+    this.computeFileStatuses =
+        createLatencyTimer(
+            "compute_file_statuses",
+            "Latency for computing file statuses for all files in a change");
+    this.computeOwnedPaths =
+        createLatencyTimer(
+            "compute_owned_paths",
+            "Latency for computing the files in a change that are owned by a user");
+    this.computePatchSetApprovals =
+        createLatencyTimer(
+            "compute_patch_set_approvals",
+            "Latency for computing the approvals of the current patch set");
+    this.extendChangeMessageOnPostReview =
+        createLatencyTimer(
+            "extend_change_message_on_post_review",
+            "Latency for extending the change message with the owned path when a code owner"
+                + " approval is applied");
+    this.getAutoMerge =
+        createLatencyTimer(
+            "get_auto_merge", "Latency for getting the auto merge commit of a merge commit");
+    this.getChangedFiles =
+        createLatencyTimer(
+            "get_changed_files", "Latency for getting changed files from diff cache");
+    this.prepareFileStatusComputation =
+        createLatencyTimer(
+            "prepare_file_status_computation", "Latency for preparing the file status computation");
+    this.prepareFileStatusComputationForAccount =
+        createLatencyTimer(
+            "compute_file_statuses_for_account",
+            "Latency for computing file statuses for an account");
+    this.resolveCodeOwnerConfig =
+        createLatencyTimer(
+            "resolve_code_owner_config", "Latency for resolving a code owner config file");
+    this.resolveCodeOwnerConfigImport =
+        createLatencyTimer(
+            "resolve_code_owner_config_import",
+            "Latency for resolving an import of a code owner config file");
+    this.resolveCodeOwnerConfigImports =
+        createLatencyTimer(
+            "resolve_code_owner_config_imports",
+            "Latency for resolving all imports of a code owner config file");
+    this.resolveCodeOwnerReferences =
+        createLatencyTimer(
+            "resolve_code_owner_references", "Latency for resolving the code owner references");
+    this.resolvePathCodeOwners =
+        createLatencyTimer(
+            "resolve_path_code_owners", "Latency for resolving the code owners of a path");
+    this.runCodeOwnerSubmitRule =
+        createLatencyTimer(
+            "run_code_owner_submit_rule", "Latency for running the code owner submit rule");
+
+    // code owner config metrics
+    this.codeOwnerConfigBackendReadsPerChange =
+        createHistogram(
+            "code_owner_config_backend_reads_per_change",
+            "Number of code owner config backend reads per change");
+    this.codeOwnerConfigCacheReadsPerChange =
+        createHistogram(
+            "code_owner_config_cache_reads_per_change",
+            "Number of code owner config cache reads per change");
+    this.loadCodeOwnerConfig =
+        createTimerWithClassField(
+            "load_code_owner_config",
+            "Latency for loading a code owner config file (read + parse)",
+            "backend");
+    this.parseCodeOwnerConfig =
+        createTimerWithClassField(
+            "parse_code_owner_config", "Latency for parsing a code owner config file", "parser");
+    this.readCodeOwnerConfig =
+        createLatencyTimer(
+            "read_code_owner_config", "Latency for reading a code owner config file");
+
+    // counter metrics
+    this.countCodeOwnerConfigReads =
+        createCounter(
+            "count_code_owner_config_reads",
+            "Total number of code owner config reads from backend");
+    this.countCodeOwnerConfigCacheReads =
+        createCounter(
+            "count_code_owner_config_cache_reads",
+            "Total number of code owner config reads from cache");
+    this.countCodeOwnerConfigValidations =
+        createCounter3(
+            "count_code_owner_config_validations",
+            "Total number of code owner config file validations",
+            Field.ofEnum(
+                    ValidationTrigger.class, "trigger", (metadataBuilder, resolveAllUsers) -> {})
+                .description("The trigger of the validation.")
+                .build(),
+            Field.ofEnum(ValidationResult.class, "result", (metadataBuilder, resolveAllUsers) -> {})
+                .description("The result of the validation.")
+                .build(),
+            Field.ofBoolean("dry_run", (metadataBuilder, resolveAllUsers) -> {})
+                .description("Whether the validation was a dry run.")
+                .build());
+    this.countCodeOwnerSubmitRuleErrors =
+        createCounter1(
+            "count_code_owner_submit_rule_errors",
+            "Total number of code owner submit rule errors",
+            Field.ofString("cause", Metadata.Builder::cause)
+                .description("The cause of the submit rule error.")
+                .build());
+    this.countCodeOwnerSubmitRuleRuns =
+        createCounter(
+            "count_code_owner_submit_rule_runs", "Total number of code owner submit rule runs");
+    this.countCodeOwnerSuggestions =
+        createCounter1(
+            "count_code_owner_suggestions",
+            "Total number of code owner suggestions",
+            Field.ofBoolean("resolve_all_users", (metadataBuilder, resolveAllUsers) -> {})
+                .description(
+                    "Whether code ownerships that are assigned to all users are resolved to random"
+                        + " users.")
+                .build());
+    this.countInvalidCodeOwnerConfigFiles =
+        createCounter3(
+            "count_invalid_code_owner_config_files",
+            "Total number of failed requests caused by an invalid / non-parsable code owner config"
+                + " file",
+            Field.ofString("project", Metadata.Builder::projectName)
+                .description(
+                    "The name of the project that contains the invalid code owner config file.")
+                .build(),
+            Field.ofString("branch", Metadata.Builder::branchName)
+                .description(
+                    "The name of the branch that contains the invalid code owner config file.")
+                .build(),
+            Field.ofString("path", Metadata.Builder::filePath)
+                .description("The path of the invalid code owner config file.")
+                .build());
+  }
+
+  private Timer0 createLatencyTimer(String name, String description) {
+    return metricMaker.newTimer(
+        name, new Description(description).setCumulative().setUnit(Units.MILLISECONDS));
+  }
+
+  private Timer1<String> createTimerWithClassField(
+      String name, String description, String fieldName) {
+    Field<String> CODE_OWNER_BACKEND_FIELD =
+        Field.ofString(
+                fieldName, (metadataBuilder, fieldValue) -> metadataBuilder.className(fieldValue))
+            .build();
+
+    return metricMaker.newTimer(
+        name,
+        new Description(description).setCumulative().setUnit(Description.Units.MILLISECONDS),
+        CODE_OWNER_BACKEND_FIELD);
+  }
+
+  private Counter0 createCounter(String name, String description) {
+    return metricMaker.newCounter(name, new Description(description).setRate());
+  }
+
+  private <F1> Counter1<F1> createCounter1(String name, String description, Field<F1> field1) {
+    return metricMaker.newCounter(name, new Description(description).setRate(), field1);
+  }
+
+  private <F1, F2, F3> Counter3<F1, F2, F3> createCounter3(
+      String name, String description, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    return metricMaker.newCounter(
+        name, new Description(description).setRate(), field1, field2, field3);
+  }
+
+  private Histogram0 createHistogram(String name, String description) {
+    return metricMaker.newHistogram(name, new Description(description).setCumulative());
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/metrics/ValidationResult.java b/java/com/google/gerrit/plugins/codeowners/metrics/ValidationResult.java
new file mode 100644
index 0000000..5267736
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/metrics/ValidationResult.java
@@ -0,0 +1,27 @@
+// 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.metrics;
+
+/** Enum that represents the result of a validation. */
+public enum ValidationResult {
+  /** The validation found issues that caused a rejection. */
+  REJECTED,
+
+  /** The validation passed without finding rejection reasons. */
+  PASSED,
+
+  /** The validation couldn't be performed due to a server error. */
+  FAILED;
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/metrics/ValidationTrigger.java b/java/com/google/gerrit/plugins/codeowners/metrics/ValidationTrigger.java
new file mode 100644
index 0000000..4d28b70
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/metrics/ValidationTrigger.java
@@ -0,0 +1,24 @@
+// 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.metrics;
+
+/** Enum to express which event triggered the validation. */
+public enum ValidationTrigger {
+  /** A new commit was received that should be validated. */
+  COMMIT_RECEIVED,
+
+  /** A commit is about to be merged and should be validated. */
+  PRE_MERGE;
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/BatchModule.java b/java/com/google/gerrit/plugins/codeowners/module/BatchModule.java
similarity index 95%
rename from java/com/google/gerrit/plugins/codeowners/BatchModule.java
rename to java/com/google/gerrit/plugins/codeowners/module/BatchModule.java
index 3267d31..8295264 100644
--- a/java/com/google/gerrit/plugins/codeowners/BatchModule.java
+++ b/java/com/google/gerrit/plugins/codeowners/module/BatchModule.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners;
+package com.google.gerrit.plugins.codeowners.module;
 
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.plugins.codeowners.backend.BackendModule;
diff --git a/java/com/google/gerrit/plugins/codeowners/HttpModule.java b/java/com/google/gerrit/plugins/codeowners/module/HttpModule.java
similarity index 94%
rename from java/com/google/gerrit/plugins/codeowners/HttpModule.java
rename to java/com/google/gerrit/plugins/codeowners/module/HttpModule.java
index 117ffa2..c432d68 100644
--- a/java/com/google/gerrit/plugins/codeowners/HttpModule.java
+++ b/java/com/google/gerrit/plugins/codeowners/module/HttpModule.java
@@ -11,7 +11,7 @@
 // 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;
+package com.google.gerrit.plugins.codeowners.module;
 
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.webui.JavaScriptPlugin;
diff --git a/java/com/google/gerrit/plugins/codeowners/Module.java b/java/com/google/gerrit/plugins/codeowners/module/Module.java
similarity index 86%
rename from java/com/google/gerrit/plugins/codeowners/Module.java
rename to java/com/google/gerrit/plugins/codeowners/module/Module.java
index 341dfc8..c5447f6 100644
--- a/java/com/google/gerrit/plugins/codeowners/Module.java
+++ b/java/com/google/gerrit/plugins/codeowners/module/Module.java
@@ -12,12 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners;
+package com.google.gerrit.plugins.codeowners.module;
 
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.plugins.codeowners.api.ApiModule;
+import com.google.gerrit.plugins.codeowners.api.impl.ApiModule;
 import com.google.gerrit.plugins.codeowners.backend.BackendModule;
-import com.google.gerrit.plugins.codeowners.config.ConfigModule;
+import com.google.gerrit.plugins.codeowners.backend.config.ConfigModule;
 import com.google.gerrit.plugins.codeowners.restapi.RestApiModule;
 import com.google.gerrit.plugins.codeowners.validation.ValidationModule;
 
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/AbstractGetCodeOwnersForPath.java b/java/com/google/gerrit/plugins/codeowners/restapi/AbstractGetCodeOwnersForPath.java
index a5f7b5d..85db8e3 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/AbstractGetCodeOwnersForPath.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/AbstractGetCodeOwnersForPath.java
@@ -19,12 +19,12 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ListAccountsOption;
 import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.common.AccountVisibility;
@@ -32,14 +32,17 @@
 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.api.CodeOwnersInfo;
 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.backend.CodeOwnerResolverResult;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerScore;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerScoring;
-import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerScorings;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnersInternalServerErrorException;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gerrit.server.account.AccountLoader;
@@ -52,10 +55,14 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Comparator;
 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.concurrent.atomic.AtomicBoolean;
 import java.util.stream.Stream;
 import org.kohsuke.args4j.Option;
 
@@ -73,6 +80,8 @@
   private final Accounts accounts;
   private final AccountControl.Factory accountControlFactory;
   private final PermissionBackend permissionBackend;
+  private final CheckCodeOwnerCapability checkCodeOwnerCapability;
+  private final CodeOwnerMetrics codeOwnerMetrics;
   private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
   private final CodeOwnerConfigHierarchy codeOwnerConfigHierarchy;
   private final Provider<CodeOwnerResolver> codeOwnerResolver;
@@ -81,6 +90,10 @@
   private final Set<String> hexOptions;
 
   private int limit = DEFAULT_LIMIT;
+  private Optional<Long> seed = Optional.empty();
+  private boolean resolveAllUsers;
+  private boolean highestScoreOnly;
+  private boolean debug;
 
   @Option(
       name = "-o",
@@ -106,11 +119,45 @@
     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);
+  }
+
+  @Option(
+      name = "--resolve-all-users",
+      usage =
+          "whether code ownerships that are assigned to all users should be resolved to random"
+              + " users")
+  public void setResolveAllUsers(boolean resolveAllUsers) {
+    this.resolveAllUsers = resolveAllUsers;
+  }
+
+  @Option(
+      name = "--highest-score-only",
+      usage = "whether only code owners with the highest score should be returned")
+  public void setHighestScoreOnly(boolean highestScoreOnly) {
+    this.highestScoreOnly = highestScoreOnly;
+  }
+
+  @Option(
+      name = "--debug",
+      usage =
+          "whether debug logs should be included into the response"
+              + " (requires the 'Check Code Owner' global capability)")
+  public void setDebug(boolean debug) {
+    this.debug = debug;
+  }
+
   protected AbstractGetCodeOwnersForPath(
       AccountVisibility accountVisibility,
       Accounts accounts,
       AccountControl.Factory accountControlFactory,
       PermissionBackend permissionBackend,
+      CheckCodeOwnerCapability checkCodeOwnerCapability,
+      CodeOwnerMetrics codeOwnerMetrics,
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
       Provider<CodeOwnerResolver> codeOwnerResolver,
@@ -119,6 +166,8 @@
     this.accounts = accounts;
     this.accountControlFactory = accountControlFactory;
     this.permissionBackend = permissionBackend;
+    this.checkCodeOwnerCapability = checkCodeOwnerCapability;
+    this.codeOwnerMetrics = codeOwnerMetrics;
     this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
     this.codeOwnerConfigHierarchy = codeOwnerConfigHierarchy;
     this.codeOwnerResolver = codeOwnerResolver;
@@ -127,11 +176,21 @@
     this.hexOptions = new HashSet<>();
   }
 
-  protected Response<List<CodeOwnerInfo>> applyImpl(R rsrc)
+  protected Response<CodeOwnersInfo> applyImpl(R rsrc)
       throws AuthException, BadRequestException, PermissionBackendException {
     parseHexOptions();
     validateLimit();
 
+    if (debug) {
+      permissionBackend.currentUser().check(checkCodeOwnerCapability.getPermission());
+    }
+
+    if (!seed.isPresent()) {
+      seed = getDefaultSeed(rsrc);
+    }
+
+    codeOwnerMetrics.countCodeOwnerSuggestions.increment(resolveAllUsers);
+
     // The distance that applies to code owners that are defined in the root code owner
     // configuration.
     int rootDistance = rsrc.getPath().getNameCount();
@@ -143,6 +202,8 @@
     CodeOwnerScoring.Builder distanceScoring = CodeOwnerScore.DISTANCE.createScoring(maxDistance);
 
     Set<CodeOwner> codeOwners = new HashSet<>();
+    AtomicBoolean ownedByAllUsers = new AtomicBoolean(false);
+    List<String> debugLogs = new ArrayList<>();
     codeOwnerConfigHierarchy.visit(
         rsrc.getBranch(),
         rsrc.getRevision(),
@@ -150,24 +211,13 @@
         codeOwnerConfig -> {
           CodeOwnerResolverResult pathCodeOwners =
               codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, rsrc.getPath());
-          codeOwners.addAll(filterCodeOwners(rsrc, pathCodeOwners.codeOwners()));
 
-          if (pathCodeOwners.ownedByAllUsers()) {
-            fillUpWithRandomUsers(rsrc, codeOwners, limit);
-
-            if (codeOwners.size() < limit) {
-              logger.atFine().log(
-                  "tried to fill up the suggestion list with random users,"
-                      + " but didn't find enough visible accounts"
-                      + " (wanted number of suggestions = %d, got = %d",
-                  limit, codeOwners.size());
-            }
-
-            // We already found that the path is owned by all users. Hence we do not need to check
-            // if there are further code owners in higher-level code owner configs.
-            return false;
+          if (debug) {
+            debugLogs.addAll(pathCodeOwners.messages());
           }
 
+          codeOwners.addAll(filterCodeOwners(rsrc, pathCodeOwners.codeOwners()));
+
           int distance =
               codeOwnerConfig.key().branchNameKey().branch().equals(RefNames.REFS_CONFIG)
                   ? defaultOwnersDistance
@@ -177,16 +227,38 @@
               .forEach(
                   localCodeOwner -> distanceScoring.putValueForCodeOwner(localCodeOwner, distance));
 
-          // If codeOwners.size() >= limit we have gathered enough code owners and do not need to
-          // look at further code owner configs.
-          // We can abort here, since all further code owners will have a lower distance scoring
-          // and hence they would appear at the end of the sorted code owners list and be dropped
-          // due to the limit.
-          return codeOwners.size() < limit;
+          if (pathCodeOwners.ownedByAllUsers()) {
+            ownedByAllUsers.set(true);
+            ImmutableSet<CodeOwner> addedCodeOwners =
+                fillUpWithRandomUsers(rsrc, codeOwners, limit);
+            addedCodeOwners.forEach(
+                localCodeOwner -> distanceScoring.putValueForCodeOwner(localCodeOwner, distance));
+
+            if (codeOwners.size() < limit) {
+              logger.atFine().log(
+                  "tried to fill up the suggestion list with random users,"
+                      + " but didn't find enough visible accounts"
+                      + " (wanted number of suggestions = %d, got = %d",
+                  limit, codeOwners.size());
+            }
+          }
+
+          // We always need to iterate over all relevant OWNERS files (even if the limit has already
+          // been reached).
+          // This is needed to collect distance scores for code owners that are mentioned in the
+          // more distant OWNERS files. Those become relevant if further scores are applied later
+          // (e.g. the score for current reviewers of the change).
+          return true;
         });
 
-    if (codeOwners.size() < limit) {
+    if (codeOwners.size() < limit || !ownedByAllUsers.get()) {
       CodeOwnerResolverResult globalCodeOwners = getGlobalCodeOwners(rsrc.getBranch().project());
+
+      if (debug) {
+        debugLogs.add("resolve global code owners");
+        debugLogs.addAll(globalCodeOwners.messages());
+      }
+
       globalCodeOwners
           .codeOwners()
           .forEach(
@@ -194,26 +266,72 @@
       codeOwners.addAll(filterCodeOwners(rsrc, globalCodeOwners.codeOwners()));
 
       if (globalCodeOwners.ownedByAllUsers()) {
-        fillUpWithRandomUsers(rsrc, codeOwners, limit);
+        ownedByAllUsers.set(true);
+        ImmutableSet<CodeOwner> addedCodeOwners = fillUpWithRandomUsers(rsrc, codeOwners, limit);
+        addedCodeOwners.forEach(
+            codeOwner -> distanceScoring.putValueForCodeOwner(codeOwner, globalOwnersDistance));
       }
     }
 
-    return Response.ok(
-        codeOwnerJsonFactory
-            .create(getFillOptions())
-            .format(sortAndLimit(distanceScoring.build(), ImmutableSet.copyOf(codeOwners))));
+    ImmutableSet<CodeOwner> immutableCodeOwners = ImmutableSet.copyOf(codeOwners);
+    CodeOwnerScorings codeOwnerScorings =
+        createScorings(rsrc, immutableCodeOwners, distanceScoring.build());
+    ImmutableMap<CodeOwner, Double> scoredCodeOwners =
+        codeOwnerScorings.getScorings(immutableCodeOwners);
+
+    ImmutableList<CodeOwner> sortedAndLimitedCodeOwners = sortAndLimit(rsrc, scoredCodeOwners);
+
+    if (highestScoreOnly) {
+      Optional<Double> highestScore =
+          scoredCodeOwners.values().stream().max(Comparator.naturalOrder());
+      if (highestScore.isPresent()) {
+        sortedAndLimitedCodeOwners =
+            sortedAndLimitedCodeOwners.stream()
+                .filter(codeOwner -> scoredCodeOwners.get(codeOwner).equals(highestScore.get()))
+                .collect(toImmutableList());
+      }
+    }
+
+    CodeOwnersInfo codeOwnersInfo = new CodeOwnersInfo();
+    codeOwnersInfo.codeOwners =
+        codeOwnerJsonFactory.create(getFillOptions()).format(sortedAndLimitedCodeOwners);
+    codeOwnersInfo.ownedByAllUsers = ownedByAllUsers.get() ? true : null;
+    codeOwnersInfo.debugLogs = debug ? debugLogs : null;
+    return Response.ok(codeOwnersInfo);
+  }
+
+  private CodeOwnerScorings createScorings(
+      R rsrc, ImmutableSet<CodeOwner> codeOwners, CodeOwnerScoring distanceScoring) {
+    ImmutableSet.Builder<CodeOwnerScoring> codeOwnerScorings = ImmutableSet.builder();
+    codeOwnerScorings.add(distanceScoring);
+    codeOwnerScorings.addAll(getCodeOwnerScorings(rsrc, codeOwners));
+    return CodeOwnerScorings.create(codeOwnerScorings.build());
   }
 
   private CodeOwnerResolverResult getGlobalCodeOwners(Project.NameKey projectName) {
     CodeOwnerResolverResult globalCodeOwners =
         codeOwnerResolver
             .get()
-            .resolve(codeOwnersPluginConfiguration.getGlobalCodeOwners(projectName));
+            .resolve(
+                codeOwnersPluginConfiguration.getProjectConfig(projectName).getGlobalCodeOwners());
     logger.atFine().log("including global code owners = %s", globalCodeOwners);
     return globalCodeOwners;
   }
 
   /**
+   * Get further code owner scorings.
+   *
+   * <p>To be overridden by subclasses to include further scorings.
+   *
+   * @param rsrc resource on which the request is being performed
+   * @param codeOwners the code owners
+   */
+  protected ImmutableSet<CodeOwnerScoring> getCodeOwnerScorings(
+      R rsrc, ImmutableSet<CodeOwner> codeOwners) {
+    return ImmutableSet.of();
+  }
+
+  /**
    * Filters out code owners that should not be suggested.
    *
    * <p>The following code owners are filtered out:
@@ -240,6 +358,18 @@
     return codeOwners;
   }
 
+  /**
+   * Returns the seed that should by default be used for sorting, if none was specified on the
+   * request.
+   *
+   * <p>If {@link Optional#empty()} is returned, a random seed will be used.
+   *
+   * @param rsrc resource on which the request is being performed
+   */
+  protected Optional<Long> getDefaultSeed(R rsrc) {
+    return Optional.empty();
+  }
+
   private Stream<CodeOwner> getVisibleCodeOwners(R rsrc, ImmutableSet<CodeOwner> allCodeOwners) {
     return allCodeOwners.stream()
         .filter(
@@ -307,35 +437,39 @@
   }
 
   private ImmutableList<CodeOwner> sortAndLimit(
-      CodeOwnerScoring distanceScoring, ImmutableSet<CodeOwner> codeOwners) {
-    return sortCodeOwners(distanceScoring, codeOwners).limit(limit).collect(toImmutableList());
+      R rsrc, ImmutableMap<CodeOwner, Double> scoredCodeOwners) {
+    return sortCodeOwners(rsrc, seed, scoredCodeOwners).limit(limit).collect(toImmutableList());
   }
 
   /**
    * Sorts the code owners.
    *
-   * <p>Code owners with higher distance score are returned first.
+   * <p>Code owners with higher score are returned first.
    *
-   * <p>The order of code owners with the same distance score is random.
+   * <p>The order of code owners with the same score is random.
    *
-   * @param distanceScoring the distance scorings for the code owners
-   * @param codeOwners the code owners that should be sorted
+   * @param rsrc resource on which this REST endpoint is invoked
+   * @param seed seed that should be used to randomize the order
+   * @param scoredCodeOwners the code owners with their scores
    * @return the sorted code owners
    */
-  private static Stream<CodeOwner> sortCodeOwners(
-      CodeOwnerScoring distanceScoring, ImmutableSet<CodeOwner> codeOwners) {
-    return randomizeOrder(codeOwners).sorted(distanceScoring.comparingByScoring());
+  private Stream<CodeOwner> sortCodeOwners(
+      R rsrc, Optional<Long> seed, ImmutableMap<CodeOwner, Double> scoredCodeOwners) {
+    return randomizeOrder(seed, scoredCodeOwners.keySet())
+        .sorted(Comparator.comparingDouble(scoredCodeOwners::get).reversed());
   }
 
   /**
    * 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();
   }
 
@@ -345,23 +479,35 @@
    *
    * <p>Must be only used to complete the suggestion list when it is found that the path is owned by
    * all user.
+   *
+   * <p>No-op if code ownership for all users should not be resolved.
+   *
+   * @return the added code owners
    */
-  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;
+  private ImmutableSet<CodeOwner> fillUpWithRandomUsers(
+      R rsrc, Set<CodeOwner> codeOwners, int limit) {
+    if (!resolveAllUsers || codeOwners.size() >= limit) {
+      // code ownership for all users should not be resolved or the limit has already been reached
+      // so that we don't need to add further suggestions
+      return ImmutableSet.of();
     }
 
     logger.atFine().log("filling up with random users");
-    codeOwners.addAll(
+    ImmutableSet<CodeOwner> codeOwnersToAdd =
         filterCodeOwners(
-            rsrc,
-            // ask for 2 times the number of users that we need so that we still have enough
-            // suggestions when some users are removed by the filterCodeOwners call or if the
-            // returned users were already present in codeOwners
-            getRandomVisibleUsers(2 * limit - codeOwners.size())
-                .map(CodeOwner::create)
-                .collect(toImmutableSet())));
+                rsrc,
+                // ask for 2 times the number of users that we need so that we still have enough
+                // suggestions when some users are removed by the filterCodeOwners call or if the
+                // returned users were already present in codeOwners
+                getRandomVisibleUsers(2 * limit - codeOwners.size())
+                    .map(CodeOwner::create)
+                    .collect(toImmutableSet()))
+            .stream()
+            .filter(codeOwner -> !codeOwners.contains(codeOwner))
+            .limit(limit - codeOwners.size())
+            .collect(toImmutableSet());
+    codeOwners.addAll(codeOwnersToAdd);
+    return codeOwnersToAdd;
   }
 
   /**
@@ -396,7 +542,7 @@
 
       throw new IllegalStateException("unknown account visibility setting: " + accountVisibility);
     } catch (IOException | PermissionBackendException e) {
-      throw new StorageException("failed to get visible users", e);
+      throw new CodeOwnersInternalServerErrorException("failed to get visible users", e);
     }
   }
 
@@ -406,6 +552,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/BUILD b/java/com/google/gerrit/plugins/codeowners/restapi/BUILD
new file mode 100644
index 0000000..298b46b
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/BUILD
@@ -0,0 +1,17 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:plugin.bzl", "PLUGIN_DEPS_NEVERLINK")
+
+java_library(
+    name = "restapi",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = PLUGIN_DEPS_NEVERLINK + [
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/api",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/backend",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/common",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/metrics",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/util",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/validation",
+        "//plugins/code-owners/proto:owners_metadata_java_proto",
+    ],
+)
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwner.java b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwner.java
new file mode 100644
index 0000000..f4e5c0f
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwner.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.restapi;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerCheckInfo;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwner;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigHierarchy;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerResolver;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwners;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnersInternalServerErrorException;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
+import com.google.gerrit.plugins.codeowners.backend.OptionalResultWithMessages;
+import com.google.gerrit.plugins.codeowners.backend.PathCodeOwners;
+import com.google.gerrit.plugins.codeowners.backend.PathCodeOwnersResult;
+import com.google.gerrit.plugins.codeowners.backend.UnresolvedImportFormatter;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.RequiredApproval;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.change.ChangeFinder;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.LabelPermission;
+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.gerrit.server.restapi.account.AccountsCollection;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.kohsuke.args4j.Option;
+
+/**
+ * REST endpoint that checks the code ownership of a user for a path in a branch.
+ *
+ * <p>This REST endpoint handles {@code GET
+ * /projects/<project-name>/branches/<branch-name>/code_owners.check} requests.
+ */
+public class CheckCodeOwner implements RestReadView<BranchResource> {
+  private final CheckCodeOwnerCapability checkCodeOwnerCapability;
+  private final PermissionBackend permissionBackend;
+  private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
+  private final CodeOwnerConfigHierarchy codeOwnerConfigHierarchy;
+  private final PathCodeOwners.Factory pathCodeOwnersFactory;
+  private final Provider<CodeOwnerResolver> codeOwnerResolverProvider;
+  private final CodeOwners codeOwners;
+  private final AccountsCollection accountsCollection;
+  private final UnresolvedImportFormatter unresolvedImportFormatter;
+  private final ChangeFinder changeFinder;
+
+  private String email;
+  private String path;
+  private String change;
+  private ChangeNotes changeNotes;
+  private String user;
+  private IdentifiedUser identifiedUser;
+
+  @Inject
+  public CheckCodeOwner(
+      CheckCodeOwnerCapability checkCodeOwnerCapability,
+      PermissionBackend permissionBackend,
+      CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
+      CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
+      PathCodeOwners.Factory pathCodeOwnersFactory,
+      Provider<CodeOwnerResolver> codeOwnerResolverProvider,
+      CodeOwners codeOwners,
+      AccountsCollection accountsCollection,
+      UnresolvedImportFormatter unresolvedImportFormatter,
+      ChangeFinder changeFinder) {
+    this.checkCodeOwnerCapability = checkCodeOwnerCapability;
+    this.permissionBackend = permissionBackend;
+    this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
+    this.codeOwnerConfigHierarchy = codeOwnerConfigHierarchy;
+    this.pathCodeOwnersFactory = pathCodeOwnersFactory;
+    this.codeOwnerResolverProvider = codeOwnerResolverProvider;
+    this.codeOwners = codeOwners;
+    this.accountsCollection = accountsCollection;
+    this.unresolvedImportFormatter = unresolvedImportFormatter;
+    this.changeFinder = changeFinder;
+  }
+
+  @Option(name = "--email", usage = "email for which the code ownership should be checked")
+  public void setEmail(String email) {
+    this.email = email;
+  }
+
+  @Option(name = "--path", usage = "path for which the code ownership should be checked")
+  public void setPath(String path) {
+    this.path = path;
+  }
+
+  @Option(
+      name = "--change",
+      usage =
+          "change for which permissions should be checked,"
+              + " if not specified change permissions are not checked")
+  public void setChange(String change) {
+    this.change = change;
+  }
+
+  @Option(
+      name = "--user",
+      usage =
+          "user for which the code owner visibility should be checked,"
+              + " if not specified the code owner visibility is not checked")
+  public void setUser(String user) {
+    this.user = user;
+  }
+
+  @Override
+  public Response<CodeOwnerCheckInfo> apply(BranchResource branchResource)
+      throws BadRequestException, AuthException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    permissionBackend.currentUser().check(checkCodeOwnerCapability.getPermission());
+
+    validateInput(branchResource);
+
+    Path absolutePath = JgitPath.of(path).getAsAbsolutePath();
+    List<String> messages = new ArrayList<>();
+    List<Path> codeOwnerConfigFilePaths = new ArrayList<>();
+    AtomicBoolean isCodeOwnershipAssignedToEmail = new AtomicBoolean(false);
+    AtomicBoolean isCodeOwnershipAssignedToAllUsers = new AtomicBoolean(false);
+    AtomicBoolean isDefaultCodeOwner = new AtomicBoolean(false);
+    AtomicBoolean hasRevelantCodeOwnerDefinitions = new AtomicBoolean(false);
+    AtomicBoolean parentCodeOwnersAreIgnored = new AtomicBoolean(false);
+    codeOwnerConfigHierarchy.visit(
+        branchResource.getBranchKey(),
+        ObjectId.fromString(branchResource.getRevision()),
+        absolutePath,
+        codeOwnerConfig -> {
+          messages.add(
+              String.format(
+                  "checking code owner config file %s", codeOwnerConfig.key().format(codeOwners)));
+          OptionalResultWithMessages<PathCodeOwnersResult> pathCodeOwnersResult =
+              pathCodeOwnersFactory
+                  .createWithoutCache(codeOwnerConfig, absolutePath)
+                  .resolveCodeOwnerConfig();
+          messages.addAll(pathCodeOwnersResult.messages());
+          pathCodeOwnersResult
+              .get()
+              .unresolvedImports()
+              .forEach(
+                  unresolvedImport ->
+                      messages.add(unresolvedImportFormatter.format(unresolvedImport)));
+          Optional<CodeOwnerReference> codeOwnerReference =
+              pathCodeOwnersResult.get().getPathCodeOwners().stream()
+                  .filter(cor -> cor.email().equals(email))
+                  .findAny();
+          if (codeOwnerReference.isPresent()
+              && !CodeOwnerResolver.ALL_USERS_WILDCARD.equals(email)) {
+            isCodeOwnershipAssignedToEmail.set(true);
+
+            if (RefNames.isConfigRef(codeOwnerConfig.key().ref())) {
+              messages.add(
+                  String.format(
+                      "found email %s as code owner in default code owner config", email));
+              isDefaultCodeOwner.set(true);
+            } else {
+              Path codeOwnerConfigFilePath = codeOwners.getFilePath(codeOwnerConfig.key());
+              messages.add(
+                  String.format(
+                      "found email %s as code owner in %s", email, codeOwnerConfigFilePath));
+              codeOwnerConfigFilePaths.add(codeOwnerConfigFilePath);
+            }
+          }
+
+          if (pathCodeOwnersResult.get().getPathCodeOwners().stream()
+              .anyMatch(cor -> cor.email().equals(CodeOwnerResolver.ALL_USERS_WILDCARD))) {
+            isCodeOwnershipAssignedToAllUsers.set(true);
+
+            if (RefNames.isConfigRef(codeOwnerConfig.key().ref())) {
+              messages.add(
+                  String.format(
+                      "found email %s as code owner in default code owner config",
+                      CodeOwnerResolver.ALL_USERS_WILDCARD));
+              isDefaultCodeOwner.set(true);
+            } else {
+              Path codeOwnerConfigFilePath = codeOwners.getFilePath(codeOwnerConfig.key());
+              messages.add(
+                  String.format(
+                      "found email %s as code owner in %s",
+                      CodeOwnerResolver.ALL_USERS_WILDCARD, codeOwnerConfigFilePath));
+              if (!codeOwnerConfigFilePaths.contains(codeOwnerConfigFilePath)) {
+                codeOwnerConfigFilePaths.add(codeOwnerConfigFilePath);
+              }
+            }
+          }
+
+          if (codeOwnerResolverProvider
+              .get()
+              .resolvePathCodeOwners(codeOwnerConfig, absolutePath)
+              .hasRevelantCodeOwnerDefinitions()) {
+            hasRevelantCodeOwnerDefinitions.set(true);
+          }
+
+          if (pathCodeOwnersResult.get().ignoreParentCodeOwners()) {
+            messages.add("parent code owners are ignored");
+            parentCodeOwnersAreIgnored.set(true);
+          }
+
+          return !pathCodeOwnersResult.get().ignoreParentCodeOwners();
+        });
+
+    boolean isGlobalCodeOwner = false;
+
+    if (isGlobalCodeOwner(branchResource.getNameKey(), email)) {
+      isGlobalCodeOwner = true;
+      messages.add(String.format("found email %s as global code owner", email));
+      isCodeOwnershipAssignedToEmail.set(true);
+    }
+
+    if (isGlobalCodeOwner(branchResource.getNameKey(), CodeOwnerResolver.ALL_USERS_WILDCARD)) {
+      isGlobalCodeOwner = true;
+      messages.add(
+          String.format(
+              "found email %s as global code owner", CodeOwnerResolver.ALL_USERS_WILDCARD));
+      isCodeOwnershipAssignedToAllUsers.set(true);
+    }
+
+    boolean isResolvable;
+    Boolean canReadRef = null;
+    Boolean canSeeChange = null;
+    Boolean canApproveChange = null;
+    if (email.equals(CodeOwnerResolver.ALL_USERS_WILDCARD)) {
+      isResolvable = true;
+    } else {
+      OptionalResultWithMessages<CodeOwner> isResolvableResult = isResolvable();
+      isResolvable = isResolvableResult.isPresent();
+      messages.addAll(isResolvableResult.messages());
+
+      if (isResolvable) {
+        PermissionBackend.WithUser withUser =
+            permissionBackend.absentUser(isResolvableResult.get().accountId());
+        canReadRef = withUser.ref(branchResource.getBranchKey()).test(RefPermission.READ);
+
+        if (changeNotes != null) {
+          PermissionBackend.ForChange forChange = withUser.change(changeNotes);
+          canSeeChange = forChange.test(ChangePermission.READ);
+          RequiredApproval requiredApproval =
+              codeOwnersPluginConfiguration
+                  .getProjectConfig(branchResource.getNameKey())
+                  .getRequiredApproval();
+          canApproveChange =
+              forChange.test(
+                  new LabelPermission.WithValue(
+                      requiredApproval.labelType(), requiredApproval.value()));
+        }
+      }
+    }
+
+    boolean isFallbackCodeOwner =
+        !isCodeOwnershipAssignedToEmail.get()
+            && !isCodeOwnershipAssignedToAllUsers.get()
+            && !hasRevelantCodeOwnerDefinitions.get()
+            && !parentCodeOwnersAreIgnored.get()
+            && isFallbackCodeOwner(branchResource.getNameKey());
+
+    CodeOwnerCheckInfo codeOwnerCheckInfo = new CodeOwnerCheckInfo();
+    codeOwnerCheckInfo.isCodeOwner =
+        (isCodeOwnershipAssignedToEmail.get()
+                || isCodeOwnershipAssignedToAllUsers.get()
+                || isFallbackCodeOwner)
+            && isResolvable;
+    codeOwnerCheckInfo.isResolvable = isResolvable;
+    codeOwnerCheckInfo.canReadRef = canReadRef;
+    codeOwnerCheckInfo.canSeeChange = canSeeChange;
+    codeOwnerCheckInfo.canApproveChange = canApproveChange;
+    codeOwnerCheckInfo.codeOwnerConfigFilePaths =
+        codeOwnerConfigFilePaths.stream().map(Path::toString).collect(toList());
+    codeOwnerCheckInfo.isFallbackCodeOwner = isFallbackCodeOwner && isResolvable;
+    codeOwnerCheckInfo.isDefaultCodeOwner = isDefaultCodeOwner.get();
+    codeOwnerCheckInfo.isGlobalCodeOwner = isGlobalCodeOwner;
+    codeOwnerCheckInfo.isOwnedByAllUsers = isCodeOwnershipAssignedToAllUsers.get();
+    codeOwnerCheckInfo.debugLogs = messages;
+    return Response.ok(codeOwnerCheckInfo);
+  }
+
+  private void validateInput(BranchResource branchResource)
+      throws BadRequestException, AuthException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (email == null) {
+      throw new BadRequestException("email required");
+    }
+    if (path == null) {
+      throw new BadRequestException("path required");
+    }
+    if (user != null) {
+      try {
+        identifiedUser =
+            accountsCollection
+                .parse(TopLevelResource.INSTANCE, IdString.fromDecoded(user))
+                .getUser();
+      } catch (ResourceNotFoundException e) {
+        throw new BadRequestException(String.format("user %s not found", user), e);
+      }
+    }
+    if (change != null) {
+      Optional<ChangeNotes> changeNotes = changeFinder.findOne(change);
+      if (!changeNotes.isPresent()
+          || !permissionBackend
+              .currentUser()
+              .change(changeNotes.get())
+              .test(ChangePermission.READ)) {
+        throw new BadRequestException(String.format("change %s not found", change));
+      }
+      if (!changeNotes.get().getChange().getDest().equals(branchResource.getBranchKey())) {
+        throw new BadRequestException(
+            "target branch of specified change must match branch from the request URL");
+      }
+      this.changeNotes = changeNotes.get();
+    }
+  }
+
+  private boolean isGlobalCodeOwner(Project.NameKey projectName, String email) {
+    return codeOwnersPluginConfiguration.getProjectConfig(projectName).getGlobalCodeOwners()
+        .stream()
+        .filter(cor -> cor.email().equals(email))
+        .findAny()
+        .isPresent();
+  }
+
+  private boolean isFallbackCodeOwner(Project.NameKey projectName) {
+    FallbackCodeOwners fallbackCodeOwners =
+        codeOwnersPluginConfiguration.getProjectConfig(projectName).getFallbackCodeOwners();
+    switch (fallbackCodeOwners) {
+      case NONE:
+        return false;
+      case PROJECT_OWNERS:
+        return isProjectOwner(projectName);
+      case ALL_USERS:
+        return true;
+    }
+    throw new IllegalStateException(
+        String.format(
+            "unknown value %s for fallbackCodeOwners in project %s",
+            fallbackCodeOwners.name(), projectName));
+  }
+
+  private boolean isProjectOwner(Project.NameKey projectName) {
+    try {
+      AccountResource accountResource =
+          accountsCollection.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(email));
+      // There is no dedicated project owner permission, but project owners are detected by checking
+      // the permission to write the project config. Only project owners can do this.
+      return permissionBackend
+          .absentUser(accountResource.getUser().getAccountId())
+          .project(projectName)
+          .test(ProjectPermission.WRITE_CONFIG);
+    } catch (PermissionBackendException
+        | ResourceNotFoundException
+        | AuthException
+        | IOException
+        | ConfigInvalidException e) {
+      throw new CodeOwnersInternalServerErrorException(
+          String.format("failed if email %s is owner of project %s", email, projectName.get()), e);
+    }
+  }
+
+  private OptionalResultWithMessages<CodeOwner> isResolvable() {
+    CodeOwnerResolver codeOwnerResolver = codeOwnerResolverProvider.get();
+    if (identifiedUser != null) {
+      codeOwnerResolver.forUser(identifiedUser);
+    } else {
+      codeOwnerResolver.enforceVisibility(false);
+    }
+    OptionalResultWithMessages<CodeOwner> resolveResult =
+        codeOwnerResolver.resolveWithMessages(CodeOwnerReference.create(email));
+
+    List<String> messages = new ArrayList<>();
+    messages.add(String.format("trying to resolve email %s", email));
+    messages.addAll(resolveResult.messages());
+    if (resolveResult.isPresent()) {
+      return OptionalResultWithMessages.create(resolveResult.get(), messages);
+    }
+    return OptionalResultWithMessages.createEmpty(messages);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerCapability.java b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerCapability.java
new file mode 100644
index 0000000..a127815
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerCapability.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.restapi;
+
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.api.access.PluginPermission;
+import com.google.gerrit.extensions.config.CapabilityDefinition;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/** Global capability that allows a user to call the {@link CheckCodeOwner} REST endpoint. */
+@Singleton
+public class CheckCodeOwnerCapability extends CapabilityDefinition {
+  public static final String ID = "checkCodeOwner";
+
+  private final String pluginName;
+
+  @Inject
+  CheckCodeOwnerCapability(@PluginName String pluginName) {
+    this.pluginName = pluginName;
+  }
+
+  @Override
+  public String getDescription() {
+    return "Check Code Owner";
+  }
+
+  public PluginPermission getPermission() {
+    return new PluginPermission(pluginName, ID);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFiles.java b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFiles.java
index 144f0e5..1eaaed6 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFiles.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFiles.java
@@ -38,9 +38,10 @@
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigScanner;
-import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.plugins.codeowners.validation.CodeOwnerConfigValidator;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -54,6 +55,8 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
  * REST endpoint that checks/validates the code owner config files in a project.
@@ -72,6 +75,7 @@
 
   private final Provider<CurrentUser> currentUser;
   private final PermissionBackend permissionBackend;
+  private final GitRepositoryManager repoManager;
   private final Provider<ListBranches> listBranches;
   private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
   private final CodeOwnerConfigScanner.Factory codeOwnerConfigScannerFactory;
@@ -81,12 +85,14 @@
   public CheckCodeOwnerConfigFiles(
       Provider<CurrentUser> currentUser,
       PermissionBackend permissionBackend,
+      GitRepositoryManager repoManager,
       Provider<ListBranches> listBranches,
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       CodeOwnerConfigScanner.Factory codeOwnerConfigScannerFactory,
       CodeOwnerConfigValidator codeOwnerConfigValidator) {
     this.currentUser = currentUser;
     this.permissionBackend = permissionBackend;
+    this.repoManager = repoManager;
     this.listBranches = listBranches;
     this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
     this.codeOwnerConfigScannerFactory = codeOwnerConfigScannerFactory;
@@ -120,20 +126,25 @@
 
     validateInput(projectResource.getNameKey(), branches, input);
 
-    ImmutableMap.Builder<String, Map<String, List<ConsistencyProblemInfo>>> resultsByBranchBuilder =
-        ImmutableMap.builder();
-    branches.stream()
-        .filter(branchNameKey -> shouldValidateBranch(input, branchNameKey))
-        .filter(
-            branchNameKey ->
-                validateDisabledBranches(input)
-                    || !codeOwnersPluginConfiguration.isDisabled(branchNameKey))
-        .forEach(
-            branchNameKey ->
-                resultsByBranchBuilder.put(
-                    branchNameKey.branch(),
-                    checkBranch(input.path, branchNameKey, input.verbosity)));
-    return Response.ok(resultsByBranchBuilder.build());
+    try (Repository repo = repoManager.openRepository(projectResource.getNameKey());
+        RevWalk revWalk = new RevWalk(repo)) {
+      ImmutableMap.Builder<String, Map<String, List<ConsistencyProblemInfo>>>
+          resultsByBranchBuilder = ImmutableMap.builder();
+      branches.stream()
+          .filter(branchNameKey -> shouldValidateBranch(input, branchNameKey))
+          .filter(
+              branchNameKey ->
+                  validateDisabledBranches(input)
+                      || !codeOwnersPluginConfiguration
+                          .getProjectConfig(branchNameKey.project())
+                          .isDisabled(branchNameKey.branch()))
+          .forEach(
+              branchNameKey ->
+                  resultsByBranchBuilder.put(
+                      branchNameKey.branch(),
+                      checkBranch(revWalk, input.path, branchNameKey, input.verbosity)));
+      return Response.ok(resultsByBranchBuilder.build());
+    }
   }
 
   private ImmutableSet<BranchNameKey> branches(ProjectResource projectResource)
@@ -145,11 +156,15 @@
   }
 
   private Map<String, List<ConsistencyProblemInfo>> checkBranch(
+      RevWalk revWalk,
       String pathGlob,
       BranchNameKey branchNameKey,
       @Nullable ConsistencyProblemInfo.Status verbosity) {
     ListMultimap<String, ConsistencyProblemInfo> problemsByPath = LinkedListMultimap.create();
-    CodeOwnerBackend codeOwnerBackend = codeOwnersPluginConfiguration.getBackend(branchNameKey);
+    CodeOwnerBackend codeOwnerBackend =
+        codeOwnersPluginConfiguration
+            .getProjectConfig(branchNameKey.project())
+            .getBackend(branchNameKey.branch());
     codeOwnerConfigScannerFactory
         .create()
         // Do not check the default code owner config file in refs/meta/config, as this config is
@@ -161,7 +176,8 @@
             codeOwnerConfig -> {
               problemsByPath.putAll(
                   codeOwnerBackend.getFilePath(codeOwnerConfig.key()).toString(),
-                  checkCodeOwnerConfig(codeOwnerBackend, codeOwnerConfig, verbosity));
+                  checkCodeOwnerConfig(
+                      branchNameKey, revWalk, codeOwnerBackend, codeOwnerConfig, verbosity));
               return true;
             },
             (codeOwnerConfigFilePath, configInvalidException) -> {
@@ -176,12 +192,18 @@
   }
 
   private ImmutableList<ConsistencyProblemInfo> checkCodeOwnerConfig(
+      BranchNameKey branchNameKey,
+      RevWalk revWalk,
       CodeOwnerBackend codeOwnerBackend,
       CodeOwnerConfig codeOwnerConfig,
       @Nullable ConsistencyProblemInfo.Status verbosity) {
     return codeOwnerConfigValidator
         .validateCodeOwnerConfig(
-            currentUser.get().asIdentifiedUser(), codeOwnerBackend, codeOwnerConfig)
+            branchNameKey,
+            revWalk,
+            currentUser.get().asIdentifiedUser(),
+            codeOwnerBackend,
+            codeOwnerConfig)
         .map(
             commitValidationMessage ->
                 createConsistencyProblemInfo(commitValidationMessage, verbosity))
@@ -239,7 +261,7 @@
         }
 
         if ((input.validateDisabledBranches == null || !input.validateDisabledBranches)
-            && codeOwnersPluginConfiguration.isDisabled(branchNameKey)) {
+            && codeOwnersPluginConfiguration.getProjectConfig(projectName).isDisabled(branchName)) {
           throw new BadRequestException(
               String.format(
                   "code owners functionality for branch %s is disabled,"
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFilesInRevision.java b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFilesInRevision.java
index b3e2494..db939c4 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFilesInRevision.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFilesInRevision.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.plugins.codeowners.api.CheckCodeOwnerConfigFilesInRevisionInput;
 import com.google.gerrit.plugins.codeowners.backend.ChangedFiles;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
-import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.plugins.codeowners.validation.CodeOwnerConfigValidator;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.RevisionResource;
@@ -84,7 +84,9 @@
         input.path);
 
     CodeOwnerBackend codeOwnerBackend =
-        codeOwnersPluginConfiguration.getBackend(revisionResource.getChange().getDest());
+        codeOwnersPluginConfiguration
+            .getProjectConfig(revisionResource.getProject())
+            .getBackend(revisionResource.getChange().getDest().branch());
 
     IdentifiedUser uploader = genericUserFactory.create(revisionResource.getPatchSet().uploader());
     logger.atFine().log("uploader = %s", uploader.getLoggableName());
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJson.java b/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJson.java
index bf3a8ad..7caf0a1 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJson.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJson.java
@@ -33,9 +33,9 @@
 import com.google.gerrit.plugins.codeowners.api.GeneralInfo;
 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.config.CodeOwnersPluginConfiguration;
-import com.google.gerrit.plugins.codeowners.config.RequiredApproval;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginProjectConfigSnapshot;
+import com.google.gerrit.plugins.codeowners.backend.config.RequiredApproval;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.BranchResource;
 import com.google.gerrit.server.project.ProjectResource;
@@ -51,16 +51,13 @@
 @Singleton
 public class CodeOwnerProjectConfigJson {
   private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
-  private final CodeOwnerConfigScanner.Factory codeOwnerConfigScannerFactory;
   private final Provider<ListBranches> listBranches;
 
   @Inject
   CodeOwnerProjectConfigJson(
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
-      CodeOwnerConfigScanner.Factory codeOwnerConfigScannerFactory,
       Provider<ListBranches> listBranches) {
     this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
-    this.codeOwnerConfigScannerFactory = codeOwnerConfigScannerFactory;
     this.listBranches = listBranches;
   }
 
@@ -69,7 +66,7 @@
     CodeOwnerProjectConfigInfo info = new CodeOwnerProjectConfigInfo();
     info.status = formatStatusInfo(projectResource);
 
-    if (codeOwnersPluginConfiguration.isDisabled(projectResource.getNameKey())) {
+    if (codeOwnersPluginConfiguration.getProjectConfig(projectResource.getNameKey()).isDisabled()) {
       return info;
     }
 
@@ -82,9 +79,12 @@
   }
 
   CodeOwnerBranchConfigInfo format(BranchResource branchResource) {
+    CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig =
+        codeOwnersPluginConfiguration.getProjectConfig(branchResource.getNameKey());
+
     CodeOwnerBranchConfigInfo info = new CodeOwnerBranchConfigInfo();
 
-    boolean disabled = codeOwnersPluginConfiguration.isDisabled(branchResource.getBranchKey());
+    boolean disabled = codeOwnersConfig.isDisabled(branchResource.getBranchKey().branch());
     info.disabled = disabled ? disabled : null;
 
     if (disabled) {
@@ -94,31 +94,25 @@
     info.general = formatGeneralInfo(branchResource.getNameKey());
     info.backendId =
         CodeOwnerBackendId.getBackendId(
-            codeOwnersPluginConfiguration.getBackend(branchResource.getBranchKey()).getClass());
+            codeOwnersConfig.getBackend(branchResource.getBranchKey().branch()).getClass());
     info.requiredApproval = formatRequiredApprovalInfo(branchResource.getNameKey());
     info.overrideApproval = formatOverrideApprovalInfo(branchResource.getNameKey());
 
-    boolean noCodeOwnersDefined =
-        !codeOwnerConfigScannerFactory
-            .create()
-            .containsAnyCodeOwnerConfigFile(branchResource.getBranchKey());
-    info.noCodeOwnersDefined = noCodeOwnersDefined ? noCodeOwnersDefined : null;
-
     return info;
   }
 
   private GeneralInfo formatGeneralInfo(Project.NameKey projectName) {
+    CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig =
+        codeOwnersPluginConfiguration.getProjectConfig(projectName);
+
     GeneralInfo generalInfo = new GeneralInfo();
-    generalInfo.fileExtension =
-        codeOwnersPluginConfiguration.getFileExtension(projectName).orElse(null);
-    generalInfo.mergeCommitStrategy =
-        codeOwnersPluginConfiguration.getMergeCommitStrategy(projectName);
-    generalInfo.implicitApprovals =
-        codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(projectName) ? true : null;
-    generalInfo.overrideInfoUrl =
-        codeOwnersPluginConfiguration.getOverrideInfoUrl(projectName).orElse(null);
-    generalInfo.fallbackCodeOwners =
-        codeOwnersPluginConfiguration.getFallbackCodeOwners(projectName);
+    generalInfo.fileExtension = codeOwnersConfig.getFileExtension().orElse(null);
+    generalInfo.mergeCommitStrategy = codeOwnersConfig.getMergeCommitStrategy();
+    generalInfo.implicitApprovals = codeOwnersConfig.areImplicitApprovalsEnabled() ? true : null;
+    generalInfo.overrideInfoUrl = codeOwnersConfig.getOverrideInfoUrl().orElse(null);
+    generalInfo.invalidCodeOwnerConfigInfoUrl =
+        codeOwnersConfig.getInvalidCodeOwnerConfigInfoUrl().orElse(null);
+    generalInfo.fallbackCodeOwners = codeOwnersConfig.getFallbackCodeOwners();
     return generalInfo;
   }
 
@@ -127,7 +121,9 @@
       throws RestApiException, PermissionBackendException, IOException {
     CodeOwnersStatusInfo info = new CodeOwnersStatusInfo();
     info.disabled =
-        codeOwnersPluginConfiguration.isDisabled(projectResource.getNameKey()) ? true : null;
+        codeOwnersPluginConfiguration.getProjectConfig(projectResource.getNameKey()).isDisabled()
+            ? true
+            : null;
 
     if (info.disabled == null) {
       ImmutableList<BranchNameKey> disabledBranches = getDisabledBranches(projectResource);
@@ -145,7 +141,10 @@
     BackendInfo info = new BackendInfo();
     info.id =
         CodeOwnerBackendId.getBackendId(
-            codeOwnersPluginConfiguration.getBackend(projectResource.getNameKey()).getClass());
+            codeOwnersPluginConfiguration
+                .getProjectConfig(projectResource.getNameKey())
+                .getBackend()
+                .getClass());
 
     ImmutableMap<String, String> idsByBranch =
         getBackendIdsPerBranch(projectResource).entrySet().stream()
@@ -157,14 +156,15 @@
   }
 
   private RequiredApprovalInfo formatRequiredApprovalInfo(Project.NameKey projectName) {
-    return formatRequiredApproval(codeOwnersPluginConfiguration.getRequiredApproval(projectName));
+    return formatRequiredApproval(
+        codeOwnersPluginConfiguration.getProjectConfig(projectName).getRequiredApproval());
   }
 
   @VisibleForTesting
   @Nullable
   ImmutableList<RequiredApprovalInfo> formatOverrideApprovalInfo(Project.NameKey projectName) {
     ImmutableList<RequiredApprovalInfo> overrideApprovalInfos =
-        codeOwnersPluginConfiguration.getOverrideApproval(projectName).stream()
+        codeOwnersPluginConfiguration.getProjectConfig(projectName).getOverrideApprovals().stream()
             .sorted(comparing(requiredApproval -> requiredApproval.toString()))
             .map(CodeOwnerProjectConfigJson::formatRequiredApproval)
             .collect(toImmutableList());
@@ -184,7 +184,11 @@
   private ImmutableList<BranchNameKey> getDisabledBranches(ProjectResource projectResource)
       throws RestApiException, PermissionBackendException, IOException {
     return branches(projectResource)
-        .filter(codeOwnersPluginConfiguration::isDisabled)
+        .filter(
+            branchNameKey ->
+                codeOwnersPluginConfiguration
+                    .getProjectConfig(branchNameKey.project())
+                    .isDisabled(branchNameKey.branch()))
         .collect(toImmutableList());
   }
 
@@ -197,7 +201,10 @@
                 Function.identity(),
                 branchNameKey ->
                     CodeOwnerBackendId.getBackendId(
-                        codeOwnersPluginConfiguration.getBackend(branchNameKey).getClass())));
+                        codeOwnersPluginConfiguration
+                            .getProjectConfig(branchNameKey.project())
+                            .getBackend(branchNameKey.branch())
+                            .getClass())));
   }
 
   private Stream<BranchNameKey> branches(ProjectResource projectResource)
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerStatusInfoJson.java b/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerStatusInfoJson.java
index 284990b..bba3dbe 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerStatusInfoJson.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerStatusInfoJson.java
@@ -24,12 +24,12 @@
 import com.google.common.collect.Maps;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.ChangeType;
-import com.google.gerrit.plugins.codeowners.JgitPath;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatusInfo;
 import com.google.gerrit.plugins.codeowners.api.FileCodeOwnerStatusInfo;
 import com.google.gerrit.plugins.codeowners.api.PathCodeOwnerStatusInfo;
 import com.google.gerrit.plugins.codeowners.backend.FileCodeOwnerStatus;
 import com.google.gerrit.plugins.codeowners.backend.PathCodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import java.util.Comparator;
 import org.eclipse.jgit.diff.DiffEntry;
 
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerConfigFiles.java b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerConfigFiles.java
index 9ec7118..54d305a 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerConfigFiles.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerConfigFiles.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigScanner;
-import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.server.project.BranchResource;
 import com.google.inject.Inject;
 import java.nio.file.Path;
@@ -86,11 +86,13 @@
   }
 
   @Override
-  public Response<List<String>> apply(BranchResource resource) throws BadRequestException {
+  public Response<List<String>> apply(BranchResource branchResource) throws BadRequestException {
     validateOptions();
 
     CodeOwnerBackend codeOwnerBackend =
-        codeOwnersPluginConfiguration.getBackend(resource.getBranchKey());
+        codeOwnersPluginConfiguration
+            .getProjectConfig(branchResource.getNameKey())
+            .getBackend(branchResource.getBranchKey().branch());
     ImmutableList.Builder<Path> codeOwnerConfigs = ImmutableList.builder();
 
     if (email != null) {
@@ -106,7 +108,7 @@
         // files in refs/meta/config explicitly.
         .includeDefaultCodeOwnerConfig(false)
         .visit(
-            resource.getBranchKey(),
+            branchResource.getBranchKey(),
             codeOwnerConfig -> {
               Path codeOwnerConfigPath = codeOwnerBackend.getFilePath(codeOwnerConfig.key());
               if (email == null || containsEmail(codeOwnerConfig, codeOwnerConfigPath, email)) {
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerConfigForPathInBranch.java b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerConfigForPathInBranch.java
index 801537d..ac049d2 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerConfigForPathInBranch.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerConfigForPathInBranch.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerConfigInfo;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwners;
-import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.plugins.codeowners.restapi.CodeOwnerConfigsInBranchCollection.PathResource;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -54,7 +54,7 @@
   @Override
   public Response<CodeOwnerConfigInfo> apply(PathResource rsrc)
       throws MethodNotAllowedException, IOException {
-    codeOwnersPluginConfiguration.checkExperimentalRestEndpointsEnabled();
+    codeOwnersPluginConfiguration.getGlobalConfig().checkExperimentalRestEndpointsEnabled();
 
     Optional<CodeOwnerConfig> codeOwnerConfig =
         codeOwners.get(rsrc.getCodeOwnerConfigKey(), rsrc.getRevision());
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerStatus.java b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerStatus.java
index 005f3e7..1952965 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerStatus.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerStatus.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
 import com.google.common.collect.ImmutableSet;
+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;
@@ -24,11 +25,12 @@
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerApprovalCheck;
 import com.google.gerrit.plugins.codeowners.backend.FileCodeOwnerStatus;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
-import com.google.inject.Singleton;
 import java.io.IOException;
+import org.kohsuke.args4j.Option;
 
 /**
  * REST endpoint that gets the code owner statuses for the files in a change.
@@ -50,10 +52,32 @@
  *       so that this approval can never happen)
  * </ul>
  */
-@Singleton
 public class GetCodeOwnerStatus implements RestReadView<ChangeResource> {
+  private static final int UNLIMITED = 0;
+
   private final CodeOwnerApprovalCheck codeOwnerApprovalCheck;
 
+  private int start;
+  private int limit;
+
+  @Option(
+      name = "--limit",
+      aliases = {"-n"},
+      metaVar = "CNT",
+      usage = "maximum number of file code owner statuses to return (by default 0 aka unlimited)")
+  public void setLimit(int limit) {
+    this.limit = limit;
+  }
+
+  @Option(
+      name = "--start",
+      aliases = {"-S"},
+      metaVar = "CNT",
+      usage = "number of owned paths to skip")
+  public void setStart(int start) {
+    this.start = start;
+  }
+
   @Inject
   public GetCodeOwnerStatus(CodeOwnerApprovalCheck codeOwnerApprovalCheck) {
     this.codeOwnerApprovalCheck = codeOwnerApprovalCheck;
@@ -62,11 +86,29 @@
   @Override
   public Response<CodeOwnerStatusInfo> apply(ChangeResource changeResource)
       throws RestApiException, IOException, PermissionBackendException,
-          PatchListNotAvailableException {
+          PatchListNotAvailableException, DiffNotAvailableException {
+    validateStartAndLimit();
+
     ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(changeResource.getNotes()).collect(toImmutableSet());
-    return Response.ok(
+        codeOwnerApprovalCheck.getFileStatusesAsSet(
+            changeResource.getNotes(), start, limit == UNLIMITED ? UNLIMITED : limit + 1);
+    CodeOwnerStatusInfo codeOwnerStatusInfo =
         CodeOwnerStatusInfoJson.format(
-            changeResource.getNotes().getCurrentPatchSet().id(), fileCodeOwnerStatuses));
+            changeResource.getNotes().getCurrentPatchSet().id(),
+            limit == UNLIMITED
+                ? fileCodeOwnerStatuses
+                : fileCodeOwnerStatuses.stream().limit(limit).collect(toImmutableSet()));
+    codeOwnerStatusInfo.more =
+        limit != UNLIMITED && fileCodeOwnerStatuses.size() > limit ? true : null;
+    return Response.ok(codeOwnerStatusInfo);
+  }
+
+  private void validateStartAndLimit() throws BadRequestException {
+    if (start < 0) {
+      throw new BadRequestException("start cannot be negative");
+    }
+    if (limit < 0) {
+      throw new BadRequestException("limit cannot be negative");
+    }
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInBranch.java b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInBranch.java
index 3fab537..446b91e 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInBranch.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInBranch.java
@@ -22,10 +22,11 @@
 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.plugins.codeowners.api.CodeOwnerInfo;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnersInfo;
 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.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.change.IncludedInResolver;
@@ -35,7 +36,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
-import java.util.List;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -75,6 +75,8 @@
       Accounts accounts,
       AccountControl.Factory accountControlFactory,
       PermissionBackend permissionBackend,
+      CheckCodeOwnerCapability checkCodeOwnerCapability,
+      CodeOwnerMetrics codeOwnerMetrics,
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
       Provider<CodeOwnerResolver> codeOwnerResolver,
@@ -85,6 +87,8 @@
         accounts,
         accountControlFactory,
         permissionBackend,
+        checkCodeOwnerCapability,
+        codeOwnerMetrics,
         codeOwnersPluginConfiguration,
         codeOwnerConfigHierarchy,
         codeOwnerResolver,
@@ -93,7 +97,7 @@
   }
 
   @Override
-  public Response<List<CodeOwnerInfo>> apply(CodeOwnersInBranchCollection.PathResource rsrc)
+  public Response<CodeOwnersInfo> apply(CodeOwnersInBranchCollection.PathResource rsrc)
       throws RestApiException, PermissionBackendException, IOException {
     if (revision != null) {
       validateRevision(rsrc.getBranch(), revision);
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInChange.java b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInChange.java
index c0465f3..e2aac82 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInChange.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnersForPathInChange.java
@@ -14,23 +14,32 @@
 
 package com.google.gerrit.plugins.codeowners.restapi;
 
+import static com.google.gerrit.plugins.codeowners.backend.CodeOwnerScore.IS_REVIEWER_SCORING_VALUE;
+import static com.google.gerrit.plugins.codeowners.backend.CodeOwnerScore.NO_REVIEWER_SCORING_VALUE;
+
+import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
 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.plugins.codeowners.api.CodeOwnerInfo;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnersInfo;
 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;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerScore;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerScoring;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
 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.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import java.util.List;
+import java.util.Optional;
 import java.util.function.Predicate;
 import java.util.stream.Stream;
 
@@ -54,6 +63,8 @@
       Accounts accounts,
       AccountControl.Factory accountControlFactory,
       PermissionBackend permissionBackend,
+      CheckCodeOwnerCapability checkCodeOwnerCapability,
+      CodeOwnerMetrics codeOwnerMetrics,
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
       Provider<CodeOwnerResolver> codeOwnerResolver,
@@ -64,6 +75,8 @@
         accounts,
         accountControlFactory,
         permissionBackend,
+        checkCodeOwnerCapability,
+        codeOwnerMetrics,
         codeOwnersPluginConfiguration,
         codeOwnerConfigHierarchy,
         codeOwnerResolver,
@@ -72,12 +85,43 @@
   }
 
   @Override
-  public Response<List<CodeOwnerInfo>> apply(CodeOwnersInChangeCollection.PathResource rsrc)
+  public Response<CodeOwnersInfo> apply(CodeOwnersInChangeCollection.PathResource rsrc)
       throws RestApiException, PermissionBackendException {
     return super.applyImpl(rsrc);
   }
 
   @Override
+  protected Optional<Long> getDefaultSeed(CodeOwnersInChangeCollection.PathResource rsrc) {
+    // use the change number as seed so that the sort order for a change is always stable
+    return Optional.of(Long.valueOf(rsrc.getRevisionResource().getChange().getId().get()));
+  }
+
+  /**
+   * This method is overridden to add scorings for the {@link CodeOwnerScore#IS_REVIEWER} score that
+   * only applies if code owners are suggested on changes.
+   */
+  @Override
+  protected ImmutableSet<CodeOwnerScoring> getCodeOwnerScorings(
+      CodeOwnersInChangeCollection.PathResource rsrc, ImmutableSet<CodeOwner> codeOwners) {
+    // Add scorings for IS_REVIEWER score.
+    ImmutableSet<Account.Id> reviewers =
+        rsrc.getRevisionResource()
+            .getNotes()
+            .getReviewers()
+            .byState(ReviewerStateInternal.REVIEWER);
+    CodeOwnerScoring.Builder isReviewerScoring = CodeOwnerScore.IS_REVIEWER.createScoring();
+    codeOwners.forEach(
+        codeOwner ->
+            isReviewerScoring.putValueForCodeOwner(
+                codeOwner,
+                reviewers.contains(codeOwner.accountId())
+                    ? IS_REVIEWER_SCORING_VALUE
+                    : NO_REVIEWER_SCORING_VALUE));
+
+    return ImmutableSet.of(isReviewerScoring.build());
+  }
+
+  @Override
   protected Stream<CodeOwner> filterCodeOwners(
       CodeOwnersInChangeCollection.PathResource rsrc, Stream<CodeOwner> codeOwners) {
     return codeOwners.filter(filterOutChangeOwner(rsrc)).filter(filterOutServiceUsers());
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/GetOwnedPaths.java b/java/com/google/gerrit/plugins/codeowners/restapi/GetOwnedPaths.java
new file mode 100644
index 0000000..1f2298b
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/GetOwnedPaths.java
@@ -0,0 +1,125 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.restapi;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.plugins.codeowners.api.OwnedPathsInfo;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerApprovalCheck;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.nio.file.Path;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.kohsuke.args4j.Option;
+
+/**
+ * REST endpoint that lists the files of a revision that are owned by a specified user.
+ *
+ * <p>This REST endpoint handles {@code GET
+ * /changes/<change-id>/revisions/<revision-id>/owned_paths} requests.
+ */
+public class GetOwnedPaths implements RestReadView<RevisionResource> {
+  @VisibleForTesting public static final int DEFAULT_LIMIT = 50;
+
+  private final AccountResolver accountResolver;
+  private final CodeOwnerApprovalCheck codeOwnerApprovalCheck;
+
+  private int start;
+  private int limit = DEFAULT_LIMIT;
+  private String user;
+
+  @Option(
+      name = "--limit",
+      aliases = {"-n"},
+      metaVar = "CNT",
+      usage = "maximum number of owned path to return (default = " + DEFAULT_LIMIT + ")")
+  public void setLimit(int limit) {
+    this.limit = limit;
+  }
+
+  @Option(
+      name = "--start",
+      aliases = {"-S"},
+      metaVar = "CNT",
+      usage = "number of owned paths to skip")
+  public void setStart(int start) {
+    this.start = start;
+  }
+
+  @Option(name = "--user", usage = "user for which the owned paths should be returned")
+  public void setUser(String user) {
+    this.user = user;
+  }
+
+  @Inject
+  public GetOwnedPaths(
+      AccountResolver accountResolver, CodeOwnerApprovalCheck codeOwnerApprovalCheck) {
+    this.accountResolver = accountResolver;
+    this.codeOwnerApprovalCheck = codeOwnerApprovalCheck;
+  }
+
+  @Override
+  public Response<OwnedPathsInfo> apply(RevisionResource revisionResource)
+      throws BadRequestException, ResourceConflictException, UnresolvableAccountException,
+          ConfigInvalidException, IOException {
+    validateStartAndLimit();
+
+    Account.Id accountId = resolveAccount();
+
+    ImmutableList<Path> ownedPaths =
+        codeOwnerApprovalCheck.getOwnedPaths(
+            revisionResource.getNotes(),
+            revisionResource.getPatchSet(),
+            accountId,
+            start,
+            limit + 1);
+
+    OwnedPathsInfo ownedPathsInfo = new OwnedPathsInfo();
+    ownedPathsInfo.more = ownedPaths.size() > limit ? true : null;
+    ownedPathsInfo.ownedPaths =
+        ownedPaths.stream().limit(limit).map(Path::toString).collect(toImmutableList());
+    return Response.ok(ownedPathsInfo);
+  }
+
+  private Account.Id resolveAccount()
+      throws BadRequestException, UnresolvableAccountException, ConfigInvalidException,
+          IOException {
+    if (Strings.isNullOrEmpty(user)) {
+      throw new BadRequestException("--user required");
+    }
+
+    return accountResolver.resolve(user).asUnique().account().id();
+  }
+
+  private void validateStartAndLimit() throws BadRequestException {
+    if (start < 0) {
+      throw new BadRequestException("start cannot be negative");
+    }
+    if (limit <= 0) {
+      throw new BadRequestException("limit must be positive");
+    }
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/PutCodeOwnerProjectConfig.java b/java/com/google/gerrit/plugins/codeowners/restapi/PutCodeOwnerProjectConfig.java
new file mode 100644
index 0000000..96b467e
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/PutCodeOwnerProjectConfig.java
@@ -0,0 +1,289 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.restapi;
+
+import static com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_IMPLICIT_APPROVALS;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_VALIDATION_ON_SUBMIT;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_EXEMPTED_USER;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_EXEMPT_PURE_REVERTS;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_FALLBACK_CODE_OWNERS;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_FILE_EXTENSION;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_GLOBAL_CODE_OWNER;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_INVALID_CODE_OWNER_CONFIG_INFO_URL;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_MAX_PATHS_IN_CHANGE_MESSAGES;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_MERGE_COMMIT_STRATEGY;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_OVERRIDE_INFO_URL;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_READ_ONLY;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_REJECT_NON_RESOLVABLE_IMPORTS;
+import static com.google.gerrit.plugins.codeowners.backend.config.OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL;
+import static com.google.gerrit.plugins.codeowners.backend.config.RequiredApprovalConfig.KEY_REQUIRED_APPROVAL;
+import static com.google.gerrit.plugins.codeowners.backend.config.StatusConfig.KEY_DISABLED;
+import static com.google.gerrit.plugins.codeowners.backend.config.StatusConfig.KEY_DISABLED_BRANCH;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.annotations.PluginName;
+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.RestModifyView;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerProjectConfigInfo;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerProjectConfigInput;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfigValidator;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersProjectConfigFile;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+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.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * REST endpoint that updates the code owner project configuration.
+ *
+ * <p>This REST endpoint handles {@code PUT /projects/<project-name>/code_owners.project_config}
+ * requests.
+ */
+@Singleton
+public class PutCodeOwnerProjectConfig
+    implements RestModifyView<ProjectResource, CodeOwnerProjectConfigInput> {
+  private final String pluginName;
+  private final PermissionBackend permissionBackend;
+  private final GitRepositoryManager repoManager;
+  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
+  private final ProjectCache projectCache;
+  private final CodeOwnersPluginConfigValidator codeOwnersPluginConfigValidator;
+  private final CodeOwnerProjectConfigJson codeOwnerProjectConfigJson;
+
+  @Inject
+  public PutCodeOwnerProjectConfig(
+      @PluginName String pluginName,
+      PermissionBackend permissionBackend,
+      GitRepositoryManager repoManager,
+      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
+      ProjectCache projectCache,
+      CodeOwnersPluginConfigValidator codeOwnersPluginConfigValidator,
+      CodeOwnerProjectConfigJson codeOwnerProjectConfigJson) {
+    this.pluginName = pluginName;
+    this.permissionBackend = permissionBackend;
+    this.repoManager = repoManager;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.projectCache = projectCache;
+    this.codeOwnersPluginConfigValidator = codeOwnersPluginConfigValidator;
+    this.codeOwnerProjectConfigJson = codeOwnerProjectConfigJson;
+  }
+
+  @Override
+  public Response<CodeOwnerProjectConfigInfo> apply(
+      ProjectResource projectResource, CodeOwnerProjectConfigInput input)
+      throws RestApiException, PermissionBackendException, IOException, ConfigInvalidException {
+    // This REST endpoint requires the caller to be a project owner.
+    permissionBackend
+        .currentUser()
+        .project(projectResource.getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
+
+    try (Repository repo = repoManager.openRepository(projectResource.getNameKey());
+        MetaDataUpdate metaDataUpdate =
+            metaDataUpdateFactory.get().create(projectResource.getNameKey())) {
+      metaDataUpdate.setMessage(String.format("Update %s configuration", pluginName));
+
+      CodeOwnersProjectConfigFile codeOwnersProjectConfigFile = new CodeOwnersProjectConfigFile();
+      codeOwnersProjectConfigFile.load(projectResource.getNameKey(), repo);
+      Config codeOwnersConfig = codeOwnersProjectConfigFile.getConfig();
+
+      if (input.disabled != null) {
+        codeOwnersConfig.setBoolean(
+            SECTION_CODE_OWNERS, /* subsection= */ null, KEY_DISABLED, input.disabled);
+      }
+
+      if (input.disabledBranches != null) {
+        codeOwnersConfig.setStringList(
+            SECTION_CODE_OWNERS,
+            /* subsection= */ null,
+            KEY_DISABLED_BRANCH,
+            input.disabledBranches);
+      }
+
+      if (input.fileExtension != null) {
+        codeOwnersConfig.setString(
+            SECTION_CODE_OWNERS, /* subsection= */ null, KEY_FILE_EXTENSION, input.fileExtension);
+      }
+
+      if (input.requiredApproval != null) {
+        if (input.requiredApproval.isEmpty()) {
+          codeOwnersConfig.unset(
+              SECTION_CODE_OWNERS, /* subsection= */ null, KEY_REQUIRED_APPROVAL);
+        } else {
+          codeOwnersConfig.setString(
+              SECTION_CODE_OWNERS,
+              /* subsection= */ null,
+              KEY_REQUIRED_APPROVAL,
+              input.requiredApproval);
+        }
+      }
+
+      if (input.overrideApprovals != null) {
+        codeOwnersConfig.setStringList(
+            SECTION_CODE_OWNERS,
+            /* subsection= */ null,
+            KEY_OVERRIDE_APPROVAL,
+            input.overrideApprovals);
+      }
+
+      if (input.fallbackCodeOwners != null) {
+        codeOwnersConfig.setEnum(
+            SECTION_CODE_OWNERS,
+            /* subsection= */ null,
+            KEY_FALLBACK_CODE_OWNERS,
+            input.fallbackCodeOwners);
+      }
+
+      if (input.globalCodeOwners != null) {
+        codeOwnersConfig.setStringList(
+            SECTION_CODE_OWNERS,
+            /* subsection= */ null,
+            KEY_GLOBAL_CODE_OWNER,
+            input.globalCodeOwners);
+      }
+
+      if (input.exemptedUsers != null) {
+        codeOwnersConfig.setStringList(
+            SECTION_CODE_OWNERS, /* subsection= */ null, KEY_EXEMPTED_USER, input.exemptedUsers);
+      }
+
+      if (input.mergeCommitStrategy != null) {
+        codeOwnersConfig.setEnum(
+            SECTION_CODE_OWNERS,
+            /* subsection= */ null,
+            KEY_MERGE_COMMIT_STRATEGY,
+            input.mergeCommitStrategy);
+      }
+
+      if (input.implicitApprovals != null) {
+        codeOwnersConfig.setBoolean(
+            SECTION_CODE_OWNERS,
+            /* subsection= */ null,
+            KEY_ENABLE_IMPLICIT_APPROVALS,
+            input.implicitApprovals);
+      }
+
+      if (input.overrideInfoUrl != null) {
+        codeOwnersConfig.setString(
+            SECTION_CODE_OWNERS,
+            /* subsection= */ null,
+            KEY_OVERRIDE_INFO_URL,
+            input.overrideInfoUrl);
+      }
+
+      if (input.invalidCodeOwnerConfigInfoUrl != null) {
+        codeOwnersConfig.setString(
+            SECTION_CODE_OWNERS,
+            /* subsection= */ null,
+            KEY_INVALID_CODE_OWNER_CONFIG_INFO_URL,
+            input.invalidCodeOwnerConfigInfoUrl);
+      }
+
+      if (input.readOnly != null) {
+        codeOwnersConfig.setBoolean(
+            SECTION_CODE_OWNERS, /* subsection= */ null, KEY_READ_ONLY, input.readOnly);
+      }
+
+      if (input.exemptPureReverts != null) {
+        codeOwnersConfig.setBoolean(
+            SECTION_CODE_OWNERS,
+            /* subsection= */ null,
+            KEY_EXEMPT_PURE_REVERTS,
+            input.exemptPureReverts);
+      }
+
+      if (input.enableValidationOnCommitReceived != null) {
+        codeOwnersConfig.setEnum(
+            SECTION_CODE_OWNERS,
+            /* subsection= */ null,
+            KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
+            input.enableValidationOnCommitReceived);
+      }
+
+      if (input.enableValidationOnSubmit != null) {
+        codeOwnersConfig.setEnum(
+            SECTION_CODE_OWNERS,
+            /* subsection= */ null,
+            KEY_ENABLE_VALIDATION_ON_SUBMIT,
+            input.enableValidationOnSubmit);
+      }
+
+      if (input.rejectNonResolvableCodeOwners != null) {
+        codeOwnersConfig.setBoolean(
+            SECTION_CODE_OWNERS,
+            /* subsection= */ null,
+            KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS,
+            input.rejectNonResolvableCodeOwners);
+      }
+
+      if (input.rejectNonResolvableImports != null) {
+        codeOwnersConfig.setBoolean(
+            SECTION_CODE_OWNERS,
+            /* subsection= */ null,
+            KEY_REJECT_NON_RESOLVABLE_IMPORTS,
+            input.rejectNonResolvableImports);
+      }
+
+      if (input.maxPathsInChangeMessages != null) {
+        codeOwnersConfig.setInt(
+            SECTION_CODE_OWNERS,
+            /* subsection= */ null,
+            KEY_MAX_PATHS_IN_CHANGE_MESSAGES,
+            input.maxPathsInChangeMessages);
+      }
+
+      validateConfig(projectResource.getProjectState(), codeOwnersConfig);
+
+      codeOwnersProjectConfigFile.commit(metaDataUpdate);
+      projectCache.evict(projectResource.getNameKey());
+    }
+
+    CodeOwnerProjectConfigInfo updatedCodeOwnerProjectConfigInfo =
+        codeOwnerProjectConfigJson.format(projectResource);
+    return Response.created(updatedCodeOwnerProjectConfigInfo);
+  }
+
+  private void validateConfig(ProjectState projectState, Config codeOwnersConfig)
+      throws BadRequestException {
+    ImmutableList<CommitValidationMessage> validationMessages =
+        codeOwnersPluginConfigValidator.validateConfig(
+            projectState, CodeOwnersProjectConfigFile.FILE_NAME, codeOwnersConfig);
+    if (!validationMessages.isEmpty()) {
+      StringBuilder exceptionMessage = new StringBuilder();
+      exceptionMessage.append("invalid config:\n");
+      validationMessages.forEach(
+          validationMessage ->
+              exceptionMessage.append(String.format("* %s\n", validationMessage.getMessage())));
+      throw new BadRequestException(exceptionMessage.toString());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/RenameEmail.java b/java/com/google/gerrit/plugins/codeowners/restapi/RenameEmail.java
index 0ed505b..e338543 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/RenameEmail.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/RenameEmail.java
@@ -35,7 +35,7 @@
 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.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.CommitUtil;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -99,7 +99,9 @@
     validateInput(input);
 
     CodeOwnerBackend codeOwnerBackend =
-        codeOwnersPluginConfiguration.getBackend(branchResource.getBranchKey());
+        codeOwnersPluginConfiguration
+            .getProjectConfig(branchResource.getNameKey())
+            .getBackend(branchResource.getBranchKey().branch());
 
     Account.Id accountOwningOldEmail = resolveEmail(input.oldEmail);
     Account.Id accountOwningNewEmail = resolveEmail(input.newEmail);
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java b/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java
index 03d8858..37b3823 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java
@@ -19,6 +19,8 @@
 import static com.google.gerrit.server.project.BranchResource.BRANCH_KIND;
 import static com.google.gerrit.server.project.ProjectResource.PROJECT_KIND;
 
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.registration.DynamicMap;
 
 /** Guice module that binds the REST API for the code-owners plugin. */
@@ -33,6 +35,11 @@
     get(BRANCH_KIND, "code_owners.branch_config").to(GetCodeOwnerBranchConfig.class);
     post(BRANCH_KIND, "code_owners.rename").to(RenameEmail.class);
 
+    bind(CapabilityDefinition.class)
+        .annotatedWith(Exports.named(CheckCodeOwnerCapability.ID))
+        .to(CheckCodeOwnerCapability.class);
+    get(BRANCH_KIND, "code_owners.check").to(CheckCodeOwner.class);
+
     factory(CodeOwnerJson.Factory.class);
     DynamicMap.mapOf(binder(), CodeOwnersInBranchCollection.PathResource.PATH_KIND);
     child(BRANCH_KIND, "code_owners").to(CodeOwnersInBranchCollection.class);
@@ -44,9 +51,11 @@
 
     get(CHANGE_KIND, "code_owners.status").to(GetCodeOwnerStatus.class);
 
+    get(REVISION_KIND, "owned_paths").to(GetOwnedPaths.class);
     post(REVISION_KIND, "code_owners.check_config").to(CheckCodeOwnerConfigFilesInRevision.class);
 
     get(PROJECT_KIND, "code_owners.project_config").to(GetCodeOwnerProjectConfig.class);
+    put(PROJECT_KIND, "code_owners.project_config").to(PutCodeOwnerProjectConfig.class);
     post(PROJECT_KIND, "code_owners.check_config").to(CheckCodeOwnerConfigFiles.class);
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/BUILD b/java/com/google/gerrit/plugins/codeowners/testing/BUILD
index 9277751..202e994 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/BUILD
+++ b/java/com/google/gerrit/plugins/codeowners/testing/BUILD
@@ -13,8 +13,13 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/truth",
         "//lib:guava",
+        "//lib:jgit",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
-        "//plugins/code-owners:code-owners__plugin",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/api",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/backend",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/common",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/restapi",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/util",
     ],
 )
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/ChangedFileSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/ChangedFileSubject.java
index b83bde2..9484c97 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/ChangedFileSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/ChangedFileSubject.java
@@ -17,14 +17,35 @@
 import static com.google.common.truth.Truth.assertAbout;
 import static com.google.gerrit.truth.OptionalSubject.optionals;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.truth.Correspondence;
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.Subject;
-import com.google.gerrit.plugins.codeowners.backend.ChangedFile;
+import com.google.gerrit.plugins.codeowners.common.ChangedFile;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
+import com.google.gerrit.truth.ListSubject;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.gerrit.truth.OptionalSubject;
+import java.util.Collection;
 
 /** {@link Subject} for doing assertions on {@link ChangedFile}s. */
 public class ChangedFileSubject extends Subject {
   /**
+   * Constructs a {@link Correspondence} that maps {@link ChangedFile}s to their paths (new path if
+   * set, otherwise old path).
+   */
+  public static Correspondence<ChangedFile, String> hasPath() {
+    return NullAwareCorrespondence.transforming(
+        changedFile ->
+            JgitPath.of(
+                    changedFile.newPath().isPresent()
+                        ? changedFile.newPath().get()
+                        : changedFile.oldPath().get())
+                .get(),
+        "has path");
+  }
+
+  /**
    * Starts fluent chain to do assertions on a {@link ChangedFile}.
    *
    * @param changedFile the changed file on which assertions should be done
@@ -34,6 +55,12 @@
     return assertAbout(changedFiles()).that(changedFile);
   }
 
+  /** Starts fluent chain to do assertions on a collection of {@link ChangedFile}s. */
+  public static ListSubject<ChangedFileSubject, ChangedFile> assertThatCollection(
+      Collection<ChangedFile> changedFiles) {
+    return ListSubject.assertThat(ImmutableList.copyOf(changedFiles), changedFiles());
+  }
+
   /** Creates subject factory for mapping {@link ChangedFile}s to {@link ChangedFileSubject}s. */
   public static Subject.Factory<ChangedFileSubject, ChangedFile> changedFiles() {
     return ChangedFileSubject::new;
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerCheckInfoSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerCheckInfoSubject.java
new file mode 100644
index 0000000..31fa4e0
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerCheckInfoSubject.java
@@ -0,0 +1,152 @@
+// 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.truth.FailureMetadata;
+import com.google.common.truth.IterableSubject;
+import com.google.common.truth.Subject;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerCheckInfo;
+
+/** {@link Subject} for doing assertions on {@link CodeOwnerCheckInfo}s. */
+public class CodeOwnerCheckInfoSubject extends Subject {
+  /**
+   * Starts fluent chain to do assertions on a {@link CodeOwnerCheckInfo}.
+   *
+   * @param codeOwnerCheckInfo the code owner check info on which assertions should be done
+   * @return the created {@link CodeOwnerCheckInfoSubject}
+   */
+  public static CodeOwnerCheckInfoSubject assertThat(CodeOwnerCheckInfo codeOwnerCheckInfo) {
+    return assertAbout(codeOwnerCheckInfos()).that(codeOwnerCheckInfo);
+  }
+
+  private static Factory<CodeOwnerCheckInfoSubject, CodeOwnerCheckInfo> codeOwnerCheckInfos() {
+    return CodeOwnerCheckInfoSubject::new;
+  }
+
+  private final CodeOwnerCheckInfo codeOwnerCheckInfo;
+
+  private CodeOwnerCheckInfoSubject(
+      FailureMetadata metadata, CodeOwnerCheckInfo codeOwnerCheckInfo) {
+    super(metadata, codeOwnerCheckInfo);
+    this.codeOwnerCheckInfo = codeOwnerCheckInfo;
+  }
+
+  public void isCodeOwner() {
+    check("isCodeOwner").that(codeOwnerCheckInfo().isCodeOwner).isTrue();
+  }
+
+  public void isNotCodeOwner() {
+    check("isCodeOwner").that(codeOwnerCheckInfo().isCodeOwner).isFalse();
+  }
+
+  public void isFallbackCodeOwner() {
+    check("isFallbackCodeOwner").that(codeOwnerCheckInfo().isFallbackCodeOwner).isTrue();
+  }
+
+  public void isNotFallbackCodeOwner() {
+    check("isFallbackCodeOwner").that(codeOwnerCheckInfo().isFallbackCodeOwner).isFalse();
+  }
+
+  public void isResolvable() {
+    check("isResolvable").that(codeOwnerCheckInfo().isResolvable).isTrue();
+  }
+
+  public void isNotResolvable() {
+    check("isResolvable").that(codeOwnerCheckInfo().isResolvable).isFalse();
+  }
+
+  public void canReadRef() {
+    check("canReadRef").that(codeOwnerCheckInfo().canReadRef).isTrue();
+  }
+
+  public void cannotReadRef() {
+    check("canReadRef").that(codeOwnerCheckInfo().canReadRef).isFalse();
+  }
+
+  public void canReadRefNotSet() {
+    check("canReadRef").that(codeOwnerCheckInfo().canReadRef).isNull();
+  }
+
+  public void canSeeChange() {
+    check("canSeeChange").that(codeOwnerCheckInfo().canSeeChange).isTrue();
+  }
+
+  public void cannotSeeChange() {
+    check("canSeeChange").that(codeOwnerCheckInfo().canSeeChange).isFalse();
+  }
+
+  public void canSeeChangeNotSet() {
+    check("canSeeChange").that(codeOwnerCheckInfo().canSeeChange).isNull();
+  }
+
+  public void canApproveChange() {
+    check("canApproveChange").that(codeOwnerCheckInfo().canApproveChange).isTrue();
+  }
+
+  public void cannotApproveChange() {
+    check("canApproveChange").that(codeOwnerCheckInfo().canApproveChange).isFalse();
+  }
+
+  public void canApproveChangeNotSet() {
+    check("canApproveChange").that(codeOwnerCheckInfo().canApproveChange).isNull();
+  }
+
+  public IterableSubject hasCodeOwnerConfigFilePathsThat() {
+    return check("codeOwnerConfigFilePaths").that(codeOwnerCheckInfo().codeOwnerConfigFilePaths);
+  }
+
+  public void isDefaultCodeOwner() {
+    check("isDefaultCodeOwner").that(codeOwnerCheckInfo().isDefaultCodeOwner).isTrue();
+  }
+
+  public void isNotDefaultCodeOwner() {
+    check("isDefaultCodeOwner").that(codeOwnerCheckInfo().isDefaultCodeOwner).isFalse();
+  }
+
+  public void isGlobalCodeOwner() {
+    check("isGlobalCodeOwner").that(codeOwnerCheckInfo().isGlobalCodeOwner).isTrue();
+  }
+
+  public void isNotGlobalCodeOwner() {
+    check("isGlobalCodeOwner").that(codeOwnerCheckInfo().isGlobalCodeOwner).isFalse();
+  }
+
+  public void isOwnedByAllUsers() {
+    check("isOwnedByAllUsers").that(codeOwnerCheckInfo().isOwnedByAllUsers).isTrue();
+  }
+
+  public void isNotOwnedByAllUsers() {
+    check("isOwnedByAllUsers").that(codeOwnerCheckInfo().isOwnedByAllUsers).isFalse();
+  }
+
+  public void hasDebugLogsThatContainAllOf(String... expectedMessages) {
+    for (String expectedMessage : expectedMessages) {
+      check("debugLogs").that(codeOwnerCheckInfo().debugLogs).contains(expectedMessage);
+    }
+  }
+
+  public void hasDebugLogsThatDoNotContainAnyOf(String... expectedMessages) {
+    for (String expectedMessage : expectedMessages) {
+      check("debugLogs").that(codeOwnerCheckInfo().debugLogs).doesNotContain(expectedMessage);
+    }
+  }
+
+  private CodeOwnerCheckInfo codeOwnerCheckInfo() {
+    isNotNull();
+    return codeOwnerCheckInfo;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerConfigInfoSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerConfigInfoSubject.java
index 116407d..c2fb3be 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerConfigInfoSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerConfigInfoSubject.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.plugins.codeowners.testing;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertAbout;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerSetInfoSubject.codeOwnerSetInfos;
 import static com.google.gerrit.truth.ListSubject.elements;
@@ -24,9 +25,11 @@
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerConfigInfo;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerSetInfo;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
-import com.google.gerrit.plugins.codeowners.restapi.CodeOwnerConfigJson;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
 import com.google.gerrit.truth.ListSubject;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.gerrit.truth.OptionalSubject;
+import java.util.List;
 import java.util.Optional;
 
 /** {@link Subject} for doing assertions on {@link CodeOwnerConfigInfo}s. */
@@ -93,9 +96,21 @@
    *     corresponds to the {@link CodeOwnerConfigInfo} of this subject
    */
   public void correspondsTo(CodeOwnerConfig codeOwnerConfig) {
-    check("codeOwnerInfo()")
-        .that(codeOwnerConfigInfo())
-        .isEqualTo(CodeOwnerConfigJson.format(codeOwnerConfig));
+    hasIgnoreParentCodeOwnersThat().isEqualTo(codeOwnerConfig.ignoreParentCodeOwners());
+    hasCodeOwnerSetsThat()
+        .comparingElementsUsing(
+            NullAwareCorrespondence.<CodeOwnerSetInfo, List<String>>transforming(
+                codeOwnerSetInfo ->
+                    codeOwnerSetInfo.codeOwners.stream()
+                        .map(codeOwnerReferenceInfo -> codeOwnerReferenceInfo.email)
+                        .collect(toImmutableList()),
+                "has code owners"))
+        .containsExactly(
+            codeOwnerConfig.codeOwnerSets().stream()
+                .flatMap(
+                    codeOwnerSet ->
+                        codeOwnerSet.codeOwners().stream().map(CodeOwnerReference::email))
+                .collect(toImmutableList()));
   }
 
   private CodeOwnerConfigInfo codeOwnerConfigInfo() {
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerInfoSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerInfoSubject.java
index be0e392..33a19aa 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerInfoSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerInfoSubject.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.plugins.codeowners.testing;
 
-import static com.google.common.truth.Truth.assertAbout;
-
 import com.google.common.truth.ComparableSubject;
 import com.google.common.truth.Correspondence;
 import com.google.common.truth.FailureMetadata;
@@ -23,9 +21,7 @@
 import com.google.common.truth.Subject;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerInfo;
-import com.google.gerrit.truth.ListSubject;
 import com.google.gerrit.truth.NullAwareCorrespondence;
-import java.util.List;
 
 /** {@link Subject} for doing assertions on {@link CodeOwnerInfo}s. */
 public class CodeOwnerInfoSubject extends Subject {
@@ -44,22 +40,7 @@
         codeOwnerInfo -> codeOwnerInfo.account.name, "has account name");
   }
 
-  /**
-   * Starts fluent chain to do assertions on a {@link CodeOwnerInfo}.
-   *
-   * @param codeOwnerInfo the code owner info on which assertions should be done
-   * @return the created {@link CodeOwnerInfoSubject}
-   */
-  public static CodeOwnerInfoSubject assertThat(CodeOwnerInfo codeOwnerInfo) {
-    return assertAbout(codeOwnerInfos()).that(codeOwnerInfo);
-  }
-
-  public static ListSubject<CodeOwnerInfoSubject, CodeOwnerInfo> assertThatList(
-      List<CodeOwnerInfo> codeOwnerInfos) {
-    return ListSubject.assertThat(codeOwnerInfos, codeOwnerInfos());
-  }
-
-  private static Factory<CodeOwnerInfoSubject, CodeOwnerInfo> codeOwnerInfos() {
+  public static Factory<CodeOwnerInfoSubject, CodeOwnerInfo> codeOwnerInfos() {
     return CodeOwnerInfoSubject::new;
   }
 
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerStatusInfoSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerStatusInfoSubject.java
index 6bd2e07..a0b3452 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerStatusInfoSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerStatusInfoSubject.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusInfoSubject.fileCodeOwnerStatusInfos;
 import static com.google.gerrit.truth.ListSubject.elements;
 
+import com.google.common.truth.BooleanSubject;
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.IntegerSubject;
 import com.google.common.truth.Subject;
@@ -57,11 +58,15 @@
   /** Returns a {@link ListSubject} for the file code owner statuses. */
   public ListSubject<FileCodeOwnerStatusInfoSubject, FileCodeOwnerStatusInfo>
       hasFileCodeOwnerStatusesThat() {
-    return check("fileCodeOwnerStatuses")
+    return check("fileCodeOwnerStatuses()")
         .about(elements())
         .thatCustom(codeOwnerStatusInfo().fileCodeOwnerStatuses, fileCodeOwnerStatusInfos());
   }
 
+  public BooleanSubject hasMoreThat() {
+    return check("more()").that(codeOwnerStatusInfo().more);
+  }
+
   private CodeOwnerStatusInfo codeOwnerStatusInfo() {
     isNotNull();
     return codeOwnerStatusInfo;
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerSubject.java
index 49852bb..5e46c2c 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnerSubject.java
@@ -14,32 +14,19 @@
 
 package com.google.gerrit.plugins.codeowners.testing;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertAbout;
 
 import com.google.common.truth.ComparableSubject;
-import com.google.common.truth.Correspondence;
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.Subject;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwner;
-import com.google.gerrit.truth.ListSubject;
-import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.gerrit.truth.OptionalSubject;
 import java.util.Optional;
-import java.util.stream.Stream;
 
 /** {@link Subject} for doing assertions on {@link CodeOwner}s. */
 public class CodeOwnerSubject extends Subject {
   /**
-   * Constructs a {@link Correspondence} that maps {@link CodeOwner}s to {@link
-   * com.google.gerrit.entities.Account.Id}s.
-   */
-  public static final Correspondence<CodeOwner, Account.Id> hasAccountId() {
-    return NullAwareCorrespondence.transforming(CodeOwner::accountId, "has account ID");
-  }
-
-  /**
    * Starts fluent chain to do assertions on a {@link CodeOwner}.
    *
    * @param codeOwner the code owner on which assertions should be done
@@ -60,16 +47,6 @@
     return OptionalSubject.assertThat(codeOwner, codeOwners());
   }
 
-  /**
-   * Starts fluent chain to do assertions on a stream of {@link CodeOwner}s.
-   *
-   * @param codeOwners stream of code owners on which assertions should be done
-   * @return the created {@link ListSubject}
-   */
-  public static ListSubject<CodeOwnerSubject, CodeOwner> assertThat(Stream<CodeOwner> codeOwners) {
-    return ListSubject.assertThat(codeOwners.collect(toImmutableList()), codeOwners());
-  }
-
   /** Creates subject factory for mapping {@link CodeOwner}s to {@link CodeOwnerSubject}s. */
   private static Subject.Factory<CodeOwnerSubject, CodeOwner> codeOwners() {
     return CodeOwnerSubject::new;
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnersInfoSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnersInfoSubject.java
new file mode 100644
index 0000000..11433e2
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/testing/CodeOwnersInfoSubject.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerInfoSubject.codeOwnerInfos;
+import static com.google.gerrit.truth.ListSubject.elements;
+
+import com.google.common.truth.BooleanSubject;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IterableSubject;
+import com.google.common.truth.Subject;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerInfo;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnersInfo;
+import com.google.gerrit.truth.ListSubject;
+
+/** {@link Subject} for doing assertions on {@link CodeOwnersInfo}s. */
+public class CodeOwnersInfoSubject extends Subject {
+  /**
+   * Starts fluent chain to do assertions on a {@link CodeOwnersInfo}.
+   *
+   * @param codeOwnersInfo the code owners info on which assertions should be done
+   * @return the created {@link CodeOwnersInfoSubject}
+   */
+  public static CodeOwnersInfoSubject assertThat(CodeOwnersInfo codeOwnersInfo) {
+    return assertAbout(codeOwnersInfos()).that(codeOwnersInfo);
+  }
+
+  private static Factory<CodeOwnersInfoSubject, CodeOwnersInfo> codeOwnersInfos() {
+    return CodeOwnersInfoSubject::new;
+  }
+
+  private final CodeOwnersInfo codeOwnersInfo;
+
+  private CodeOwnersInfoSubject(FailureMetadata metadata, CodeOwnersInfo codeOwnersInfo) {
+    super(metadata, codeOwnersInfo);
+    this.codeOwnersInfo = codeOwnersInfo;
+  }
+
+  public ListSubject<CodeOwnerInfoSubject, CodeOwnerInfo> hasCodeOwnersThat() {
+    return check("codeOwners()")
+        .about(elements())
+        .thatCustom(codeOwnersInfo().codeOwners, codeOwnerInfos());
+  }
+
+  public BooleanSubject hasOwnedByAllUsersThat() {
+    return check("ownedByAllUsers").that(codeOwnersInfo().ownedByAllUsers);
+  }
+
+  public void hasDebugLogsThatContainAllOf(String... expectedMessages) {
+    for (String expectedMessage : expectedMessages) {
+      check("debugLogs").that(codeOwnersInfo().debugLogs).contains(expectedMessage);
+    }
+  }
+
+  public IterableSubject hasDebugLogsThat() {
+    return check("debugLogs").that(codeOwnersInfo().debugLogs);
+  }
+
+  private CodeOwnersInfo codeOwnersInfo() {
+    isNotNull();
+    return codeOwnersInfo;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/FileCodeOwnerStatusInfoSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/FileCodeOwnerStatusInfoSubject.java
index 26d3483..5f885e7 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/FileCodeOwnerStatusInfoSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/FileCodeOwnerStatusInfoSubject.java
@@ -17,17 +17,72 @@
 import static com.google.common.truth.Truth.assertAbout;
 import static com.google.gerrit.plugins.codeowners.testing.PathCodeOwnerStatusInfoSubject.pathCodeOwnerStatusInfos;
 import static com.google.gerrit.truth.OptionalSubject.optionals;
+import static java.util.Objects.requireNonNull;
 
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
 import com.google.common.truth.ComparableSubject;
+import com.google.common.truth.Correspondence;
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.Subject;
 import com.google.gerrit.extensions.common.ChangeType;
 import com.google.gerrit.plugins.codeowners.api.FileCodeOwnerStatusInfo;
+import com.google.gerrit.plugins.codeowners.api.PathCodeOwnerStatusInfo;
+import com.google.gerrit.plugins.codeowners.backend.FileCodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.backend.PathCodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.common.ChangedFile;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.gerrit.truth.OptionalSubject;
 import java.util.Optional;
+import org.eclipse.jgit.diff.DiffEntry;
 
 /** {@link Subject} for doing assertions on {@link FileCodeOwnerStatusInfo}s. */
 public class FileCodeOwnerStatusInfoSubject extends Subject {
+  private static final ImmutableMap<ChangeType, DiffEntry.ChangeType> CHANGE_TYPE =
+      Maps.immutableEnumMap(
+          new ImmutableMap.Builder<ChangeType, DiffEntry.ChangeType>()
+              .put(ChangeType.ADDED, DiffEntry.ChangeType.ADD)
+              .put(ChangeType.MODIFIED, DiffEntry.ChangeType.MODIFY)
+              .put(ChangeType.DELETED, DiffEntry.ChangeType.DELETE)
+              .put(ChangeType.RENAMED, DiffEntry.ChangeType.RENAME)
+              .put(ChangeType.COPIED, DiffEntry.ChangeType.COPY)
+              .build());
+
+  /**
+   * {@link Correspondence} that maps {@link FileCodeOwnerStatusInfo}s to {@link
+   * FileCodeOwnerStatus}s.
+   */
+  public static final Correspondence<FileCodeOwnerStatusInfo, FileCodeOwnerStatus>
+      isFileCodeOwnerStatus() {
+    return NullAwareCorrespondence.transforming(
+        FileCodeOwnerStatusInfoSubject::toFileCodeOwnerStatus, "is file code owner status");
+  }
+
+  private static FileCodeOwnerStatus toFileCodeOwnerStatus(
+      FileCodeOwnerStatusInfo fileCodeOwnerStatusInfo) {
+    requireNonNull(fileCodeOwnerStatusInfo, "fileCodeOwnerStatusInfo");
+
+    ChangedFile changedFile =
+        ChangedFile.create(
+            Optional.ofNullable(fileCodeOwnerStatusInfo.newPathStatus)
+                .map(pathCodeOwnerStatusInfo -> pathCodeOwnerStatusInfo.path),
+            Optional.ofNullable(fileCodeOwnerStatusInfo.oldPathStatus)
+                .map(pathCodeOwnerStatusInfo -> pathCodeOwnerStatusInfo.path),
+            CHANGE_TYPE.get(fileCodeOwnerStatusInfo.changeType));
+    return FileCodeOwnerStatus.create(
+        changedFile,
+        Optional.ofNullable(fileCodeOwnerStatusInfo.newPathStatus)
+            .map(FileCodeOwnerStatusInfoSubject::toPathCodeOwnerStatus),
+        Optional.ofNullable(fileCodeOwnerStatusInfo.oldPathStatus)
+            .map(FileCodeOwnerStatusInfoSubject::toPathCodeOwnerStatus));
+  }
+
+  private static PathCodeOwnerStatus toPathCodeOwnerStatus(
+      PathCodeOwnerStatusInfo pathCodeOwnerStatusInfo) {
+    requireNonNull(pathCodeOwnerStatusInfo, "pathCodeOwnerStatusInfo");
+    return PathCodeOwnerStatus.create(pathCodeOwnerStatusInfo.path, pathCodeOwnerStatusInfo.status);
+  }
+
   /**
    * Starts fluent chain to do assertions on a {@link FileCodeOwnerStatusInfo}.
    *
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/FileCodeOwnerStatusSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/FileCodeOwnerStatusSubject.java
index 03948f1..3a81908 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/FileCodeOwnerStatusSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/FileCodeOwnerStatusSubject.java
@@ -20,11 +20,13 @@
 import static com.google.gerrit.plugins.codeowners.testing.PathCodeOwnerStatusSubject.pathCodeOwnerStatuses;
 import static com.google.gerrit.truth.OptionalSubject.optionals;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.Subject;
 import com.google.gerrit.plugins.codeowners.backend.FileCodeOwnerStatus;
 import com.google.gerrit.truth.ListSubject;
 import com.google.gerrit.truth.OptionalSubject;
+import java.util.Collection;
 import java.util.stream.Stream;
 
 /** {@link Subject} for doing assertions on {@link FileCodeOwnerStatus}es. */
@@ -39,6 +41,13 @@
         fileCodeOwnerStatuses.collect(toImmutableList()), fileCodeOwnerStatuses());
   }
 
+  /** Starts fluent chain to do assertions on a collection of {@link FileCodeOwnerStatus}es. */
+  public static ListSubject<FileCodeOwnerStatusSubject, FileCodeOwnerStatus> assertThatCollection(
+      Collection<FileCodeOwnerStatus> fileCodeOwnerStatuses) {
+    return ListSubject.assertThat(
+        ImmutableList.copyOf(fileCodeOwnerStatuses), fileCodeOwnerStatuses());
+  }
+
   private static Factory<FileCodeOwnerStatusSubject, FileCodeOwnerStatus> fileCodeOwnerStatuses() {
     return FileCodeOwnerStatusSubject::new;
   }
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/SubmitRequirementInfoSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/LegacySubmitRequirementInfoSubject.java
similarity index 68%
rename from java/com/google/gerrit/plugins/codeowners/testing/SubmitRequirementInfoSubject.java
rename to java/com/google/gerrit/plugins/codeowners/testing/LegacySubmitRequirementInfoSubject.java
index dbe7848..9c8d2cb 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/SubmitRequirementInfoSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/LegacySubmitRequirementInfoSubject.java
@@ -18,27 +18,27 @@
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
-import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
 import com.google.gerrit.truth.ListSubject;
 import java.util.Collection;
 
-/** {@link Subject} for doing assertions on {@link SubmitRequirementInfo}s. */
-public class SubmitRequirementInfoSubject extends Subject {
-  public static ListSubject<SubmitRequirementInfoSubject, SubmitRequirementInfo>
-      assertThatCollection(Collection<SubmitRequirementInfo> submitRequirementInfos) {
+/** {@link Subject} for doing assertions on {@link LegacySubmitRequirementInfo}s. */
+public class LegacySubmitRequirementInfoSubject extends Subject {
+  public static ListSubject<LegacySubmitRequirementInfoSubject, LegacySubmitRequirementInfo>
+      assertThatCollection(Collection<LegacySubmitRequirementInfo> submitRequirementInfos) {
     return ListSubject.assertThat(
         ImmutableList.copyOf(submitRequirementInfos), submitRecordRequirementInfos());
   }
 
-  private static Factory<SubmitRequirementInfoSubject, SubmitRequirementInfo>
+  private static Factory<LegacySubmitRequirementInfoSubject, LegacySubmitRequirementInfo>
       submitRecordRequirementInfos() {
-    return SubmitRequirementInfoSubject::new;
+    return LegacySubmitRequirementInfoSubject::new;
   }
 
-  private final SubmitRequirementInfo submitRequirementInfo;
+  private final LegacySubmitRequirementInfo submitRequirementInfo;
 
-  private SubmitRequirementInfoSubject(
-      FailureMetadata metadata, SubmitRequirementInfo submitRequirementInfo) {
+  private LegacySubmitRequirementInfoSubject(
+      FailureMetadata metadata, LegacySubmitRequirementInfo submitRequirementInfo) {
     super(metadata, submitRequirementInfo);
     this.submitRequirementInfo = submitRequirementInfo;
   }
@@ -58,7 +58,7 @@
     return check("fallbackText()").that(submitRequirementInfo().fallbackText);
   }
 
-  private SubmitRequirementInfo submitRequirementInfo() {
+  private LegacySubmitRequirementInfo submitRequirementInfo() {
     isNotNull();
     return submitRequirementInfo;
   }
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/SubmitRequirementSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/LegacySubmitRequirementSubject.java
similarity index 68%
rename from java/com/google/gerrit/plugins/codeowners/testing/SubmitRequirementSubject.java
rename to java/com/google/gerrit/plugins/codeowners/testing/LegacySubmitRequirementSubject.java
index 7ffd31c..8a2fb99 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/SubmitRequirementSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/LegacySubmitRequirementSubject.java
@@ -17,17 +17,19 @@
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
-import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 
-/** {@link Subject} for doing assertions on {@link SubmitRequirement}s. */
-public class SubmitRequirementSubject extends Subject {
-  public static Factory<SubmitRequirementSubject, SubmitRequirement> submitRecordRequirements() {
-    return SubmitRequirementSubject::new;
+/** {@link Subject} for doing assertions on {@link LegacySubmitRequirement}s. */
+public class LegacySubmitRequirementSubject extends Subject {
+  public static Factory<LegacySubmitRequirementSubject, LegacySubmitRequirement>
+      submitRecordRequirements() {
+    return LegacySubmitRequirementSubject::new;
   }
 
-  private final SubmitRequirement submitRequirement;
+  private final LegacySubmitRequirement submitRequirement;
 
-  private SubmitRequirementSubject(FailureMetadata metadata, SubmitRequirement submitRequirement) {
+  private LegacySubmitRequirementSubject(
+      FailureMetadata metadata, LegacySubmitRequirement submitRequirement) {
     super(metadata, submitRequirement);
     this.submitRequirement = submitRequirement;
   }
@@ -42,7 +44,7 @@
     return check("fallbackText()").that(submitRequirement().fallbackText());
   }
 
-  private SubmitRequirement submitRequirement() {
+  private LegacySubmitRequirement submitRequirement() {
     isNotNull();
     return submitRequirement;
   }
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/OptionalResultWithMessagesSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/OptionalResultWithMessagesSubject.java
new file mode 100644
index 0000000..8d46152
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/testing/OptionalResultWithMessagesSubject.java
@@ -0,0 +1,68 @@
+// 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 static com.google.gerrit.truth.OptionalSubject.optionals;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IterableSubject;
+import com.google.common.truth.Subject;
+import com.google.gerrit.plugins.codeowners.backend.OptionalResultWithMessages;
+
+/** {@link Subject} for doing assertions on {@link OptionalResultWithMessages}s. */
+public class OptionalResultWithMessagesSubject extends Subject {
+  /**
+   * Starts fluent chain to do assertions on a {@link OptionalResultWithMessages}.
+   *
+   * @param optionalResultWithMessages the optionalResultWithMessages instance on which assertions
+   *     should be done
+   * @return the created {@link OptionalResultWithMessagesSubject}
+   */
+  public static OptionalResultWithMessagesSubject assertThat(
+      OptionalResultWithMessages<?> optionalResultWithMessages) {
+    return assertAbout(optionalResultsWithMessages()).that(optionalResultWithMessages);
+  }
+
+  /**
+   * Creates subject factory for mapping {@link OptionalResultWithMessages}s to {@link
+   * OptionalResultWithMessagesSubject}s.
+   */
+  private static Subject.Factory<OptionalResultWithMessagesSubject, OptionalResultWithMessages<?>>
+      optionalResultsWithMessages() {
+    return OptionalResultWithMessagesSubject::new;
+  }
+
+  private final OptionalResultWithMessages<?> optionalResultWithMessages;
+
+  private OptionalResultWithMessagesSubject(
+      FailureMetadata metadata, OptionalResultWithMessages<?> optionalResultWithMessages) {
+    super(metadata, optionalResultWithMessages);
+    this.optionalResultWithMessages = optionalResultWithMessages;
+  }
+
+  public void isEmpty() {
+    check("result()").about(optionals()).that(optionalResultWithMessages().result()).isEmpty();
+  }
+
+  public IterableSubject hasMessagesThat() {
+    return check("messages()").that(optionalResultWithMessages().messages());
+  }
+
+  private OptionalResultWithMessages<?> optionalResultWithMessages() {
+    isNotNull();
+    return optionalResultWithMessages;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/OwnedPathsInfoSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/OwnedPathsInfoSubject.java
new file mode 100644
index 0000000..d6a1072
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/testing/OwnedPathsInfoSubject.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.BooleanSubject;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IterableSubject;
+import com.google.common.truth.Subject;
+import com.google.gerrit.plugins.codeowners.api.OwnedPathsInfo;
+
+/** {@link Subject} for doing assertions on {@link OwnedPathsInfo}s. */
+public class OwnedPathsInfoSubject extends Subject {
+  /**
+   * Starts fluent chain to do assertions on a {@link OwnedPathsInfo}.
+   *
+   * @param ownedPathsInfo the owned paths info on which assertions should be done
+   * @return the created {@link OwnedPathsInfoSubject}
+   */
+  public static OwnedPathsInfoSubject assertThat(OwnedPathsInfo ownedPathsInfo) {
+    return assertAbout(ownedPathsInfos()).that(ownedPathsInfo);
+  }
+
+  private static Factory<OwnedPathsInfoSubject, OwnedPathsInfo> ownedPathsInfos() {
+    return OwnedPathsInfoSubject::new;
+  }
+
+  private final OwnedPathsInfo ownedPathsInfo;
+
+  private OwnedPathsInfoSubject(FailureMetadata metadata, OwnedPathsInfo ownedPathsInfo) {
+    super(metadata, ownedPathsInfo);
+    this.ownedPathsInfo = ownedPathsInfo;
+  }
+
+  public IterableSubject hasOwnedPathsThat() {
+    return check("ownedPaths()").that(ownedPathsInfo().ownedPaths);
+  }
+
+  public BooleanSubject hasMoreThat() {
+    return check("more()").that(ownedPathsInfo().more);
+  }
+
+  private OwnedPathsInfo ownedPathsInfo() {
+    isNotNull();
+    return ownedPathsInfo;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/PathCodeOwnerStatusInfoSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/PathCodeOwnerStatusInfoSubject.java
index 4671d8d..0ed2f75 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/PathCodeOwnerStatusInfoSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/PathCodeOwnerStatusInfoSubject.java
@@ -19,8 +19,8 @@
 import com.google.common.truth.ComparableSubject;
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.Subject;
-import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatus;
 import com.google.gerrit.plugins.codeowners.api.PathCodeOwnerStatusInfo;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
 
 /** {@link Subject} for doing assertions on {@link PathCodeOwnerStatusInfo}s. */
 public class PathCodeOwnerStatusInfoSubject extends Subject {
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/PathCodeOwnerStatusSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/PathCodeOwnerStatusSubject.java
index 47bea0e..5e2f58c 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/PathCodeOwnerStatusSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/PathCodeOwnerStatusSubject.java
@@ -15,28 +15,17 @@
 package com.google.gerrit.plugins.codeowners.testing;
 
 import static com.google.common.truth.PathSubject.paths;
-import static com.google.common.truth.Truth.assertAbout;
 
 import com.google.common.truth.ComparableSubject;
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.PathSubject;
 import com.google.common.truth.Subject;
-import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatus;
 import com.google.gerrit.plugins.codeowners.backend.PathCodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
 
 /** {@link Subject} for doing assertions on {@link PathCodeOwnerStatus}s. */
 public class PathCodeOwnerStatusSubject extends Subject {
   /**
-   * Starts fluent chain to do assertions on a {@link PathCodeOwnerStatus}.
-   *
-   * @param pathCodeOwnerStatus the {@link PathCodeOwnerStatus} on which assertions should be done
-   * @return the created {@link PathCodeOwnerStatusSubject}
-   */
-  public static PathCodeOwnerStatusSubject assertThat(PathCodeOwnerStatus pathCodeOwnerStatus) {
-    return assertAbout(pathCodeOwnerStatuses()).that(pathCodeOwnerStatus);
-  }
-
-  /**
    * Creates subject factory for mapping {@link PathCodeOwnerStatus}es to {@link
    * PathCodeOwnerStatusSubject}s.
    */
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/RequiredApprovalSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/RequiredApprovalSubject.java
index 68673df..15a25f3 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/RequiredApprovalSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/RequiredApprovalSubject.java
@@ -17,15 +17,13 @@
 import static com.google.common.truth.Truth.assertAbout;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedSet;
 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.plugins.codeowners.backend.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 {
@@ -40,17 +38,6 @@
   }
 
   /**
-   * 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
@@ -62,14 +49,14 @@
   }
 
   /**
-   * Starts a fluent chain to do assertions on a set of {@link RequiredApproval}s.
+   * Starts a fluent chain to do assertions on a sorted set of {@link RequiredApproval}s.
    *
-   * @param requiredApprovals set of required approvals on which assertions should be done
+   * @param requiredApprovals sorted 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());
+      ImmutableSortedSet<RequiredApproval> requiredApprovals) {
+    return assertThat(requiredApprovals.asList());
   }
 
   /**
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/SubmitRecordSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/SubmitRecordSubject.java
index 301fc1d..aa727b5 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/SubmitRecordSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/SubmitRecordSubject.java
@@ -14,15 +14,14 @@
 
 package com.google.gerrit.plugins.codeowners.testing;
 
-import static com.google.common.truth.Truth.assertAbout;
-import static com.google.gerrit.plugins.codeowners.testing.SubmitRequirementSubject.submitRecordRequirements;
+import static com.google.gerrit.plugins.codeowners.testing.LegacySubmitRequirementSubject.submitRecordRequirements;
 import static com.google.gerrit.truth.ListSubject.elements;
 
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.truth.ListSubject;
 import com.google.gerrit.truth.OptionalSubject;
 import java.util.Optional;
@@ -30,16 +29,6 @@
 /** {@link Subject} for doing assertions on {@link SubmitRecord}s. */
 public class SubmitRecordSubject extends Subject {
   /**
-   * Starts fluent chain to do assertions on a {@link SubmitRecord}.
-   *
-   * @param submitRecord the {@link SubmitRecord} on which assertions should be done
-   * @return the created {@link SubmitRecordSubject}
-   */
-  public static SubmitRecordSubject assertThat(SubmitRecord submitRecord) {
-    return assertAbout(submitRecords()).that(submitRecord);
-  }
-
-  /**
    * Starts fluent chain to do assertions on an {@link Optional} {@link SubmitRecord}.
    *
    * @param submitRecord the {@link SubmitRecord} {@link Optional} on which assertions should be
@@ -75,7 +64,8 @@
   }
 
   /** Returns a {@link ListSubject} for the submit requirements. */
-  public ListSubject<SubmitRequirementSubject, SubmitRequirement> hasSubmitRequirementsThat() {
+  public ListSubject<LegacySubmitRequirementSubject, LegacySubmitRequirement>
+      hasSubmitRequirementsThat() {
     return check("submitRequirements()")
         .about(elements())
         .thatCustom(submitRecord().requirements, submitRecordRequirements());
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/backend/BUILD b/java/com/google/gerrit/plugins/codeowners/testing/backend/BUILD
index 6c29233..f4392f9 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/backend/BUILD
+++ b/java/com/google/gerrit/plugins/codeowners/testing/backend/BUILD
@@ -15,6 +15,7 @@
         "//lib:jgit-junit",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
-        "//plugins/code-owners:code-owners__plugin",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/backend",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/util",
     ],
 )
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/backend/TestCodeOwnerConfigStorage.java b/java/com/google/gerrit/plugins/codeowners/testing/backend/TestCodeOwnerConfigStorage.java
index 6a9c483..772277b 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/backend/TestCodeOwnerConfigStorage.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/backend/TestCodeOwnerConfigStorage.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.plugins.codeowners.testing.backend;
 
-import com.google.gerrit.plugins.codeowners.JgitPath;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigParser;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
diff --git a/java/com/google/gerrit/plugins/codeowners/util/BUILD b/java/com/google/gerrit/plugins/codeowners/util/BUILD
new file mode 100644
index 0000000..1ea5031
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/util/BUILD
@@ -0,0 +1,7 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "util",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+)
diff --git a/java/com/google/gerrit/plugins/codeowners/JgitPath.java b/java/com/google/gerrit/plugins/codeowners/util/JgitPath.java
similarity index 97%
rename from java/com/google/gerrit/plugins/codeowners/JgitPath.java
rename to java/com/google/gerrit/plugins/codeowners/util/JgitPath.java
index fd6c5fd..18b77be 100644
--- a/java/com/google/gerrit/plugins/codeowners/JgitPath.java
+++ b/java/com/google/gerrit/plugins/codeowners/util/JgitPath.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners;
+package com.google.gerrit.plugins.codeowners.util;
 
 import static java.util.Objects.requireNonNull;
 
diff --git a/java/com/google/gerrit/plugins/codeowners/validation/BUILD b/java/com/google/gerrit/plugins/codeowners/validation/BUILD
new file mode 100644
index 0000000..f66a8a5
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/validation/BUILD
@@ -0,0 +1,14 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:plugin.bzl", "PLUGIN_DEPS_NEVERLINK")
+
+java_library(
+    name = "validation",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = PLUGIN_DEPS_NEVERLINK + [
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/backend",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/common",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/metrics",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/util",
+    ],
+)
diff --git a/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java b/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java
index e659580..6292194 100644
--- a/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java
+++ b/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java
@@ -17,22 +17,20 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.gerrit.plugins.codeowners.backend.CodeOwners.getInvalidConfigCause;
+import static com.google.gerrit.plugins.codeowners.backend.CodeOwners.getInvalidCodeOwnerConfigCause;
 import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.plugins.codeowners.JgitPath;
-import com.google.gerrit.plugins.codeowners.api.CodeOwnerConfigValidationPolicy;
-import com.google.gerrit.plugins.codeowners.api.MergeCommitStrategy;
-import com.google.gerrit.plugins.codeowners.backend.ChangedFile;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.plugins.codeowners.backend.ChangedFiles;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
@@ -40,9 +38,18 @@
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigReference;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerResolver;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnersInternalServerErrorException;
+import com.google.gerrit.plugins.codeowners.backend.InvalidCodeOwnerConfigException;
 import com.google.gerrit.plugins.codeowners.backend.PathCodeOwners;
-import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
-import com.google.gerrit.plugins.codeowners.config.InvalidPluginConfigurationException;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginProjectConfigSnapshot;
+import com.google.gerrit.plugins.codeowners.backend.config.InvalidPluginConfigurationException;
+import com.google.gerrit.plugins.codeowners.common.ChangedFile;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerConfigValidationPolicy;
+import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
+import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
+import com.google.gerrit.plugins.codeowners.metrics.ValidationTrigger;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
@@ -59,7 +66,6 @@
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
@@ -125,6 +131,7 @@
 public class CodeOwnerConfigValidator implements CommitValidationListener, MergeValidationListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private final String pluginName;
   private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
   private final GitRepositoryManager repoManager;
   private final ChangedFiles changedFiles;
@@ -134,9 +141,12 @@
   private final ChangeNotes.Factory changeNotesFactory;
   private final PatchSetUtil patchSetUtil;
   private final IdentifiedUser.GenericFactory userFactory;
+  private final SkipCodeOwnerConfigValidationPushOption skipCodeOwnerConfigValidationPushOption;
+  private final CodeOwnerMetrics codeOwnerMetrics;
 
   @Inject
   CodeOwnerConfigValidator(
+      @PluginName String pluginName,
       CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
       GitRepositoryManager repoManager,
       ChangedFiles changedFiles,
@@ -145,7 +155,10 @@
       ProjectCache projectCache,
       ChangeNotes.Factory changeNotesFactory,
       PatchSetUtil patchSetUtil,
-      IdentifiedUser.GenericFactory userFactory) {
+      IdentifiedUser.GenericFactory userFactory,
+      SkipCodeOwnerConfigValidationPushOption skipCodeOwnerConfigValidationPushOption,
+      CodeOwnerMetrics codeOwnerMetrics) {
+    this.pluginName = pluginName;
     this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
     this.repoManager = repoManager;
     this.changedFiles = changedFiles;
@@ -155,6 +168,8 @@
     this.changeNotesFactory = changeNotesFactory;
     this.patchSetUtil = patchSetUtil;
     this.userFactory = userFactory;
+    this.skipCodeOwnerConfigValidationPushOption = skipCodeOwnerConfigValidationPushOption;
+    this.codeOwnerMetrics = codeOwnerMetrics;
   }
 
   @Override
@@ -170,14 +185,17 @@
                 .username(receiveEvent.user.getLoggableName())
                 .build())) {
       CodeOwnerConfigValidationPolicy codeOwnerConfigValidationPolicy =
-          codeOwnersPluginConfiguration.getCodeOwnerConfigValidationPolicyForCommitReceived(
-              receiveEvent.getProjectNameKey());
+          codeOwnersPluginConfiguration
+              .getProjectConfig(receiveEvent.getProjectNameKey())
+              .getCodeOwnerConfigValidationPolicyForCommitReceived(receiveEvent.refName);
       logger.atFine().log("codeOwnerConfigValidationPolicy = %s", codeOwnerConfigValidationPolicy);
+      boolean metricRecordingDone = false;
       Optional<ValidationResult> validationResult;
       if (!codeOwnerConfigValidationPolicy.runValidation()) {
         validationResult =
             Optional.of(
                 ValidationResult.create(
+                    pluginName,
                     "skipping validation of code owner config files",
                     new CommitValidationMessage(
                         "code owners config validation is disabled", ValidationMessage.Type.HINT)));
@@ -189,15 +207,23 @@
                   receiveEvent.repoConfig,
                   receiveEvent.revWalk,
                   receiveEvent.commit,
-                  receiveEvent.user);
+                  receiveEvent.user,
+                  codeOwnerConfigValidationPolicy.isForced(),
+                  receiveEvent.pushOptions);
         } catch (RuntimeException e) {
+          codeOwnerMetrics.countCodeOwnerConfigValidations.increment(
+              ValidationTrigger.COMMIT_RECEIVED,
+              com.google.gerrit.plugins.codeowners.metrics.ValidationResult.FAILED,
+              codeOwnerConfigValidationPolicy.isDryRun());
+          metricRecordingDone = true;
+
           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(
+          logger.atWarning().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",
               receiveEvent.commit.getName(),
@@ -211,6 +237,14 @@
       }
 
       logger.atFine().log("validation result = %s", validationResult.get());
+      if (!metricRecordingDone) {
+        codeOwnerMetrics.countCodeOwnerConfigValidations.increment(
+            ValidationTrigger.COMMIT_RECEIVED,
+            validationResult.get().hasError()
+                ? com.google.gerrit.plugins.codeowners.metrics.ValidationResult.REJECTED
+                : com.google.gerrit.plugins.codeowners.metrics.ValidationResult.PASSED,
+            codeOwnerConfigValidationPolicy.isDryRun());
+      }
       return validationResult
           .get()
           .processForOnCommitReceived(codeOwnerConfigValidationPolicy.isDryRun());
@@ -238,14 +272,16 @@
                 .patchSetId(patchSetId.get())
                 .build())) {
       CodeOwnerConfigValidationPolicy codeOwnerConfigValidationPolicy =
-          codeOwnersPluginConfiguration.getCodeOwnerConfigValidationPolicyForSubmit(
-              branchNameKey.project());
+          codeOwnersPluginConfiguration
+              .getProjectConfig(branchNameKey.project())
+              .getCodeOwnerConfigValidationPolicyForSubmit(branchNameKey.branch());
       logger.atFine().log("codeOwnerConfigValidationPolicy = %s", codeOwnerConfigValidationPolicy);
       Optional<ValidationResult> validationResult;
       if (!codeOwnerConfigValidationPolicy.runValidation()) {
         validationResult =
             Optional.of(
                 ValidationResult.create(
+                    pluginName,
                     "skipping validation of code owner config files",
                     new CommitValidationMessage(
                         "code owners config validation is disabled", ValidationMessage.Type.HINT)));
@@ -257,8 +293,19 @@
           IdentifiedUser patchSetUploader = userFactory.create(patchSet.uploader());
           validationResult =
               validateCodeOwnerConfig(
-                  branchNameKey, repository.getConfig(), revWalk, commit, patchSetUploader);
+                  branchNameKey,
+                  repository.getConfig(),
+                  revWalk,
+                  commit,
+                  patchSetUploader,
+                  codeOwnerConfigValidationPolicy.isForced(),
+                  /* pushOptions= */ ImmutableListMultimap.of());
         } catch (RuntimeException e) {
+          codeOwnerMetrics.countCodeOwnerConfigValidations.increment(
+              ValidationTrigger.PRE_MERGE,
+              com.google.gerrit.plugins.codeowners.metrics.ValidationResult.FAILED,
+              codeOwnerConfigValidationPolicy.isDryRun());
+
           if (!codeOwnerConfigValidationPolicy.isDryRun()) {
             throw e;
           }
@@ -274,6 +321,12 @@
       }
       if (validationResult.isPresent()) {
         logger.atFine().log("validation result = %s", validationResult.get());
+        codeOwnerMetrics.countCodeOwnerConfigValidations.increment(
+            ValidationTrigger.PRE_MERGE,
+            validationResult.get().hasError()
+                ? com.google.gerrit.plugins.codeowners.metrics.ValidationResult.REJECTED
+                : com.google.gerrit.plugins.codeowners.metrics.ValidationResult.PASSED,
+            codeOwnerConfigValidationPolicy.isDryRun());
         validationResult.get().processForOnPreMerge(codeOwnerConfigValidationPolicy.isDryRun());
       }
     }
@@ -288,6 +341,8 @@
    * @param revCommit the commit for which newly added and modified code owner configs should be
    *     validated
    * @param user user for which the code owner visibility checks should be performed
+   * @param force whether the validation should be done even if the code owners functionality is
+   *     disabled for the branch
    * @return the validation result, {@link Optional#empty()} if no validation is performed because
    *     the given commit doesn't contain newly added or modified code owner configs
    */
@@ -296,17 +351,54 @@
       Config repoConfig,
       RevWalk revWalk,
       RevCommit revCommit,
-      IdentifiedUser user) {
-    if (codeOwnersPluginConfiguration.isDisabled(branchNameKey)) {
+      IdentifiedUser user,
+      boolean force,
+      ImmutableListMultimap<String, String> pushOptions) {
+    CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig =
+        codeOwnersPluginConfiguration.getProjectConfig(branchNameKey.project());
+    logger.atFine().log("force = %s", force);
+    if (!force && codeOwnersConfig.isDisabled(branchNameKey.branch())) {
       return Optional.of(
           ValidationResult.create(
+              pluginName,
               "skipping validation of code owner config files",
               new CommitValidationMessage(
                   "code-owners functionality is disabled", ValidationMessage.Type.HINT)));
     }
-    if (codeOwnersPluginConfiguration.areCodeOwnerConfigsReadOnly(branchNameKey.project())) {
+
+    try {
+      if (skipCodeOwnerConfigValidationPushOption.skipValidation(pushOptions)) {
+        logger.atFine().log("skip validation requested");
+        return Optional.of(
+            ValidationResult.create(
+                pluginName,
+                "skipping validation of code owner config files",
+                new CommitValidationMessage(
+                    String.format(
+                        "the validation is skipped due to the --%s~%s push option",
+                        pluginName, SkipCodeOwnerConfigValidationPushOption.NAME),
+                    ValidationMessage.Type.HINT)));
+      }
+    } catch (AuthException e) {
+      logger.atFine().withCause(e).log("Not allowed to skip code owner config validation");
       return Optional.of(
           ValidationResult.create(
+              pluginName,
+              "skipping code owner config validation not allowed",
+              new CommitValidationMessage(e.getMessage(), ValidationMessage.Type.ERROR)));
+    } catch (SkipCodeOwnerConfigValidationPushOption.InvalidValueException e) {
+      logger.atFine().log(e.getMessage());
+      return Optional.of(
+          ValidationResult.create(
+              pluginName,
+              "invalid push option",
+              new CommitValidationMessage(e.getMessage(), ValidationMessage.Type.ERROR)));
+    }
+
+    if (codeOwnersConfig.areCodeOwnerConfigsReadOnly()) {
+      return Optional.of(
+          ValidationResult.create(
+              pluginName,
               "modifying code owner config files not allowed",
               new CommitValidationMessage(
                   "code owner config files are configured to be read-only",
@@ -314,15 +406,14 @@
     }
 
     try {
-      CodeOwnerBackend codeOwnerBackend = codeOwnersPluginConfiguration.getBackend(branchNameKey);
+      CodeOwnerBackend codeOwnerBackend = codeOwnersConfig.getBackend(branchNameKey.branch());
 
       // For merge commits, always do the comparison against the destination branch
       // (MergeCommitStrategy.ALL_CHANGED_FILES). Doing the comparison against the auto-merge
-      // (MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION) is not possible because the auto-merge
-      // is loaded via the PatchListCache to which we cannot pass the rev walk which should be used
-      // to load the newly created merge commit and hence trying to load it from PatchListCache
-      // would fail with a missing object exception. This is why we use
-      // MergeCommitStrategy.ALL_CHANGED_FILES here even if
+      // (MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION) is not possible because loading the
+      // auto-merge cannot reuse the rev walk that can see newly created merge commits and hence
+      // trying to get the auto merge would fail with a missing object exception. This is why we
+      // use MergeCommitStrategy.ALL_CHANGED_FILES here even if
       // MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION is configured.
       ImmutableList<ChangedFile> modifiedCodeOwnerConfigFiles =
           changedFiles
@@ -352,6 +443,7 @@
       // validate the code owner config files
       return Optional.of(
           ValidationResult.create(
+              pluginName,
               modifiedCodeOwnerConfigFiles.stream()
                   .flatMap(
                       changedFile ->
@@ -375,19 +467,20 @@
               e.getMessage()));
       return Optional.of(
           ValidationResult.create(
+              pluginName,
               "skipping validation of code owner config files",
               new CommitValidationMessage(
                   "code-owners plugin configuration is invalid,"
                       + " cannot validate code owner config files",
                   ValidationMessage.Type.WARNING)));
-    } catch (IOException | PatchListNotAvailableException e) {
+    } catch (IOException e) {
       String errorMessage =
           String.format(
               "failed to validate code owner config files in revision %s"
                   + " (project = %s, branch = %s)",
               revCommit.getName(), branchNameKey.project(), branchNameKey.branch());
       logger.atSevere().withCause(e).log(errorMessage);
-      throw new StorageException(errorMessage, e);
+      throw new CodeOwnersInternalServerErrorException(errorMessage, e);
     }
   }
 
@@ -425,7 +518,8 @@
     CodeOwnerConfig codeOwnerConfig;
     try {
       // Load the code owner config. If the code owner config is not parsable this will fail with a
-      // InvalidConfigException (wrapped in a StorageException) that we handle below.
+      // InvalidConfigException (wrapped in a CodeOwnersInternalServerErrorException) that we handle
+      // below.
       CodeOwnerConfig.Key codeOwnerConfigKey =
           createCodeOwnerConfigKey(branchNameKey, changedFile.newPath().get());
       codeOwnerConfig =
@@ -441,13 +535,13 @@
                           String.format(
                               "code owner config %s not found in revision %s",
                               codeOwnerConfigKey, revCommit.name())));
-    } catch (StorageException storageException) {
+    } catch (CodeOwnersInternalServerErrorException codeOwnersInternalServerErrorException) {
       // Loading the code owner config has failed.
-      Optional<ConfigInvalidException> configInvalidException =
-          getInvalidConfigCause(storageException);
-      if (!configInvalidException.isPresent()) {
+      Optional<InvalidCodeOwnerConfigException> invalidCodeOwnerConfigException =
+          getInvalidCodeOwnerConfigCause(codeOwnersInternalServerErrorException);
+      if (!invalidCodeOwnerConfigException.isPresent()) {
         // Propagate any failure that is not related to the contents of the code owner config.
-        throw storageException;
+        throw codeOwnersInternalServerErrorException;
       }
 
       // The exception was caused by a ConfigInvalidException. This means loading the code owner
@@ -457,7 +551,7 @@
       // it.
       return Stream.of(
           new CommitValidationMessage(
-              configInvalidException.get().getMessage(),
+              invalidCodeOwnerConfigException.get().getMessage(),
               getValidationMessageTypeForParsingError(
                   codeOwnerBackend, branchNameKey, changedFile, revWalk, revCommit)));
     }
@@ -471,26 +565,32 @@
     try {
       baseCodeOwnerConfig =
           getBaseCodeOwnerConfig(codeOwnerBackend, branchNameKey, changedFile, revWalk, revCommit);
-    } catch (StorageException storageException) {
-      if (getInvalidConfigCause(storageException).isPresent()) {
+    } catch (CodeOwnersInternalServerErrorException codeOwnersInternalServerErrorException) {
+      if (getInvalidCodeOwnerConfigCause(codeOwnersInternalServerErrorException).isPresent()) {
         // The base code owner config is non-parseable. Since the update makes the code owner
         // config parseable, it is a good update even if the code owner config still contains
         // issues. Hence in this case we downgrade all validation errors in the new version to
         // warnings so that the update is not blocked.
-        return validateCodeOwnerConfig(user, codeOwnerBackend, codeOwnerConfig)
+        return validateCodeOwnerConfig(
+                branchNameKey, revWalk, user, codeOwnerBackend, codeOwnerConfig)
             .map(CodeOwnerConfigValidator::downgradeErrorToWarning);
       }
 
       // Propagate any exception that was not caused by the content of the code owner config.
-      throw storageException;
+      throw codeOwnersInternalServerErrorException;
     }
 
     // Validate the parsed code owner config.
     if (baseCodeOwnerConfig.isPresent()) {
       return validateCodeOwnerConfig(
-          user, codeOwnerBackend, codeOwnerConfig, baseCodeOwnerConfig.get());
+          branchNameKey,
+          revWalk,
+          user,
+          codeOwnerBackend,
+          codeOwnerConfig,
+          baseCodeOwnerConfig.get());
     }
-    return validateCodeOwnerConfig(user, codeOwnerBackend, codeOwnerConfig);
+    return validateCodeOwnerConfig(branchNameKey, revWalk, user, codeOwnerBackend, codeOwnerConfig);
   }
 
   /**
@@ -512,8 +612,9 @@
   /**
    * Loads and returns the base code owner config if it exists.
    *
-   * <p>Throws a {@link ConfigInvalidException} (wrapped in a {@link StorageException} if the base
-   * code owner config exists, but is not parseable.
+   * <p>Throws a {@link ConfigInvalidException} (wrapped in a {@link
+   * CodeOwnersInternalServerErrorException} if the base code owner config exists, but is not
+   * parseable.
    *
    * @param codeOwnerBackend the code owner backend from which the base code owner config can be
    *     loaded
@@ -597,16 +698,16 @@
         // is introduced by the new commit and we should block uploading it, which we achieve by
         // setting the validation message type to fatal.
         return ValidationMessage.Type.FATAL;
-      } catch (StorageException storageException) {
+      } catch (CodeOwnersInternalServerErrorException codeOwnersInternalServerErrorException) {
         // Loading the base code owner config has failed.
-        if (getInvalidConfigCause(storageException).isPresent()) {
+        if (getInvalidCodeOwnerConfigCause(codeOwnersInternalServerErrorException).isPresent()) {
           // The code owner config was already non-parseable before, hence we do not need to
           // block the upload if the code owner config is still non-parseable.
           // Using warning as type means that uploads are not blocked.
           return ValidationMessage.Type.WARNING;
         }
         // Propagate any failure that is not related to the contents of the code owner config.
-        throw storageException;
+        throw codeOwnersInternalServerErrorException;
       }
     }
 
@@ -655,6 +756,8 @@
    * <p>Validation errors that exist in both code owner configs are returned as warning (because
    * they are not newly introduced by the given code owner config).
    *
+   * @param branchNameKey the branch and the project
+   * @param revWalk rev walk that should be used to load the code owner configs
    * @param user user for which the code owner visibility checks should be performed
    * @param codeOwnerBackend the code owner backend from which the code owner configs were loaded
    * @param codeOwnerConfig the code owner config that should be validated
@@ -663,6 +766,8 @@
    *     empty stream if there are no issues
    */
   private Stream<CommitValidationMessage> validateCodeOwnerConfig(
+      BranchNameKey branchNameKey,
+      RevWalk revWalk,
       IdentifiedUser user,
       CodeOwnerBackend codeOwnerBackend,
       CodeOwnerConfig codeOwnerConfig,
@@ -671,9 +776,9 @@
     requireNonNull(baseCodeOwnerConfig, "baseCodeOwnerConfig");
 
     ImmutableSet<CommitValidationMessage> issuesInBaseVersion =
-        validateCodeOwnerConfig(user, codeOwnerBackend, baseCodeOwnerConfig)
+        validateCodeOwnerConfig(branchNameKey, revWalk, user, codeOwnerBackend, baseCodeOwnerConfig)
             .collect(toImmutableSet());
-    return validateCodeOwnerConfig(user, codeOwnerBackend, codeOwnerConfig)
+    return validateCodeOwnerConfig(branchNameKey, revWalk, user, codeOwnerBackend, codeOwnerConfig)
         .map(
             commitValidationMessage ->
                 issuesInBaseVersion.contains(commitValidationMessage)
@@ -684,6 +789,8 @@
   /**
    * Validates the given code owner config and returns validation issues as stream.
    *
+   * @param branchNameKey the branch and the project
+   * @param revWalk rev walk that should be used to load the code owner configs from {@code project}
    * @param user user for which the code owner visibility checks should be performed
    * @param codeOwnerBackend the code owner backend from which the code owner config was loaded
    * @param codeOwnerConfig the code owner config that should be validated
@@ -691,17 +798,29 @@
    *     empty stream if there are no issues
    */
   public Stream<CommitValidationMessage> validateCodeOwnerConfig(
-      IdentifiedUser user, CodeOwnerBackend codeOwnerBackend, CodeOwnerConfig codeOwnerConfig) {
+      BranchNameKey branchNameKey,
+      RevWalk revWalk,
+      IdentifiedUser user,
+      CodeOwnerBackend codeOwnerBackend,
+      CodeOwnerConfig codeOwnerConfig) {
     requireNonNull(codeOwnerConfig, "codeOwnerConfig");
     return Streams.concat(
         validateCodeOwnerReferences(
-            user, codeOwnerBackend.getFilePath(codeOwnerConfig.key()), codeOwnerConfig),
-        validateImports(codeOwnerBackend.getFilePath(codeOwnerConfig.key()), codeOwnerConfig));
+            branchNameKey,
+            user,
+            codeOwnerBackend.getFilePath(codeOwnerConfig.key()),
+            codeOwnerConfig),
+        validateImports(
+            branchNameKey,
+            revWalk,
+            codeOwnerBackend.getFilePath(codeOwnerConfig.key()),
+            codeOwnerConfig));
   }
 
   /**
    * Validates the code owner references of the given code owner config.
    *
+   * @param branchNameKey the branch and the project
    * @param user user for which the code owner visibility checks should be performed
    * @param codeOwnerConfigFilePath the path of the code owner config file which contains the code
    *     owner references
@@ -711,12 +830,16 @@
    *     empty stream if there are no issues
    */
   private Stream<CommitValidationMessage> validateCodeOwnerReferences(
-      IdentifiedUser user, Path codeOwnerConfigFilePath, CodeOwnerConfig codeOwnerConfig) {
+      BranchNameKey branchNameKey,
+      IdentifiedUser user,
+      Path codeOwnerConfigFilePath,
+      CodeOwnerConfig codeOwnerConfig) {
     return codeOwnerConfig.codeOwnerSets().stream()
         .flatMap(codeOwnerSet -> codeOwnerSet.codeOwners().stream())
         .map(
             codeOwnerReference ->
-                validateCodeOwnerReference(user, codeOwnerConfigFilePath, codeOwnerReference))
+                validateCodeOwnerReference(
+                    branchNameKey, user, codeOwnerConfigFilePath, codeOwnerReference))
         .filter(Optional::isPresent)
         .map(Optional::get);
   }
@@ -724,6 +847,7 @@
   /**
    * Validates a code owner reference.
    *
+   * @param branchNameKey the branch and the project
    * @param user user for which the code owner visibility checks should be performed
    * @param codeOwnerConfigFilePath the path of the code owner config file which contains the code
    *     owner reference
@@ -732,10 +856,14 @@
    *     Optional#empty()} if there is no issue
    */
   private Optional<CommitValidationMessage> validateCodeOwnerReference(
-      IdentifiedUser user, Path codeOwnerConfigFilePath, CodeOwnerReference codeOwnerReference) {
+      BranchNameKey branchNameKey,
+      IdentifiedUser user,
+      Path codeOwnerConfigFilePath,
+      CodeOwnerReference codeOwnerReference) {
     CodeOwnerResolver codeOwnerResolver = codeOwnerResolverProvider.get().forUser(user);
-    if (!codeOwnerResolver.isEmailDomainAllowed(codeOwnerReference.email())) {
-      return error(
+    if (!codeOwnerResolver.isEmailDomainAllowed(codeOwnerReference.email()).get()) {
+      return nonResolvableCodeOwner(
+          branchNameKey,
           String.format(
               "the domain of the code owner email '%s' in '%s' is not allowed for code owners",
               codeOwnerReference.email(), codeOwnerConfigFilePath));
@@ -752,7 +880,8 @@
     // CodeOwerResolver for details). We intentionally return the same generic message in all these
     // cases so that uploaders cannot probe emails for existence (e.g. they cannot add an email and
     // conclude from the error message whether the email exists).
-    return error(
+    return nonResolvableCodeOwner(
+        branchNameKey,
         String.format(
             "code owner email '%s' in '%s' cannot be resolved for %s",
             codeOwnerReference.email(), codeOwnerConfigFilePath, user.getLoggableName()));
@@ -761,6 +890,8 @@
   /**
    * Validates the imports of the given code owner config.
    *
+   * @param branchNameKey the branch and the project
+   * @param revWalk rev walk that should be used to load the code owner configs from {@code project}
    * @param codeOwnerConfigFilePath the path of the code owner config file which contains the code
    *     owner config
    * @param codeOwnerConfig the code owner config for which the imports should be validated
@@ -768,34 +899,49 @@
    *     if there are no issues
    */
   private Stream<CommitValidationMessage> validateImports(
-      Path codeOwnerConfigFilePath, CodeOwnerConfig codeOwnerConfig) {
-    return Streams.concat(
-            codeOwnerConfig.imports().stream()
-                .map(
-                    codeOwnerConfigReference ->
-                        validateCodeOwnerConfigReference(
-                            codeOwnerConfigFilePath,
-                            codeOwnerConfig.key(),
-                            codeOwnerConfig.revision(),
-                            CodeOwnerConfigImportType.GLOBAL,
-                            codeOwnerConfigReference)),
-            codeOwnerConfig.codeOwnerSets().stream()
-                .flatMap(codeOwnerSet -> codeOwnerSet.imports().stream())
-                .map(
-                    codeOwnerConfigReference ->
-                        validateCodeOwnerConfigReference(
-                            codeOwnerConfigFilePath,
-                            codeOwnerConfig.key(),
-                            codeOwnerConfig.revision(),
-                            CodeOwnerConfigImportType.PER_FILE,
-                            codeOwnerConfigReference)))
-        .filter(Optional::isPresent)
-        .map(Optional::get);
+      BranchNameKey branchNameKey,
+      RevWalk revWalk,
+      Path codeOwnerConfigFilePath,
+      CodeOwnerConfig codeOwnerConfig) {
+    try {
+      RevCommit codeOwnerConfigRevision = revWalk.parseCommit(codeOwnerConfig.revision());
+      return Streams.concat(
+              codeOwnerConfig.imports().stream()
+                  .map(
+                      codeOwnerConfigReference ->
+                          validateCodeOwnerConfigReference(
+                              branchNameKey,
+                              revWalk,
+                              codeOwnerConfigFilePath,
+                              codeOwnerConfig.key(),
+                              codeOwnerConfigRevision,
+                              CodeOwnerConfigImportType.GLOBAL,
+                              codeOwnerConfigReference)),
+              codeOwnerConfig.codeOwnerSets().stream()
+                  .flatMap(codeOwnerSet -> codeOwnerSet.imports().stream())
+                  .map(
+                      codeOwnerConfigReference ->
+                          validateCodeOwnerConfigReference(
+                              branchNameKey,
+                              revWalk,
+                              codeOwnerConfigFilePath,
+                              codeOwnerConfig.key(),
+                              codeOwnerConfigRevision,
+                              CodeOwnerConfigImportType.PER_FILE,
+                              codeOwnerConfigReference)))
+          .filter(Optional::isPresent)
+          .map(Optional::get);
+    } catch (IOException e) {
+      throw new CodeOwnersInternalServerErrorException(
+          String.format("Failed to validate imports for %s in ", codeOwnerConfig.key()), e);
+    }
   }
 
   /**
    * Validates a code owner config reference.
    *
+   * @param branchNameKey the branch and the project
+   * @param revWalk rev walk that should be used to load the code owner configs from {@code project}
    * @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
@@ -807,29 +953,45 @@
    *     Optional#empty()} if there is no issue
    */
   private Optional<CommitValidationMessage> validateCodeOwnerConfigReference(
+      BranchNameKey branchNameKey,
+      RevWalk revWalk,
       Path codeOwnerConfigFilePath,
       CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig,
-      ObjectId codeOwnerConfigRevision,
+      RevCommit codeOwnerConfigRevision,
       CodeOwnerConfigImportType importType,
       CodeOwnerConfigReference codeOwnerConfigReference) {
     CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
         PathCodeOwners.createKeyForImportedCodeOwnerConfig(
             keyOfImportingCodeOwnerConfig, codeOwnerConfigReference);
 
+    if (isSelfImport(keyOfImportingCodeOwnerConfig, keyOfImportedCodeOwnerConfig)) {
+      return nonResolvableImport(
+          importType,
+          codeOwnerConfigFilePath,
+          "code owner config imports itself",
+          ValidationMessage.Type.WARNING);
+    }
+
     Optional<ProjectState> projectState = projectCache.get(keyOfImportedCodeOwnerConfig.project());
     if (!projectState.isPresent() || !isProjectReadable(keyOfImportedCodeOwnerConfig)) {
       // we intentionally use the same error message for non-existing and non-readable projects so
       // that uploaders cannot probe for the existence of projects (e.g. deduce from the error
       // message whether a project exists)
-      return invalidImport(
+      return nonResolvableImport(
+          codeOwnerConfigRevision,
+          branchNameKey,
           importType,
+          codeOwnerConfigReference,
           codeOwnerConfigFilePath,
           String.format("project '%s' not found", keyOfImportedCodeOwnerConfig.project().get()));
     }
 
     if (!projectState.get().statePermitsRead()) {
-      return invalidImport(
+      return nonResolvableImport(
+          codeOwnerConfigRevision,
+          branchNameKey,
           importType,
+          codeOwnerConfigReference,
           codeOwnerConfigFilePath,
           String.format(
               "project '%s' has state '%s' that doesn't permit read",
@@ -844,8 +1006,11 @@
       // 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
       // message whether a branch exists)
-      return invalidImport(
+      return nonResolvableImport(
+          codeOwnerConfigRevision,
+          branchNameKey,
           importType,
+          codeOwnerConfigReference,
           codeOwnerConfigFilePath,
           String.format(
               "branch '%s' not found in project '%s'",
@@ -854,34 +1019,52 @@
     }
 
     CodeOwnerBackend codeOwnerBackend =
-        codeOwnersPluginConfiguration.getBackend(keyOfImportedCodeOwnerConfig.branchNameKey());
+        codeOwnersPluginConfiguration
+            .getProjectConfig(keyOfImportedCodeOwnerConfig.project())
+            .getBackend(keyOfImportedCodeOwnerConfig.branchNameKey().branch());
     if (!codeOwnerBackend.isCodeOwnerConfigFile(
         keyOfImportedCodeOwnerConfig.project(), codeOwnerConfigReference.fileName())) {
-      return invalidImport(
+      return nonResolvableImport(
+          codeOwnerConfigRevision,
+          branchNameKey,
           importType,
+          codeOwnerConfigReference,
           codeOwnerConfigFilePath,
           String.format(
               "'%s' is not a code owner config file", codeOwnerConfigReference.filePath()));
     }
 
     try {
-      if (!codeOwnerBackend
-          .getCodeOwnerConfig(keyOfImportedCodeOwnerConfig, revision.get())
-          .isPresent()) {
-        return invalidImport(
+      // If a code owner config is imported from the same project, we must use the provided rev
+      // walk, otherwise the revision may not be visible yet and trying to load a code owner config
+      // from it could fail with MissingObjectException.
+      Optional<CodeOwnerConfig> importedCodeOwnerConfig =
+          keyOfImportedCodeOwnerConfig.project().equals(branchNameKey.project())
+              ? codeOwnerBackend.getCodeOwnerConfig(
+                  keyOfImportedCodeOwnerConfig, revWalk, revision.get())
+              : codeOwnerBackend.getCodeOwnerConfig(keyOfImportedCodeOwnerConfig, revision.get());
+      if (!importedCodeOwnerConfig.isPresent()) {
+        return nonResolvableImport(
+            codeOwnerConfigRevision,
+            branchNameKey,
             importType,
+            codeOwnerConfigReference,
             codeOwnerConfigFilePath,
             String.format(
-                "'%s' does not exist (project = %s, branch = %s)",
+                "'%s' does not exist (project = %s, branch = %s, revision = %s)",
                 codeOwnerConfigReference.filePath(),
                 keyOfImportedCodeOwnerConfig.branchNameKey().project().get(),
-                keyOfImportedCodeOwnerConfig.branchNameKey().shortName()));
+                keyOfImportedCodeOwnerConfig.branchNameKey().shortName(),
+                revision.get().name()));
       }
-    } catch (StorageException storageException) {
-      if (getInvalidConfigCause(storageException).isPresent()) {
+    } catch (CodeOwnersInternalServerErrorException codeOwnersInternalServerErrorException) {
+      if (getInvalidCodeOwnerConfigCause(codeOwnersInternalServerErrorException).isPresent()) {
         // The imported code owner config is non-parseable.
-        return invalidImport(
+        return nonResolvableImport(
+            codeOwnerConfigRevision,
+            branchNameKey,
             importType,
+            codeOwnerConfigReference,
             codeOwnerConfigFilePath,
             String.format(
                 "'%s' is not parseable (project = %s, branch = %s)",
@@ -891,13 +1074,30 @@
       }
 
       // Propagate any exception that was not caused by the content of the code owner config.
-      throw storageException;
+      throw codeOwnersInternalServerErrorException;
     }
 
     // no issue found
     return Optional.empty();
   }
 
+  /** Whether the importing code owner config is the same as the imported code owner config. */
+  private boolean isSelfImport(
+      CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig,
+      CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig) {
+    return keyOfImportingCodeOwnerConfig.project().equals(keyOfImportedCodeOwnerConfig.project())
+        && keyOfImportingCodeOwnerConfig.ref().equals(keyOfImportedCodeOwnerConfig.ref())
+        && codeOwnersPluginConfiguration
+            .getProjectConfig(keyOfImportingCodeOwnerConfig.project())
+            .getBackend(keyOfImportingCodeOwnerConfig.branchNameKey().branch())
+            .getFilePath(keyOfImportingCodeOwnerConfig)
+            .equals(
+                codeOwnersPluginConfiguration
+                    .getProjectConfig(keyOfImportedCodeOwnerConfig.project())
+                    .getBackend(keyOfImportedCodeOwnerConfig.branchNameKey().branch())
+                    .getFilePath(keyOfImportedCodeOwnerConfig));
+  }
+
   private boolean isProjectReadable(CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig) {
     try {
       return permissionBackend
@@ -905,7 +1105,7 @@
           .project(keyOfImportedCodeOwnerConfig.project())
           .test(ProjectPermission.ACCESS);
     } catch (PermissionBackendException e) {
-      throw new StorageException(
+      throw new CodeOwnersInternalServerErrorException(
           "failed to check read permission for project of imported code owner config", e);
     }
   }
@@ -918,7 +1118,7 @@
           .ref(keyOfImportedCodeOwnerConfig.ref())
           .test(RefPermission.READ);
     } catch (PermissionBackendException e) {
-      throw new StorageException(
+      throw new CodeOwnersInternalServerErrorException(
           "failed to check read permission for branch of imported code owner config", e);
     }
   }
@@ -939,20 +1139,85 @@
       return Optional.ofNullable(repo.exactRef(keyOfImportedCodeOwnerConfig.ref()))
           .map(Ref::getObjectId);
     } catch (IOException e) {
-      throw new StorageException("failed to read revision of import code owner config", e);
+      throw new CodeOwnersInternalServerErrorException(
+          "failed to read revision of import code owner config", e);
     }
   }
 
-  private Optional<CommitValidationMessage> invalidImport(
-      CodeOwnerConfigImportType importType, Path codeOwnerConfigFilePath, String message) {
-    return error(
-        String.format(
-            "invalid %s import in '%s': %s",
-            importType.getType(), codeOwnerConfigFilePath, message));
+  private Optional<CommitValidationMessage> nonResolvableImport(
+      RevCommit codeOwnerConfigRevision,
+      BranchNameKey branchNameKey,
+      CodeOwnerConfigImportType importType,
+      CodeOwnerConfigReference codeOwnerConfigReference,
+      Path codeOwnerConfigFilePath,
+      String message) {
+    ValidationMessage.Type validationMessageType;
+    if (codeOwnerConfigRevision.getParentCount() > 1
+        && !codeOwnerConfigReference.branch().isPresent()
+        && codeOwnerConfigReference.project().isPresent()
+        && !codeOwnerConfigReference.project().get().equals(branchNameKey.project())) {
+      // For merge commits, imports from other projects, that implicitly assume the same branch as
+      // the importing code owner config, should not be rejected if they cannot be resolved. Hence
+      // issues with them are always reported as warnings (rather than errors which would cause a
+      // rejection).
+      //
+      // We do not reject such non-resolvable imports because that can require landing merge
+      // commits in a certain order or even make the landing of merge commits impossible.
+      //
+      // Example 1:
+      // 1. project A adds foo/OWNERS
+      // 2. project B imports A:foo/OWNERS
+      // => If these changes should be merged into another branch (e.g. a release branch) the
+      // validation of the code owner config files only succeeds if the merges are done in the
+      // correct order (1. do merge for project A, 2. do merge for project B). If the merges are
+      // done in the opposite order (1. do merge for project B, 2. do merge for project A) the code
+      // owner config file validation for the merge in project B would fail since A:foo/OWNERS
+      // doesn't exist in the target branch yet.
+      //
+      // Example 2:
+      // 1. project A adds foo/OWNERS
+      // 2. project B imports A:foo/OWNERS
+      // 3. project B adds bar/OWNERS
+      // 4. project A imports B:bar/OWNERS
+      // => If the merge for project A is done first the code owner config file validation would
+      // fail because B:bar/OWNERS doesn't exist yet. If the merge for project B is done first the
+      // code owner config file validation would fail because A:foo/OWNERS doesn't exist yet.
+      validationMessageType = ValidationMessage.Type.WARNING;
+    } else {
+      validationMessageType =
+          codeOwnersPluginConfiguration
+                  .getProjectConfig(branchNameKey.project())
+                  .rejectNonResolvableImports(branchNameKey.branch())
+              ? ValidationMessage.Type.ERROR
+              : ValidationMessage.Type.WARNING;
+    }
+
+    return nonResolvableImport(importType, codeOwnerConfigFilePath, message, validationMessageType);
   }
 
-  private Optional<CommitValidationMessage> error(String message) {
-    return Optional.of(new CommitValidationMessage(message, ValidationMessage.Type.ERROR));
+  private Optional<CommitValidationMessage> nonResolvableImport(
+      CodeOwnerConfigImportType importType,
+      Path codeOwnerConfigFilePath,
+      String message,
+      ValidationMessage.Type validationMessageType) {
+    return Optional.of(
+        new CommitValidationMessage(
+            String.format(
+                "invalid %s import in '%s': %s",
+                importType.getType(), codeOwnerConfigFilePath, message),
+            validationMessageType));
+  }
+
+  private Optional<CommitValidationMessage> nonResolvableCodeOwner(
+      BranchNameKey branchNameKey, String message) {
+    return Optional.of(
+        new CommitValidationMessage(
+            message,
+            codeOwnersPluginConfiguration
+                    .getProjectConfig(branchNameKey.project())
+                    .rejectNonResolvableCodeOwners(branchNameKey.branch())
+                ? ValidationMessage.Type.ERROR
+                : ValidationMessage.Type.WARNING));
   }
 
   /** The result of validating code owner config files. */
@@ -962,21 +1227,26 @@
         "code owner config files validated, no issues found";
     private static final String INVALID_MSG = "invalid code owner config files";
 
+    abstract String pluginName();
+
     abstract String summaryMessage();
 
     abstract ImmutableList<CommitValidationMessage> validationMessages();
 
     static ValidationResult create(
-        String summaryMessage, CommitValidationMessage commitValidationMessage) {
+        String pluginName, String summaryMessage, CommitValidationMessage commitValidationMessage) {
       return new AutoValue_CodeOwnerConfigValidator_ValidationResult(
-          summaryMessage, ImmutableList.of(commitValidationMessage));
+          pluginName, summaryMessage, ImmutableList.of(commitValidationMessage));
     }
 
-    static ValidationResult create(Stream<CommitValidationMessage> validationMessagesStream) {
+    static ValidationResult create(
+        String pluginName, Stream<CommitValidationMessage> validationMessagesStream) {
       ImmutableList<CommitValidationMessage> validationMessages =
           validationMessagesStream.collect(toImmutableList());
       return new AutoValue_CodeOwnerConfigValidator_ValidationResult(
-          validationMessages.isEmpty() ? NO_ISSUES_MSG : INVALID_MSG, validationMessages);
+          pluginName,
+          validationMessages.isEmpty() ? NO_ISSUES_MSG : INVALID_MSG,
+          validationMessages);
     }
 
     /**
@@ -991,7 +1261,8 @@
     List<CommitValidationMessage> processForOnCommitReceived(boolean dryRun)
         throws CommitValidationException {
       if (!dryRun && hasError()) {
-        throw new CommitValidationException(summaryMessage(), validationMessages());
+        throw new CommitValidationException(
+            withPluginName(summaryMessage()), withPluginName(validationMessages()));
       }
 
       return validationMessagesWithIncludedSummaryMessage();
@@ -1020,7 +1291,7 @@
     }
 
     /** Checks whether any of the validation messages is an error. */
-    private boolean hasError() {
+    public boolean hasError() {
       return validationMessages().stream()
           .anyMatch(
               validationMessage ->
@@ -1032,8 +1303,8 @@
       return ImmutableList.<CommitValidationMessage>builder()
           .add(
               new CommitValidationMessage(
-                  summaryMessage(), getValidationMessageTypeForSummaryMessage()))
-          .addAll(validationMessages())
+                  withPluginName(summaryMessage()), getValidationMessageTypeForSummaryMessage()))
+          .addAll(withPluginName(validationMessages()))
           .build();
     }
 
@@ -1081,7 +1352,7 @@
      */
     private String getMessage(List<CommitValidationMessage> validationMessages) {
       checkState(!validationMessages.isEmpty(), "expected at least 1 validation message");
-      StringBuilder msgBuilder = new StringBuilder(summaryMessage()).append(":");
+      StringBuilder msgBuilder = new StringBuilder(withPluginName(summaryMessage())).append(":");
       for (CommitValidationMessage msg : validationMessages) {
         msgBuilder
             .append("\n  ")
@@ -1091,5 +1362,16 @@
       }
       return msgBuilder.toString();
     }
+
+    private String withPluginName(String message) {
+      return "[" + pluginName() + "] " + message;
+    }
+
+    private ImmutableList<CommitValidationMessage> withPluginName(
+        ImmutableList<CommitValidationMessage> validationMessages) {
+      return validationMessages.stream()
+          .map(msg -> new CommitValidationMessage(withPluginName(msg.getMessage()), msg.getType()))
+          .collect(toImmutableList());
+    }
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/validation/SkipCodeOwnerConfigValidationCapability.java b/java/com/google/gerrit/plugins/codeowners/validation/SkipCodeOwnerConfigValidationCapability.java
new file mode 100644
index 0000000..1fcb717
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/validation/SkipCodeOwnerConfigValidationCapability.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.validation;
+
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.api.access.PluginPermission;
+import com.google.gerrit.extensions.config.CapabilityDefinition;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/**
+ * Global capability that allows a user to skip the code owner config validation on push via the
+ * {@code code-owners~skip-validation} push option.
+ */
+@Singleton
+public class SkipCodeOwnerConfigValidationCapability extends CapabilityDefinition {
+  public static final String ID = "canSkipCodeOwnerConfigValidation";
+
+  private final String pluginName;
+
+  @Inject
+  SkipCodeOwnerConfigValidationCapability(@PluginName String pluginName) {
+    this.pluginName = pluginName;
+  }
+
+  @Override
+  public String getDescription() {
+    return "Can Skip Code Owner Config Validation";
+  }
+
+  public PluginPermission getPermission() {
+    return new PluginPermission(pluginName, ID, /* fallBackToAdmin= */ true);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/validation/SkipCodeOwnerConfigValidationPushOption.java b/java/com/google/gerrit/plugins/codeowners/validation/SkipCodeOwnerConfigValidationPushOption.java
new file mode 100644
index 0000000..bc7ac6b
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/validation/SkipCodeOwnerConfigValidationPushOption.java
@@ -0,0 +1,127 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.validation;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnersInternalServerErrorException;
+import com.google.gerrit.server.git.receive.PluginPushOption;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/** Push option that allows to skip the code owner config validation. */
+@Singleton
+public class SkipCodeOwnerConfigValidationPushOption implements PluginPushOption {
+  public static final String NAME = "skip-validation";
+
+  private static final String DESCRIPTION = "skips the code owner config validation";
+
+  private final String pluginName;
+  private final PermissionBackend permissionBackend;
+  private final SkipCodeOwnerConfigValidationCapability skipCodeOwnerConfigValidationCapability;
+
+  @Inject
+  SkipCodeOwnerConfigValidationPushOption(
+      @PluginName String pluginName,
+      PermissionBackend permissionBackend,
+      SkipCodeOwnerConfigValidationCapability skipCodeOwnerConfigValidationCapability) {
+    this.pluginName = pluginName;
+    this.permissionBackend = permissionBackend;
+    this.skipCodeOwnerConfigValidationCapability = skipCodeOwnerConfigValidationCapability;
+  }
+
+  @Override
+  public String getName() {
+    return NAME;
+  }
+
+  @Override
+  public String getDescription() {
+    return DESCRIPTION;
+  }
+
+  /**
+   * Whether the code owner config validation should be skipped.
+   *
+   * <p>Only returns {@code true} if the {@code --code-owners~skip-validation} push option was
+   * specified and the calling user is allowed to skip the code owner config validation (requires
+   * the {@link SkipCodeOwnerConfigValidationCapability}).
+   *
+   * @param pushOptions the push options that have been specified on the push
+   * @return {@code true} if the {@code --code-owners~skip-validation} push option was specified and
+   *     the calling user is allowed to skip the code owner config validation
+   * @throws InvalidValueException if the {@code --code-owners~skip-validation} push option was
+   *     specified with an invalid value or if the {@code --code-owners~skip-validation} push option
+   *     was specified multiple times
+   * @throws AuthException thrown if the {@code --code-owners~skip-validation} push option was
+   *     specified, but the calling user is not allowed to skip the code owner config validation
+   */
+  public boolean skipValidation(ImmutableListMultimap<String, String> pushOptions)
+      throws InvalidValueException, AuthException {
+    String qualifiedName = pluginName + "~" + NAME;
+    if (!pushOptions.containsKey(qualifiedName)) {
+      return false;
+    }
+    ImmutableList<String> values = pushOptions.get(qualifiedName);
+    if (values.size() != 1) {
+      throw new InvalidValueException(values);
+    }
+
+    String value = values.get(0);
+    if (Boolean.parseBoolean(value) || value.isEmpty()) {
+      canSkipCodeOwnerConfigValidation();
+      return true;
+    }
+
+    if (value.equalsIgnoreCase(Boolean.FALSE.toString())) {
+      return false;
+    }
+
+    // value was neither 'true', 'false' nor empty
+    throw new InvalidValueException(values);
+  }
+
+  private void canSkipCodeOwnerConfigValidation() throws AuthException {
+    try {
+      permissionBackend
+          .currentUser()
+          .check(skipCodeOwnerConfigValidationCapability.getPermission());
+    } catch (PermissionBackendException e) {
+      throw new CodeOwnersInternalServerErrorException(
+          String.format(
+              "Failed to check %s capability", SkipCodeOwnerConfigValidationCapability.ID),
+          e);
+    }
+  }
+
+  public class InvalidValueException extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    InvalidValueException(ImmutableList<String> invalidValues) {
+      super(
+          invalidValues.size() == 1
+              ? String.format(
+                  "Invalid value for --%s~%s push option: %s",
+                  pluginName, NAME, invalidValues.get(0))
+              : String.format(
+                  "--%s~%s push option can be specified only once, received multiple values: %s",
+                  pluginName, NAME, invalidValues));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/validation/ValidationModule.java b/java/com/google/gerrit/plugins/codeowners/validation/ValidationModule.java
index a5d06ee..d28925c 100644
--- a/java/com/google/gerrit/plugins/codeowners/validation/ValidationModule.java
+++ b/java/com/google/gerrit/plugins/codeowners/validation/ValidationModule.java
@@ -14,7 +14,10 @@
 
 package com.google.gerrit.plugins.codeowners.validation;
 
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.git.receive.PluginPushOption;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.MergeValidationListener;
 import com.google.inject.AbstractModule;
@@ -25,5 +28,11 @@
   protected void configure() {
     DynamicSet.bind(binder(), CommitValidationListener.class).to(CodeOwnerConfigValidator.class);
     DynamicSet.bind(binder(), MergeValidationListener.class).to(CodeOwnerConfigValidator.class);
+
+    bind(CapabilityDefinition.class)
+        .annotatedWith(Exports.named(SkipCodeOwnerConfigValidationCapability.ID))
+        .to(SkipCodeOwnerConfigValidationCapability.class);
+    DynamicSet.bind(binder(), PluginPushOption.class)
+        .to(SkipCodeOwnerConfigValidationPushOption.class);
   }
 }
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 e6d8955..2688b07 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/AbstractGetCodeOwnersForPathIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/AbstractGetCodeOwnersForPathIT.java
@@ -17,11 +17,12 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
-import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerInfoSubject.assertThatList;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerInfoSubject.hasAccountId;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerInfoSubject.hasAccountName;
+import static com.google.gerrit.plugins.codeowners.testing.CodeOwnersInfoSubject.assertThat;
 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 com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.TestAccount;
@@ -31,8 +32,10 @@
 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.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.client.ListAccountsOption;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -41,12 +44,19 @@
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.TestCodeOwnerConfigCreation;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.TestPathExpressions;
-import com.google.gerrit.plugins.codeowners.api.CodeOwnerInfo;
 import com.google.gerrit.plugins.codeowners.api.CodeOwners;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnersInfo;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigImportMode;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigReference;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerResolver;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerSet;
+import com.google.gerrit.plugins.codeowners.restapi.CheckCodeOwnerCapability;
 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;
 
@@ -78,18 +88,18 @@
   /** Must return the {@link CodeOwners} API against which the tests should be run. */
   protected abstract CodeOwners getCodeOwnersApi() throws RestApiException;
 
-  protected List<CodeOwnerInfo> queryCodeOwners(String path) throws RestApiException {
+  protected CodeOwnersInfo queryCodeOwners(String path) throws RestApiException {
     return queryCodeOwners(getCodeOwnersApi().query(), path);
   }
 
-  protected List<CodeOwnerInfo> queryCodeOwners(CodeOwners.QueryRequest queryRequest, String path)
+  protected CodeOwnersInfo queryCodeOwners(CodeOwners.QueryRequest queryRequest, String path)
       throws RestApiException {
     return queryRequest.get(path);
   }
 
   @Test
   public void getCodeOwnersWhenNoCodeOwnerConfigsExist() throws Exception {
-    assertThat(queryCodeOwners("/foo/bar/baz.md")).isEmpty();
+    assertThat(queryCodeOwners("/foo/bar/baz.md")).hasCodeOwnersThat().isEmpty();
   }
 
   @Test
@@ -101,18 +111,27 @@
         .folderPath("/abc/def/")
         .addCodeOwnerEmail(admin.email())
         .addCodeOwnerEmail(user.email())
+        .addCodeOwnerEmail(CodeOwnerResolver.ALL_USERS_WILDCARD)
         .create();
-    assertThat(queryCodeOwners("/foo/bar/baz.md")).isEmpty();
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo).hasCodeOwnersThat().isEmpty();
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isNull();
   }
 
   @Test
   public void getCodeOwnersForAbsolutePath() throws Exception {
-    testGetCodeOwners(true);
+    testGetCodeOwners(/* useAbsolutePath= */ true);
   }
 
   @Test
   public void getCodeOwnersForNonAbsolutePath() throws Exception {
-    testGetCodeOwners(false);
+    testGetCodeOwners(/* useAbsolutePath= */ false);
+  }
+
+  @Test
+  public void getCodeOwnersForAnonymousUser() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    testGetCodeOwners(/* useAbsolutePath= */ true);
   }
 
   private void testGetCodeOwners(boolean useAbsolutePath) throws Exception {
@@ -142,15 +161,19 @@
         .addCodeOwnerEmail(user2.email())
         .create();
 
-    List<CodeOwnerInfo> codeOwnerInfos =
+    CodeOwnersInfo codeOwnersInfo =
         queryCodeOwners(useAbsolutePath ? "/foo/bar/baz.md" : "foo/bar/baz.md");
-    assertThat(codeOwnerInfos)
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(user2.id(), user.id(), admin.id())
         .inOrder();
-    assertThat(codeOwnerInfos)
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountName())
         .containsExactly(null, null, null);
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isNull();
+    assertThat(codeOwnersInfo).hasDebugLogsThat().isNull();
   }
 
   @Test
@@ -181,15 +204,16 @@
         .addCodeOwnerEmail(user.email())
         .create();
 
-    // 3. code owner config that makes "admin" a code owner, but for this test this code owner
-    // config is ignored, since the 2. code owner config ignores code owners from parent code owner
-    // configs
+    // 3. code owner config that makes "admin" a code owner and assigns code ownership to all users,
+    // but for this test this code owner config is ignored, since the 2. code owner config ignores
+    // code owners from parent code owner configs
     codeOwnerConfigOperations
         .newCodeOwnerConfig()
         .project(project)
         .branch("master")
         .folderPath("/")
         .addCodeOwnerEmail(admin.email())
+        .addCodeOwnerEmail(CodeOwnerResolver.ALL_USERS_WILDCARD)
         .create();
 
     // Assert the code owners for "/foo/bar/baz.md". This evaluates the code owner configs in the
@@ -197,11 +221,13 @@
     // The 3. code owner config is ignored since the 2. code owner config has set
     // 'ignoreParentCodeOwners=true'. Hence the expected code owners are only the users that are
     // code owner according to the 1. and 2. code owner config: user2 + user
-    List<CodeOwnerInfo> codeOwnerInfos = queryCodeOwners("/foo/bar/baz.md");
-    assertThat(codeOwnerInfos)
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(user2.id(), user.id())
         .inOrder();
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isNull();
   }
 
   @Test
@@ -213,23 +239,25 @@
         .folderPath("/foo/bar/")
         .addCodeOwnerSet(
             CodeOwnerSet.builder()
-                .addPathExpression(testPathExpressions.matchFileTypeInCurrentFolder("txt"))
+                .addPathExpression(testPathExpressions.matchFileType("txt"))
                 .addCodeOwnerEmail(admin.email())
                 .build())
         .addCodeOwnerSet(
             CodeOwnerSet.builder()
-                .addPathExpression(testPathExpressions.matchFileTypeInCurrentFolder("md"))
+                .addPathExpression(testPathExpressions.matchFileType("md"))
                 .addCodeOwnerEmail(user.email())
                 .build())
         .create();
 
     assertThat(queryCodeOwners("/foo/bar/config.txt"))
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(admin.id());
     assertThat(queryCodeOwners("/foo/bar/baz.md"))
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(user.id());
-    assertThat(queryCodeOwners("/foo/bar/main.config")).isEmpty();
+    assertThat(queryCodeOwners("/foo/bar/main.config")).hasCodeOwnersThat().isEmpty();
   }
 
   @Test
@@ -242,11 +270,15 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    List<CodeOwnerInfo> codeOwnerInfos =
+    CodeOwnersInfo codeOwnersInfo =
         queryCodeOwners(
             getCodeOwnersApi().query().withOptions(ListAccountsOption.DETAILS), "/foo/bar/baz.md");
-    assertThat(codeOwnerInfos).comparingElementsUsing(hasAccountId()).containsExactly(admin.id());
-    assertThat(codeOwnerInfos)
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountName())
         .containsExactly(admin.fullName());
   }
@@ -267,12 +299,16 @@
     accountOperations.account(admin.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
 
     // Make the request with the admin user that has the 'Modify Account' global capability.
-    List<CodeOwnerInfo> codeOwnerInfos =
+    CodeOwnersInfo codeOwnersInfo =
         queryCodeOwners(
             getCodeOwnersApi().query().withOptions(ListAccountsOption.ALL_EMAILS),
             "/foo/bar/baz.md");
-    assertThat(codeOwnerInfos).comparingElementsUsing(hasAccountId()).containsExactly(admin.id());
-    assertThatList(codeOwnerInfos)
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .onlyElement()
         .hasSecondaryEmailsThat()
         .containsExactly(secondaryEmail);
@@ -322,17 +358,22 @@
     accountOperations.account(admin.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
 
     // Make the request with the admin user that has the 'Modify Account' global capability.
-    List<CodeOwnerInfo> codeOwnerInfos =
+    CodeOwnersInfo codeOwnersInfo =
         queryCodeOwners(
             getCodeOwnersApi()
                 .query()
                 .withOptions(ListAccountsOption.DETAILS, ListAccountsOption.ALL_EMAILS),
             "/foo/bar/baz.md");
-    assertThat(codeOwnerInfos).comparingElementsUsing(hasAccountId()).containsExactly(admin.id());
-    assertThat(codeOwnerInfos)
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountName())
         .containsExactly(admin.fullName());
-    assertThatList(codeOwnerInfos)
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .onlyElement()
         .hasSecondaryEmailsThat()
         .containsExactly(secondaryEmail);
@@ -394,6 +435,7 @@
 
     // Make the request as admin who can see all accounts.
     assertThat(queryCodeOwners("/foo/bar/baz.md"))
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(admin.id(), user.id(), user2.id(), user3.id());
 
@@ -405,6 +447,7 @@
     // We expect only user2 and user3 as code owner (user and admin should be filtered
     // out because user2 cannot see their accounts).
     assertThat(queryCodeOwners("/foo/bar/baz.md"))
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(user2.id(), user3.id());
   }
@@ -423,6 +466,7 @@
 
     // Check that both code owners are suggested.
     assertThat(queryCodeOwners("/foo/bar/baz.md"))
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(admin.id(), user.id());
 
@@ -436,6 +480,7 @@
 
     // Expect that 'user' is filtered out now.
     assertThat(queryCodeOwners("/foo/bar/baz.md"))
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(admin.id());
   }
@@ -457,12 +502,14 @@
 
     // admin has the "Modify Account" global capability and hence can see secondary emails
     assertThat(queryCodeOwners("/foo/bar/baz.md"))
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(user.id());
 
     // user can see the own secondary email
     requestScopeOperations.setApiUser(user.id());
     assertThat(queryCodeOwners("/foo/bar/baz.md"))
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(user.id());
 
@@ -470,39 +517,7 @@
     // email
     TestAccount user2 = accountCreator.user2();
     requestScopeOperations.setApiUser(user2.id());
-    assertThat(queryCodeOwners("/foo/bar/baz.md")).isEmpty();
-  }
-
-  @Test
-  public void getCodeOwnersOrderNotDefinedIfCodeOwnersHaveTheSameScoring() throws Exception {
-    TestAccount user2 = accountCreator.user2();
-
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/")
-        .addCodeOwnerEmail(admin.email())
-        .addCodeOwnerEmail(user2.email())
-        .create();
-
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/")
-        .addCodeOwnerEmail(user.email())
-        .create();
-
-    List<CodeOwnerInfo> codeOwnerInfos = queryCodeOwners("/foo/bar.md");
-    assertThat(codeOwnerInfos)
-        .comparingElementsUsing(hasAccountId())
-        .containsExactly(admin.id(), user.id(), user2.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());
+    assertThat(queryCodeOwners("/foo/bar/baz.md")).hasCodeOwnersThat().isEmpty();
   }
 
   @Test
@@ -523,8 +538,10 @@
     codeOwnerConfigCreation.create();
 
     // Assert that the result is limited by the default limit.
-    List<CodeOwnerInfo> codeOwnerInfos = queryCodeOwners("/foo/bar.md");
-    assertThat(codeOwnerInfos).hasSize(GetCodeOwnersForPathInBranch.DEFAULT_LIMIT);
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("/foo/bar.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .hasSize(GetCodeOwnersForPathInBranch.DEFAULT_LIMIT);
   }
 
   @Test
@@ -550,20 +567,26 @@
         .create();
 
     // get code owners with different limits
-    List<CodeOwnerInfo> codeOwnerInfos =
+    CodeOwnersInfo codeOwnersInfo =
         queryCodeOwners(getCodeOwnersApi().query().withLimit(1), "/foo/bar/baz.md");
-    assertThat(codeOwnerInfos).hasSize(1);
+    assertThat(codeOwnersInfo).hasCodeOwnersThat().hasSize(1);
     // the first 2 code owners have the same scoring, so their order is random and we don't know
     // which of them we get when the limit is 1
-    assertThatList(codeOwnerInfos).element(0).hasAccountIdThat().isAnyOf(user.id(), user2.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(0)
+        .hasAccountIdThat()
+        .isAnyOf(user.id(), user2.id());
 
-    codeOwnerInfos = queryCodeOwners(getCodeOwnersApi().query().withLimit(2), "/foo/bar/baz.md");
-    assertThat(codeOwnerInfos)
+    codeOwnersInfo = queryCodeOwners(getCodeOwnersApi().query().withLimit(2), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(user.id(), user2.id());
 
-    codeOwnerInfos = getCodeOwnersApi().query().withLimit(3).get("/foo/bar/baz.md");
-    assertThat(codeOwnerInfos)
+    codeOwnersInfo = getCodeOwnersApi().query().withLimit(3).get("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(admin.id(), user.id(), user2.id());
   }
@@ -598,8 +621,11 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    List<CodeOwnerInfo> codeOwnerInfos = queryCodeOwners("/foo/bar/baz.md");
-    assertThat(codeOwnerInfos).comparingElementsUsing(hasAccountId()).containsExactly(admin.id());
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id());
   }
 
   @Test
@@ -608,11 +634,50 @@
     TestAccount globalOwner =
         accountCreator.create("global_owner", "global.owner@example.com", "Global Owner", null);
     assertThat(queryCodeOwners("/foo/bar/baz.md"))
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(globalOwner.id());
   }
 
   @Test
+  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
+  public void getAllUsersAsGlobalCodeOwners() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    CodeOwnersInfo codeOwnersInfo =
+        queryCodeOwners(getCodeOwnersApi().query().setResolveAllUsers(true), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(user.id(), user2.id(), admin.id());
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
+
+    // Query code owners with a limit.
+    requestScopeOperations.setApiUser(user.id());
+    codeOwnersInfo =
+        queryCodeOwners(
+            getCodeOwnersApi().query().setResolveAllUsers(true).withLimit(2), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo).hasCodeOwnersThat().hasSize(2);
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(0)
+        .hasAccountIdThat()
+        .isAnyOf(user.id(), user2.id(), admin.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(1)
+        .hasAccountIdThat()
+        .isAnyOf(user.id(), user2.id(), admin.id());
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
+
+    // Query code owners without resolving all users.
+    codeOwnersInfo =
+        queryCodeOwners(getCodeOwnersApi().query().setResolveAllUsers(false), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo).hasCodeOwnersThat().isEmpty();
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
+  }
+
+  @Test
   @GerritConfig(
       name = "plugin.code-owners.globalCodeOwner",
       values = {"global.owner1@example.com", "global.owner2@example.com"})
@@ -645,37 +710,57 @@
         .create();
 
     // get code owners with different limits
-    List<CodeOwnerInfo> codeOwnerInfos =
+    CodeOwnersInfo codeOwnersInfo =
         queryCodeOwners(getCodeOwnersApi().query().withLimit(1), "/foo/bar/baz.md");
-    assertThat(codeOwnerInfos).hasSize(1);
+    assertThat(codeOwnersInfo).hasCodeOwnersThat().hasSize(1);
     // the first 2 code owners have the same scoring, so their order is random and we don't know
     // which of them we get when the limit is 1
-    assertThatList(codeOwnerInfos).element(0).hasAccountIdThat().isAnyOf(user.id(), user2.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(0)
+        .hasAccountIdThat()
+        .isAnyOf(user.id(), user2.id());
 
-    codeOwnerInfos = queryCodeOwners(getCodeOwnersApi().query().withLimit(2), "/foo/bar/baz.md");
-    assertThat(codeOwnerInfos)
+    codeOwnersInfo = queryCodeOwners(getCodeOwnersApi().query().withLimit(2), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(user.id(), user2.id());
 
-    codeOwnerInfos = getCodeOwnersApi().query().withLimit(3).get("/foo/bar/baz.md");
-    assertThat(codeOwnerInfos)
+    codeOwnersInfo = getCodeOwnersApi().query().withLimit(3).get("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(admin.id(), user.id(), user2.id());
 
-    codeOwnerInfos = getCodeOwnersApi().query().withLimit(4).get("/foo/bar/baz.md");
-    assertThat(codeOwnerInfos).hasSize(4);
+    codeOwnersInfo = getCodeOwnersApi().query().withLimit(4).get("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo).hasCodeOwnersThat().hasSize(4);
     // the order of the first 2 code owners is random
-    assertThatList(codeOwnerInfos).element(0).hasAccountIdThat().isAnyOf(user.id(), user2.id());
-    assertThatList(codeOwnerInfos).element(1).hasAccountIdThat().isAnyOf(user.id(), user2.id());
-    assertThatList(codeOwnerInfos).element(2).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(0)
+        .hasAccountIdThat()
+        .isAnyOf(user.id(), user2.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(1)
+        .hasAccountIdThat()
+        .isAnyOf(user.id(), user2.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(2)
+        .hasAccountIdThat()
+        .isEqualTo(admin.id());
     // the order of the global code owners is random
-    assertThatList(codeOwnerInfos)
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .element(3)
         .hasAccountIdThat()
         .isAnyOf(globalOwner1.id(), globalOwner2.id());
 
-    codeOwnerInfos = getCodeOwnersApi().query().withLimit(5).get("/foo/bar/baz.md");
-    assertThat(codeOwnerInfos)
+    codeOwnersInfo = getCodeOwnersApi().query().withLimit(5).get("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(admin.id(), user.id(), user2.id(), globalOwner1.id(), globalOwner2.id());
   }
@@ -692,6 +777,7 @@
         .create();
 
     assertThat(queryCodeOwners("/foo/bar/baz.md"))
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(user.id());
   }
@@ -743,64 +829,100 @@
         .create();
 
     // get code owners with different limits
-    List<CodeOwnerInfo> codeOwnerInfos =
+    CodeOwnersInfo codeOwnersInfo =
         queryCodeOwners(getCodeOwnersApi().query().withLimit(1), "/foo/bar/baz.md");
-    assertThat(codeOwnerInfos).hasSize(1);
+    assertThat(codeOwnersInfo).hasCodeOwnersThat().hasSize(1);
     // the first 2 code owners have the same scoring, so their order is random and we don't know
     // which of them we get when the limit is 1
-    assertThatList(codeOwnerInfos).element(0).hasAccountIdThat().isAnyOf(user.id(), user2.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(0)
+        .hasAccountIdThat()
+        .isAnyOf(user.id(), user2.id());
 
-    codeOwnerInfos = queryCodeOwners(getCodeOwnersApi().query().withLimit(2), "/foo/bar/baz.md");
-    assertThat(codeOwnerInfos)
+    codeOwnersInfo = queryCodeOwners(getCodeOwnersApi().query().withLimit(2), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(user.id(), user2.id());
 
-    codeOwnerInfos = getCodeOwnersApi().query().withLimit(3).get("/foo/bar/baz.md");
-    assertThat(codeOwnerInfos)
+    codeOwnersInfo = getCodeOwnersApi().query().withLimit(3).get("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(admin.id(), user.id(), user2.id());
 
-    codeOwnerInfos = getCodeOwnersApi().query().withLimit(4).get("/foo/bar/baz.md");
-    assertThat(codeOwnerInfos).hasSize(4);
+    codeOwnersInfo = getCodeOwnersApi().query().withLimit(4).get("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo).hasCodeOwnersThat().hasSize(4);
     // the order of the first 2 code owners is random
-    assertThatList(codeOwnerInfos).element(0).hasAccountIdThat().isAnyOf(user.id(), user2.id());
-    assertThatList(codeOwnerInfos).element(1).hasAccountIdThat().isAnyOf(user.id(), user2.id());
-    assertThatList(codeOwnerInfos).element(2).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(0)
+        .hasAccountIdThat()
+        .isAnyOf(user.id(), user2.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(1)
+        .hasAccountIdThat()
+        .isAnyOf(user.id(), user2.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(2)
+        .hasAccountIdThat()
+        .isEqualTo(admin.id());
     // the order of the default code owners is random
-    assertThatList(codeOwnerInfos)
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .element(3)
         .hasAccountIdThat()
         .isAnyOf(defaultCodeOwner1.id(), defaultCodeOwner2.id());
 
-    codeOwnerInfos = getCodeOwnersApi().query().withLimit(5).get("/foo/bar/baz.md");
-    assertThat(codeOwnerInfos)
+    codeOwnersInfo = getCodeOwnersApi().query().withLimit(5).get("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(
             admin.id(), user.id(), user2.id(), defaultCodeOwner1.id(), defaultCodeOwner2.id());
 
-    codeOwnerInfos = getCodeOwnersApi().query().withLimit(6).get("/foo/bar/baz.md");
-    assertThat(codeOwnerInfos).hasSize(6);
+    codeOwnersInfo = getCodeOwnersApi().query().withLimit(6).get("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo).hasCodeOwnersThat().hasSize(6);
     // the order of the first 2 code owners is random
-    assertThatList(codeOwnerInfos).element(0).hasAccountIdThat().isAnyOf(user.id(), user2.id());
-    assertThatList(codeOwnerInfos).element(1).hasAccountIdThat().isAnyOf(user.id(), user2.id());
-    assertThatList(codeOwnerInfos).element(2).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(0)
+        .hasAccountIdThat()
+        .isAnyOf(user.id(), user2.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(1)
+        .hasAccountIdThat()
+        .isAnyOf(user.id(), user2.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(2)
+        .hasAccountIdThat()
+        .isEqualTo(admin.id());
     // the order of the default code owners is random
-    assertThatList(codeOwnerInfos)
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .element(3)
         .hasAccountIdThat()
         .isAnyOf(defaultCodeOwner1.id(), defaultCodeOwner2.id());
-    assertThatList(codeOwnerInfos)
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .element(4)
         .hasAccountIdThat()
         .isAnyOf(defaultCodeOwner1.id(), defaultCodeOwner2.id());
     // the order of the global code owners is random
-    assertThatList(codeOwnerInfos)
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .element(5)
         .hasAccountIdThat()
         .isAnyOf(globalOwner1.id(), globalOwner2.id());
 
-    codeOwnerInfos = getCodeOwnersApi().query().withLimit(7).get("/foo/bar/baz.md");
-    assertThat(codeOwnerInfos)
+    codeOwnersInfo = getCodeOwnersApi().query().withLimit(7).get("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(
             admin.id(),
@@ -826,22 +948,31 @@
         .addCodeOwnerEmail("*")
         .create();
 
-    List<CodeOwnerInfo> codeOwnerInfos = queryCodeOwners("/foo/bar/baz.md");
-    assertThat(codeOwnerInfos)
+    CodeOwnersInfo codeOwnersInfo =
+        queryCodeOwners(getCodeOwnersApi().query().setResolveAllUsers(true), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(user.id(), user2.id(), admin.id());
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
 
     // Query code owners with a limit.
-    codeOwnerInfos = queryCodeOwners(getCodeOwnersApi().query().withLimit(2), "/foo/bar/baz.md");
-    assertThat(codeOwnerInfos).hasSize(2);
-    assertThatList(codeOwnerInfos)
+    requestScopeOperations.setApiUser(user.id());
+    codeOwnersInfo =
+        queryCodeOwners(
+            getCodeOwnersApi().query().setResolveAllUsers(true).withLimit(2), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo).hasCodeOwnersThat().hasSize(2);
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .element(0)
         .hasAccountIdThat()
         .isAnyOf(user.id(), user2.id(), admin.id());
-    assertThatList(codeOwnerInfos)
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .element(1)
         .hasAccountIdThat()
         .isAnyOf(user.id(), user2.id(), admin.id());
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
   }
 
   @Test
@@ -863,28 +994,46 @@
 
     // user can only see itself
     requestScopeOperations.setApiUser(user.id());
-    List<CodeOwnerInfo> codeOwnerInfos = queryCodeOwners("/foo/bar/baz.md");
-    assertThat(codeOwnerInfos).comparingElementsUsing(hasAccountId()).containsExactly(user.id());
+    CodeOwnersInfo codeOwnersInfo =
+        queryCodeOwners(getCodeOwnersApi().query().setResolveAllUsers(true), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(user.id());
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
 
     // user2 can see user3 and itself
     requestScopeOperations.setApiUser(user2.id());
-    codeOwnerInfos = queryCodeOwners("/foo/bar/baz.md");
-    assertThat(codeOwnerInfos)
+    codeOwnersInfo =
+        queryCodeOwners(getCodeOwnersApi().query().setResolveAllUsers(true), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(user2.id(), user3.id());
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
 
     // admin can see all users
     requestScopeOperations.setApiUser(admin.id());
-    codeOwnerInfos = queryCodeOwners("/foo/bar/baz.md");
-    assertThat(codeOwnerInfos)
+    codeOwnersInfo =
+        queryCodeOwners(getCodeOwnersApi().query().setResolveAllUsers(true), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(admin.id(), user.id(), user2.id(), user3.id());
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
 
     // Query code owners with a limit, user2 can see user3 and itself
     requestScopeOperations.setApiUser(user2.id());
-    codeOwnerInfos = queryCodeOwners(getCodeOwnersApi().query().withLimit(1), "/foo/bar/baz.md");
-    assertThat(codeOwnerInfos).hasSize(1);
-    assertThatList(codeOwnerInfos).element(0).hasAccountIdThat().isAnyOf(user2.id(), user3.id());
+    codeOwnersInfo =
+        queryCodeOwners(
+            getCodeOwnersApi().query().setResolveAllUsers(true).withLimit(1), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo).hasCodeOwnersThat().hasSize(1);
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(0)
+        .hasAccountIdThat()
+        .isAnyOf(user2.id(), user3.id());
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
   }
 
   @Test
@@ -914,30 +1063,46 @@
     // user can only see itself, user2 (because user is owner of a group that contains user2) and
     // user3 (because user3 is member of a group that is visible to all users)
     requestScopeOperations.setApiUser(user.id());
-    List<CodeOwnerInfo> codeOwnerInfos = queryCodeOwners("/foo/bar/baz.md");
-    assertThat(codeOwnerInfos)
+    CodeOwnersInfo codeOwnersInfo =
+        queryCodeOwners(getCodeOwnersApi().query().setResolveAllUsers(true), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(user.id(), user2.id(), user3.id());
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
 
     // user2 can see user3 and itself
     requestScopeOperations.setApiUser(user2.id());
-    codeOwnerInfos = queryCodeOwners("/foo/bar/baz.md");
-    assertThat(codeOwnerInfos)
+    codeOwnersInfo =
+        queryCodeOwners(getCodeOwnersApi().query().setResolveAllUsers(true), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(user2.id(), user3.id());
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
 
     // admin can see all users
     requestScopeOperations.setApiUser(admin.id());
-    codeOwnerInfos = queryCodeOwners("/foo/bar/baz.md");
-    assertThat(codeOwnerInfos)
+    codeOwnersInfo =
+        queryCodeOwners(getCodeOwnersApi().query().setResolveAllUsers(true), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(admin.id(), user.id(), user2.id(), user3.id());
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
 
     // Query code owners with a limit, user2 can see user3 and itself
     requestScopeOperations.setApiUser(user2.id());
-    codeOwnerInfos = queryCodeOwners(getCodeOwnersApi().query().withLimit(1), "/foo/bar/baz.md");
-    assertThat(codeOwnerInfos).hasSize(1);
-    assertThatList(codeOwnerInfos).element(0).hasAccountIdThat().isAnyOf(user2.id(), user3.id());
+    codeOwnersInfo =
+        queryCodeOwners(
+            getCodeOwnersApi().query().setResolveAllUsers(true).withLimit(1), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo).hasCodeOwnersThat().hasSize(1);
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(0)
+        .hasAccountIdThat()
+        .isAnyOf(user2.id(), user3.id());
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
   }
 
   @Test
@@ -956,19 +1121,14 @@
 
     // Use user, since admin is allowed to view all accounts.
     requestScopeOperations.setApiUser(user.id());
-    List<CodeOwnerInfo> codeOwnerInfos = queryCodeOwners("/foo/bar/baz.md");
-    assertThat(codeOwnerInfos).isEmpty();
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo).hasCodeOwnersThat().isEmpty();
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
   }
 
   @Test
   @GerritConfig(name = "accounts.visibility", value = "NONE")
   public void getAllUsersAsCodeOwners_withViewAllAccounts() throws Exception {
-    // Allow all users to view all accounts.
-    projectOperations
-        .allProjectsForUpdate()
-        .add(allowCapability(GlobalCapability.VIEW_ALL_ACCOUNTS).group(REGISTERED_USERS))
-        .update();
-
     TestAccount user2 = accountCreator.user2();
 
     // Add a code owner config that makes all users code owners.
@@ -980,9 +1140,464 @@
         .addCodeOwnerEmail("*")
         .create();
 
-    List<CodeOwnerInfo> codeOwnerInfos = queryCodeOwners("/foo/bar/baz.md");
-    assertThat(codeOwnerInfos)
+    requestScopeOperations.setApiUser(user.id());
+
+    // Since accounts.visibility = NONE, no account is visible and hence the list of code owners is
+    // empty.
+    CodeOwnersInfo codeOwnersInfo =
+        queryCodeOwners(getCodeOwnersApi().query().setResolveAllUsers(true), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo).hasCodeOwnersThat().isEmpty();
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
+
+    // Allow all users to view all accounts.
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.VIEW_ALL_ACCOUNTS).group(REGISTERED_USERS))
+        .update();
+
+    // If VIEW_ALL_ACCOUNTS is assigned, all accounts are visible now.
+    codeOwnersInfo =
+        queryCodeOwners(getCodeOwnersApi().query().setResolveAllUsers(true), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(admin.id(), user.id(), user2.id());
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
+  }
+
+  @Test
+  public void getAllUsersAsCodeOwners_withoutResolvingAllUsers() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(user.email())
+        .addCodeOwnerEmail("*")
+        .create();
+
+    // If resolveAllUsers = false, only 'user' should be returned as code owner.
+    CodeOwnersInfo codeOwnersInfo =
+        queryCodeOwners(getCodeOwnersApi().query().setResolveAllUsers(false), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(user.id());
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
+
+    // If resolveAllUsers = true, the result includes 'admin' in addition to 'user' which has code
+    // ownership explicitly assigned.
+    codeOwnersInfo =
+        queryCodeOwners(getCodeOwnersApi().query().setResolveAllUsers(true), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(user.id(), admin.id());
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
+  }
+
+  @Test
+  public void getAllUsersAsCodeOwners_explicitlyMentionedCodeOwnersArePreferred() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    // Add a code owner config that assigns code ownership to user2 and all users.
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(user2.email())
+        .addCodeOwnerEmail("*")
+        .create();
+
+    // Query code owners with limits, "*" is resolved to random users, but user2 should always be
+    // included since this user is set explicitly as code owner
+    CodeOwnersInfo codeOwnersInfo =
+        queryCodeOwners(
+            getCodeOwnersApi().query().setResolveAllUsers(true).withLimit(2), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo).hasCodeOwnersThat().hasSize(2);
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .contains(user2.id());
+
+    codeOwnersInfo =
+        queryCodeOwners(
+            getCodeOwnersApi().query().setResolveAllUsers(true).withLimit(1), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(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();
+
+    CodeOwnersInfo codeOwnersInfo =
+        queryCodeOwners(getCodeOwnersApi().query().withSeed(seed), "/foo/bar/baz.md");
+    // all code owners have the same score, hence their order is random
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .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 =
+        codeOwnersInfo.codeOwners.stream()
+            .map(info -> Account.id(info.account._accountId))
+            .collect(toList());
+    for (int i = 0; i < 10; i++) {
+      assertThat(queryCodeOwners(getCodeOwnersApi().query().withSeed(seed), "/foo/bar/baz.md"))
+          .hasCodeOwnersThat()
+          .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();
+
+    CodeOwnersInfo codeOwnersInfo =
+        queryCodeOwners(
+            getCodeOwnersApi().query().setResolveAllUsers(true).withSeed(seed), "/foo/bar/baz.md");
+    // all code owners have the same score, hence their order is random
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .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 =
+        codeOwnersInfo.codeOwners.stream()
+            .map(info -> Account.id(info.account._accountId))
+            .collect(toList());
+    for (int i = 0; i < 10; i++) {
+      assertThat(
+              queryCodeOwners(
+                  getCodeOwnersApi().query().setResolveAllUsers(true).withSeed(seed),
+                  "/foo/bar/baz.md"))
+          .hasCodeOwnersThat()
+          .comparingElementsUsing(hasAccountId())
+          .containsExactlyElementsIn(expectedAccountIds)
+          .inOrder();
+    }
+  }
+
+  @Test
+  public void allUsersOwnershipThatIsAssignedInParentIsConsideredEvenIfLimitWasAlreadyReached()
+      throws Exception {
+    // Create some code owner configs.
+    // The order below reflects the order in which the code owner configs are evaluated.
+
+    // 1. code owner config that makes "user" a code owner, inheriting code owners from parent code
+    // owner configs is enabled by default
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/bar/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    // 2. code owner config that makes "admin" a code owner, inheriting code owners from parent code
+    // owner configs is enabled by default
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    // 3. code owner config that makes all users code owners
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(CodeOwnerResolver.ALL_USERS_WILDCARD)
+        .create();
+
+    // Query code owners for "/foo/bar/baz.md" with limit 2. After inspecting code owner config 1.
+    // and 2. enough code owners are found to satisfy the limit, but code owner config 3. is still
+    // inspected so that the ownedByAllUsers field in the response gets set.
+    CodeOwnersInfo codeOwnersInfo =
+        queryCodeOwners(getCodeOwnersApi().query().withLimit(2), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(user.id(), admin.id())
+        .inOrder();
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
+  public void allUsersOwnershipThatIsAssignedAsGlobalOwnerIsConsideredEvenIfLimitWasAlreadyReached()
+      throws Exception {
+    // Create some code owner configs.
+    // The order below reflects the order in which the code owner configs are evaluated.
+
+    // 1. code owner config that makes "user" a code owner, inheriting code owners from parent code
+    // owner configs is enabled by default
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/bar/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    // 2. code owner config that makes "admin" a code owner, inheriting code owners from parent code
+    // owner configs is enabled by default
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    // Query code owners for "/foo/bar/baz.md" with limit 2. After inspecting code owner config 1.
+    // and 2. enough code owners are found to satisfy the limit, but global code owners are still
+    // inspected so that the ownedByAllUsers field in the response gets set.
+    CodeOwnersInfo codeOwnersInfo =
+        queryCodeOwners(getCodeOwnersApi().query().withLimit(2), "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(user.id(), admin.id())
+        .inOrder();
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
+  }
+
+  @Test
+  public void getCodeOwnersWithHighestScoreOnly() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    // create some code owner configs
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/bar/")
+        .addCodeOwnerEmail(user.email())
+        .addCodeOwnerEmail(user2.email())
+        .create();
+
+    CodeOwnersInfo codeOwnersInfo =
+        queryCodeOwners(
+            getCodeOwnersApi().query().withHighestScoreOnly(/* highestScoreOnly= */ false),
+            "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo).hasCodeOwnersThat().hasSize(3);
+    // the first 2 code owners have the same scoring, so their order is random and we don't know
+    // which of them we get get first and second
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(0)
+        .hasAccountIdThat()
+        .isAnyOf(user.id(), user2.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(1)
+        .hasAccountIdThat()
+        .isAnyOf(user.id(), user2.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(2)
+        .hasAccountIdThat()
+        .isEqualTo(admin.id());
+
+    codeOwnersInfo =
+        queryCodeOwners(
+            getCodeOwnersApi().query().withHighestScoreOnly(/* highestScoreOnly= */ true),
+            "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo).hasCodeOwnersThat().hasSize(2);
+    // the first 2 code owners have the same scoring, so their order is random and we don't know
+    // which of them we get get first and second
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(0)
+        .hasAccountIdThat()
+        .isAnyOf(user.id(), user2.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .element(1)
+        .hasAccountIdThat()
+        .isAnyOf(user.id(), user2.id());
+  }
+
+  @Test
+  public void getCodeOwnersWithHighestScoreOnly_noCodeOwners() throws Exception {
+    CodeOwnersInfo codeOwnersInfo =
+        queryCodeOwners(
+            getCodeOwnersApi().query().withHighestScoreOnly(/* highestScoreOnly= */ true),
+            "/foo/bar/baz.md");
+    assertThat(codeOwnersInfo).hasCodeOwnersThat().isEmpty();
+  }
+
+  @Test
+  public void debugRequireCallerToBeAdminOrHaveTheCheckCodeOwnerCapability() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    AuthException authException =
+        assertThrows(
+            AuthException.class,
+            () ->
+                queryCodeOwners(
+                    getCodeOwnersApi().query().withDebug(/* debug= */ true), "/foo/bar/baz.md"));
+    assertThat(authException)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format("%s for plugin code-owners not permitted", CheckCodeOwnerCapability.ID));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "global.owner@example.com")
+  public void getCodeOwnersWithDebug_byAdmin() throws Exception {
+    testGetCodeOwnersWithDebug();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "global.owner@example.com")
+  public void getCodeOwnersWithDebug_byUserThatHasTheCheckCodeOwnerCapability() throws Exception {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability("code-owners-" + CheckCodeOwnerCapability.ID).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    testGetCodeOwnersWithDebug();
+  }
+
+  private void testGetCodeOwnersWithDebug() throws Exception {
+    TestAccount globalOwner =
+        accountCreator.create("global_owner", "global.owner@example.com", "Global Owner", null);
+
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
+
+    TestAccount user2 = accountCreator.user2();
+
+    CodeOwnerConfig.Key rootKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    String nonExistingEmail = "non-existing@example.com";
+    CodeOwnerConfig.Key fooKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/")
+            .addCodeOwnerEmail(nonExistingEmail)
+            .create();
+
+    Project.NameKey nonExistingProject = Project.nameKey("non-existing");
+    CodeOwnerConfigReference nonResolvableCodeOwnerConfigReference =
+        CodeOwnerConfigReference.builder(
+                CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
+                codeOwnerConfigOperations
+                    .codeOwnerConfig(CodeOwnerConfig.Key.create(nonExistingProject, "master", "/"))
+                    .getFilePath())
+            .setProject(nonExistingProject)
+            .build();
+
+    CodeOwnerConfig.Key fooBarKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/foo/bar/")
+            .addImport(nonResolvableCodeOwnerConfigReference)
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .addPathExpression(testPathExpressions.matchFileType("md"))
+                    .addCodeOwnerEmail(user.email())
+                    .build())
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .addPathExpression(testPathExpressions.matchFileType("txt"))
+                    .addCodeOwnerEmail(user2.email())
+                    .build())
+            .create();
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnersInfo codeOwnersInfo =
+        queryCodeOwners(getCodeOwnersApi().query().withDebug(/* debug= */ true), path);
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(user.id(), admin.id(), globalOwner.id())
+        .inOrder();
+    assertThat(codeOwnersInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format("resolve code owners for %s from code owner config %s", path, fooBarKey),
+            "per-file code owner set with path expressions [*.md] matches",
+            String.format(
+                "The import of %s:master:/%s in %s:master:/foo/bar/%s cannot be resolved:"
+                    + " project %s not found",
+                nonExistingProject.get(),
+                getCodeOwnerConfigFileName(),
+                project.get(),
+                getCodeOwnerConfigFileName(),
+                nonExistingProject.get()),
+            String.format(
+                "resolving code owner reference %s", CodeOwnerReference.create(user.email())),
+            String.format("resolved to account %d", user.id().get()),
+            String.format("resolve code owners for %s from code owner config %s", path, fooKey),
+            String.format(
+                "resolving code owner reference %s", CodeOwnerReference.create(nonExistingEmail)),
+            String.format(
+                "cannot resolve code owner email %s: no account with this email exists",
+                nonExistingEmail),
+            String.format("resolve code owners for %s from code owner config %s", path, rootKey),
+            String.format(
+                "resolving code owner reference %s", CodeOwnerReference.create(admin.email())),
+            String.format("resolved to account %d", admin.id().get()),
+            "resolve global code owners",
+            String.format(
+                "resolving code owner reference %s",
+                CodeOwnerReference.create(globalOwner.email())),
+            String.format("resolved to account %d", globalOwner.id().get()));
   }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/BUILD b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/BUILD
index 5fb0fdf..cccf418 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/BUILD
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/BUILD
@@ -10,10 +10,16 @@
     group = f[:f.index(".")],
     deps = [
         "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/acceptance/testsuite",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/api",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/api/impl",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/backend",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/common",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/restapi",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/util",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/validation",
     ],
 ) for f in glob(
     ["*IT.java"],
@@ -31,9 +37,11 @@
         "//java/com/google/gerrit/server",
         "//lib:guava",
         "//lib/guice",
-        "//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/acceptance/testsuite",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/api",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/backend",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/restapi",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing",
     ],
 )
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 9a31ca1..a275287 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerConfigFilesIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerConfigFilesIT.java
@@ -15,7 +15,6 @@
 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.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -36,18 +35,13 @@
 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.backend.CodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigImportMode;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigReference;
-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.BackendConfig;
 import com.google.inject.Inject;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import org.junit.Before;
 import org.junit.Test;
 
 /**
@@ -63,11 +57,12 @@
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ProjectOperations projectOperations;
 
-  private BackendConfig backendConfig;
-
-  @Before
-  public void setUpCodeOwnersPlugin() throws Exception {
-    backendConfig = plugin.getSysInjector().getInstance(BackendConfig.class);
+  @Test
+  public void requiresAuthenticatedUser() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException authException =
+        assertThrows(AuthException.class, () -> checkCodeOwnerConfigFilesIn(project));
+    assertThat(authException).hasMessageThat().contains("Authentication required");
   }
 
   @Test
@@ -161,21 +156,29 @@
                     ImmutableList.of(
                         fatal(
                             String.format(
-                                "invalid code owner config file '%s':\n  %s",
+                                "invalid code owner config file '%s' (project = %s,"
+                                    + " branch = master):\n  %s",
                                 codeOwnerConfigPath,
-                                getParsingErrorMessage(
-                                    ImmutableMap.of(
-                                        FindOwnersBackend.class,
-                                        "invalid line: INVALID",
-                                        ProtoBackend.class,
-                                        "1:8: Expected \"{\".")))))),
+                                project,
+                                getParsingErrorMessageForNonParseableCodeOwnerConfig())))),
             "refs/meta/config", ImmutableMap.of());
   }
 
   @Test
   public void issuesInCodeOwnerConfigFile() throws Exception {
-    // imports are not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    testIssuesInCodeOwnerConfigFile(ConsistencyProblemInfo.Status.ERROR);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.rejectNonResolvableCodeOwners", value = "false")
+  @GerritConfig(name = "plugin.code-owners.rejectNonResolvableImports", value = "false")
+  public void issuesInCodeOwnerConfigFileReportedAsWarnings() throws Exception {
+    testIssuesInCodeOwnerConfigFile(ConsistencyProblemInfo.Status.WARNING);
+  }
+
+  private void testIssuesInCodeOwnerConfigFile(ConsistencyProblemInfo.Status expectedStatus)
+      throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
 
     // Create some code owner config files with issues.
     CodeOwnerConfig.Key keyOfInvalidConfig1 =
@@ -218,19 +221,22 @@
                 ImmutableMap.of(
                     pathOfInvalidConfig1,
                     ImmutableList.of(
-                        error(
+                        problem(
+                            expectedStatus,
                             String.format(
                                 "invalid global import in '%s': '/not-a-code-owner-config' is"
                                     + " not a code owner config file",
                                 pathOfInvalidConfig1))),
                     pathOfInvalidConfig2,
                     ImmutableList.of(
-                        error(
+                        problem(
+                            expectedStatus,
                             String.format(
                                 "code owner email 'unknown1@example.com' in '%s' cannot be"
                                     + " resolved for admin",
                                 pathOfInvalidConfig2)),
-                        error(
+                        problem(
+                            expectedStatus,
                             String.format(
                                 "code owner email 'unknown2@example.com' in '%s' cannot be"
                                     + " resolved for admin",
@@ -368,8 +374,7 @@
 
   @Test
   public void validateExactFile() throws Exception {
-    // imports are not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
 
     // Create some code owner config files with issues.
     CodeOwnerConfig.Key keyOfInvalidConfig1 =
@@ -441,8 +446,7 @@
 
   @Test
   public void validateFilesMatchingGlob() throws Exception {
-    // imports are not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
 
     // Create some code owner config files with issues.
     codeOwnerConfigOperations
@@ -505,27 +509,69 @@
 
   @Test
   public void allIssuesAreReturnedIfNoLevelIsSpecified() throws Exception {
-    testIssuesAreFilteredByVerbosity(
-        /** verbosity */
-        null);
+    testIssuesAreFilteredByVerbosity(/* verbosity= */ null, ConsistencyProblemInfo.Status.ERROR);
   }
 
   @Test
   public void allIssuesAreReturnedIfLevelIsSetToWarning() throws Exception {
-    testIssuesAreFilteredByVerbosity(ConsistencyProblemInfo.Status.WARNING);
+    testIssuesAreFilteredByVerbosity(
+        ConsistencyProblemInfo.Status.WARNING, ConsistencyProblemInfo.Status.ERROR);
   }
 
   @Test
   public void onlyFatalAndErrorIssuesAreReturnedIfLevelIsSetToError() throws Exception {
-    testIssuesAreFilteredByVerbosity(ConsistencyProblemInfo.Status.ERROR);
+    testIssuesAreFilteredByVerbosity(
+        ConsistencyProblemInfo.Status.ERROR, ConsistencyProblemInfo.Status.ERROR);
   }
 
   @Test
   public void onlyFatalIssuesAreReturnedIfLevelIsSetToFatal() throws Exception {
-    testIssuesAreFilteredByVerbosity(ConsistencyProblemInfo.Status.FATAL);
+    testIssuesAreFilteredByVerbosity(
+        ConsistencyProblemInfo.Status.FATAL, ConsistencyProblemInfo.Status.ERROR);
   }
 
-  private void testIssuesAreFilteredByVerbosity(@Nullable ConsistencyProblemInfo.Status verbosity)
+  @Test
+  @GerritConfig(name = "plugin.code-owners.rejectNonResolvableCodeOwners", value = "false")
+  @GerritConfig(name = "plugin.code-owners.rejectNonResolvableImports", value = "false")
+  public void
+      allIssuesAreReturnedIfNoLevelIsSpecified_issuesInCodeOwnerConfigFileReportedAsWarnings()
+          throws Exception {
+    testIssuesAreFilteredByVerbosity(/* verbosity= */ null, ConsistencyProblemInfo.Status.WARNING);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.rejectNonResolvableCodeOwners", value = "false")
+  @GerritConfig(name = "plugin.code-owners.rejectNonResolvableImports", value = "false")
+  public void
+      allIssuesAreReturnedIfLevelIsSetToWarning_issuesInCodeOwnerConfigFileReportedAsWarnings()
+          throws Exception {
+    testIssuesAreFilteredByVerbosity(
+        ConsistencyProblemInfo.Status.WARNING, ConsistencyProblemInfo.Status.WARNING);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.rejectNonResolvableCodeOwners", value = "false")
+  @GerritConfig(name = "plugin.code-owners.rejectNonResolvableImports", value = "false")
+  public void
+      onlyFatalAndErrorIssuesAreReturnedIfLevelIsSetToError_issuesInCodeOwnerConfigFileReportedAsWarnings()
+          throws Exception {
+    testIssuesAreFilteredByVerbosity(
+        ConsistencyProblemInfo.Status.ERROR, ConsistencyProblemInfo.Status.WARNING);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.rejectNonResolvableCodeOwners", value = "false")
+  @GerritConfig(name = "plugin.code-owners.rejectNonResolvableImports", value = "false")
+  public void
+      onlyFatalIssuesAreReturnedIfLevelIsSetToFatal_issuesInCodeOwnerConfigFileReportedAsWarnings()
+          throws Exception {
+    testIssuesAreFilteredByVerbosity(
+        ConsistencyProblemInfo.Status.FATAL, ConsistencyProblemInfo.Status.WARNING);
+  }
+
+  private void testIssuesAreFilteredByVerbosity(
+      @Nullable ConsistencyProblemInfo.Status verbosity,
+      ConsistencyProblemInfo.Status expectedStatus)
       throws Exception {
     // create a non-parseable code owner config, that will be reported as fatal
     String pathOfNonParseableCodeOwnerConfig = "/" + getCodeOwnerConfigFileName();
@@ -552,21 +598,20 @@
         ImmutableList.of(
             fatal(
                 String.format(
-                    "invalid code owner config file '%s':\n  %s",
+                    "invalid code owner config file '%s' (project = %s, branch = master):\n  %s",
                     pathOfNonParseableCodeOwnerConfig,
-                    getParsingErrorMessage(
-                        ImmutableMap.of(
-                            FindOwnersBackend.class,
-                            "invalid line: INVALID",
-                            ProtoBackend.class,
-                            "1:8: Expected \"{\"."))))));
+                    project,
+                    getParsingErrorMessageForNonParseableCodeOwnerConfig()))));
     if (verbosity == null
-        || ConsistencyProblemInfo.Status.ERROR.equals(verbosity)
-        || ConsistencyProblemInfo.Status.WARNING.equals(verbosity)) {
+        || (ConsistencyProblemInfo.Status.ERROR.equals(verbosity)
+            && (expectedStatus.equals(ConsistencyProblemInfo.Status.FATAL)
+                || expectedStatus.equals(ConsistencyProblemInfo.Status.ERROR)))
+        || (ConsistencyProblemInfo.Status.WARNING.equals(verbosity))) {
       expectedMasterIssues.put(
           pathOfInvalidConfig,
           ImmutableList.of(
-              error(
+              problem(
+                  expectedStatus,
                   String.format(
                       "code owner email 'unknown@example.com' in '%s' cannot be"
                           + " resolved for admin",
@@ -592,25 +637,12 @@
     return new ConsistencyProblemInfo(ConsistencyProblemInfo.Status.ERROR, message);
   }
 
+  private ConsistencyProblemInfo problem(ConsistencyProblemInfo.Status status, String message) {
+    return new ConsistencyProblemInfo(status, message);
+  }
+
   private Map<String, Map<String, List<ConsistencyProblemInfo>>> checkCodeOwnerConfigFilesIn(
       Project.NameKey projectName) throws RestApiException {
     return projectCodeOwnersApiFactory.project(projectName).checkCodeOwnerConfigFiles().check();
   }
-
-  private String getCodeOwnerConfigFileName() {
-    CodeOwnerBackend backend = backendConfig.getDefaultBackend();
-    if (backend instanceof FindOwnersBackend) {
-      return FindOwnersBackend.CODE_OWNER_CONFIG_FILE_NAME;
-    } else if (backend instanceof ProtoBackend) {
-      return ProtoBackend.CODE_OWNER_CONFIG_FILE_NAME;
-    }
-    throw new IllegalStateException("unknown code owner backend: " + backend.getClass().getName());
-  }
-
-  private String getParsingErrorMessage(
-      ImmutableMap<Class<? extends CodeOwnerBackend>, String> messagesByBackend) {
-    CodeOwnerBackend codeOwnerBackend = backendConfig.getDefaultBackend();
-    assertThat(messagesByBackend).containsKey(codeOwnerBackend.getClass());
-    return messagesByBackend.get(codeOwnerBackend.getClass());
-  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerConfigFilesInRevisionIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerConfigFilesInRevisionIT.java
index 98a071c..b003707 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerConfigFilesInRevisionIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerConfigFilesInRevisionIT.java
@@ -23,16 +23,15 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
-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;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerSet;
+import com.google.gerrit.plugins.codeowners.backend.config.BackendConfig;
 import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
 import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersCodeOwnerConfigParser;
 import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend;
 import com.google.gerrit.plugins.codeowners.backend.proto.ProtoCodeOwnerConfigParser;
-import com.google.gerrit.plugins.codeowners.config.BackendConfig;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -129,8 +128,9 @@
             ImmutableList.of(
                 fatal(
                     String.format(
-                        "invalid code owner config file '%s':\n  %s",
+                        "invalid code owner config file '%s' (project = %s, branch = master):\n  %s",
                         codeOwnerConfigPath,
+                        project,
                         getParsingErrorMessage(
                             ImmutableMap.of(
                                 FindOwnersBackend.class,
@@ -458,8 +458,9 @@
         ImmutableList.of(
             fatal(
                 String.format(
-                    "invalid code owner config file '%s':\n  %s",
+                    "invalid code owner config file '%s' (project = %s, branch = master):\n  %s",
                     pathOfNonParseableCodeOwnerConfig,
+                    project,
                     getParsingErrorMessage(
                         ImmutableMap.of(
                             FindOwnersBackend.class,
@@ -523,11 +524,4 @@
             "unknown code owner backend: %s",
             backendConfig.getDefaultBackend().getClass().getName()));
   }
-
-  private String getParsingErrorMessage(
-      ImmutableMap<Class<? extends CodeOwnerBackend>, String> messagesByBackend) {
-    CodeOwnerBackend codeOwnerBackend = backendConfig.getDefaultBackend();
-    assertThat(messagesByBackend).containsKey(codeOwnerBackend.getClass());
-    return messagesByBackend.get(codeOwnerBackend.getClass());
-  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerIT.java
new file mode 100644
index 0000000..0db2388
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CheckCodeOwnerIT.java
@@ -0,0 +1,1516 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.acceptance.api;
+
+import static com.google.common.truth.Truth.assertThat;
+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.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerCheckInfoSubject.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+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.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+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.BranchInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.gerrit.plugins.codeowners.acceptance.testsuite.TestCodeOwnerConfigCreation;
+import com.google.gerrit.plugins.codeowners.acceptance.testsuite.TestPathExpressions;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerCheckInfo;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigImportMode;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigReference;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerResolver;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerSet;
+import com.google.gerrit.plugins.codeowners.restapi.CheckCodeOwnerCapability;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.Arrays;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Acceptance test for the {@link com.google.gerrit.plugins.codeowners.restapi.CheckCodeOwner} REST
+ * endpoint.
+ */
+public class CheckCodeOwnerIT extends AbstractCodeOwnersIT {
+  private static final String ROOT_PATH = "/";
+
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private AccountOperations accountOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject @ServerInitiated private Provider<AccountsUpdate> accountsUpdate;
+  @Inject private ExternalIdNotes.Factory externalIdNotesFactory;
+
+  private TestPathExpressions testPathExpressions;
+
+  @Before
+  public void setUpCodeOwnersPlugin() throws Exception {
+    testPathExpressions = plugin.getSysInjector().getInstance(TestPathExpressions.class);
+  }
+
+  @Test
+  public void requiresEmail() throws Exception {
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> checkCodeOwner("/", /* email= */ null));
+    assertThat(exception).hasMessageThat().isEqualTo("email required");
+  }
+
+  @Test
+  public void requiresPath() throws Exception {
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class, () -> checkCodeOwner(/* path= */ null, user.email()));
+    assertThat(exception).hasMessageThat().isEqualTo("path required");
+  }
+
+  @Test
+  public void requiresCallerToBeAdminOrHaveTheCheckCodeOwnerCapability() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    AuthException authException =
+        assertThrows(AuthException.class, () -> checkCodeOwner(ROOT_PATH, user.email()));
+    assertThat(authException)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format("%s for plugin code-owners not permitted", CheckCodeOwnerCapability.ID));
+  }
+
+  @Test
+  public void checkCodeOwner_byAdmin() throws Exception {
+    testCheckCodeOwner();
+  }
+
+  @Test
+  public void checkCodeOwner_byUserThatHasTheCheckCodeOwnerCapability() throws Exception {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability("code-owners-" + CheckCodeOwnerCapability.ID).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    testCheckCodeOwner();
+  }
+
+  private void testCheckCodeOwner() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+    setAsCodeOwners("/foo/", codeOwner);
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, codeOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo).canReadRef();
+    assertThat(checkCodeOwnerInfo).canSeeChangeNotSet();
+    assertThat(checkCodeOwnerInfo).canApproveChangeNotSet();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath("/foo/"));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                codeOwner.email(), getCodeOwnerConfigFilePath("/foo/")),
+            String.format("resolved to account %s", codeOwner.id()));
+  }
+
+  @Test
+  public void checkCodeOwnerThatHasCodeOwnershipThroughMultipleFiles() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+    setAsRootCodeOwners(codeOwner);
+    setAsCodeOwners("/foo/", codeOwner);
+    setAsCodeOwners("/foo/bar/", codeOwner);
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, codeOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(
+            getCodeOwnerConfigFilePath("/foo/bar/"),
+            getCodeOwnerConfigFilePath("/foo/"),
+            getCodeOwnerConfigFilePath(ROOT_PATH))
+        .inOrder();
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                codeOwner.email(), getCodeOwnerConfigFilePath("/foo/bar/")),
+            String.format(
+                "found email %s as code owner in %s",
+                codeOwner.email(), getCodeOwnerConfigFilePath("/foo/")),
+            String.format(
+                "found email %s as code owner in %s",
+                codeOwner.email(), getCodeOwnerConfigFilePath(ROOT_PATH)),
+            String.format("resolved to account %s", codeOwner.id()));
+  }
+
+  @Test
+  public void checkCodeOwnerWithParentCodeOwnersIgnored() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+    setAsRootCodeOwners(codeOwner);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .ignoreParentCodeOwners()
+        .addCodeOwnerEmail(codeOwner.email())
+        .create();
+
+    setAsCodeOwners("/foo/bar/", codeOwner);
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, codeOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(
+            getCodeOwnerConfigFilePath("/foo/bar/"), getCodeOwnerConfigFilePath("/foo/"))
+        .inOrder();
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                codeOwner.email(), getCodeOwnerConfigFilePath("/foo/bar/")),
+            String.format(
+                "found email %s as code owner in %s",
+                codeOwner.email(), getCodeOwnerConfigFilePath("/foo/")),
+            "parent code owners are ignored",
+            String.format("resolved to account %s", codeOwner.id()));
+  }
+
+  @Test
+  public void checkCodeOwner_secondaryEmail() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+    String secondaryEmail = "codeOwnerSecondary@example.com";
+    accountOperations
+        .account(codeOwner.id())
+        .forUpdate()
+        .addSecondaryEmail(secondaryEmail)
+        .update();
+
+    setAsRootCodeOwners(secondaryEmail);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(ROOT_PATH, secondaryEmail);
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                secondaryEmail, getCodeOwnerConfigFilePath(ROOT_PATH)),
+            String.format("resolved to account %s", codeOwner.id()));
+  }
+
+  @Test
+  public void checkCodeOwner_ownedByAllUsers() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+
+    setAsRootCodeOwners(CodeOwnerResolver.ALL_USERS_WILDCARD);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(ROOT_PATH, codeOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                CodeOwnerResolver.ALL_USERS_WILDCARD, getCodeOwnerConfigFilePath(ROOT_PATH)));
+  }
+
+  @Test
+  public void checkCodeOwner_ownedByEmailAndOwnedByAllUsers() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+
+    setAsRootCodeOwners(codeOwner.email(), CodeOwnerResolver.ALL_USERS_WILDCARD);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(ROOT_PATH, codeOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                codeOwner.email(), getCodeOwnerConfigFilePath(ROOT_PATH)),
+            String.format(
+                "found email %s as code owner in %s",
+                CodeOwnerResolver.ALL_USERS_WILDCARD, getCodeOwnerConfigFilePath(ROOT_PATH)));
+  }
+
+  @Test
+  public void checkNonCodeOwner() throws Exception {
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(ROOT_PATH, user.email());
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo).hasCodeOwnerConfigFilePathsThat().isEmpty();
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(String.format("resolved to account %s", user.id()));
+  }
+
+  @Test
+  public void checkNonExistingEmail() throws Exception {
+    String nonExistingEmail = "non-exiting@example.com";
+
+    setAsRootCodeOwners(nonExistingEmail);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(ROOT_PATH, nonExistingEmail);
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                nonExistingEmail, getCodeOwnerConfigFilePath(ROOT_PATH)),
+            String.format(
+                "cannot resolve code owner email %s: no account with this email exists",
+                nonExistingEmail));
+  }
+
+  @Test
+  public void checkAmbiguousExistingEmail() throws Exception {
+    String ambiguousEmail = "ambiguous@example.com";
+
+    setAsRootCodeOwners(ambiguousEmail);
+
+    // Add the email to 2 accounts to make it ambiguous.
+    addEmail(user.id(), ambiguousEmail);
+    addEmail(admin.id(), ambiguousEmail);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(ROOT_PATH, ambiguousEmail);
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                ambiguousEmail, getCodeOwnerConfigFilePath(ROOT_PATH)),
+            String.format(
+                "cannot resolve code owner email %s: email is ambiguous", ambiguousEmail));
+  }
+
+  @Test
+  public void checkOrphanedEmail() throws Exception {
+    // Create an external ID with an email for a non-existing account.
+    String orphanedEmail = "orphaned@example.com";
+    Account.Id accountId = Account.id(999999);
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+      extIdNotes.upsert(ExternalId.createEmail(accountId, orphanedEmail));
+      extIdNotes.commit(md);
+    }
+
+    setAsRootCodeOwners(orphanedEmail);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(ROOT_PATH, orphanedEmail);
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                orphanedEmail, getCodeOwnerConfigFilePath(ROOT_PATH)),
+            String.format(
+                "cannot resolve account %s for email %s: account does not exists",
+                accountId, orphanedEmail),
+            String.format(
+                "cannot resolve code owner email %s: no active account with this email found",
+                orphanedEmail));
+  }
+
+  @Test
+  public void checkInactiveAccount() throws Exception {
+    TestAccount inactiveUser =
+        accountCreator.create(
+            "inactiveUser", "inactiveUser@example.com", "Inactive User", /* displayName= */ null);
+    accountOperations.account(inactiveUser.id()).forUpdate().inactive().update();
+
+    setAsRootCodeOwners(inactiveUser);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(ROOT_PATH, inactiveUser.email());
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                inactiveUser.email(), getCodeOwnerConfigFilePath(ROOT_PATH)),
+            String.format(
+                "account %s for email %s is inactive", inactiveUser.id(), inactiveUser.email()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.allowedEmailDomain", value = "example.net")
+  public void checkEmailWithAllowedDomain() throws Exception {
+    String emailWithAllowedEmailDomain = "foo@example.net";
+    TestAccount userWithAllowedEmail =
+        accountCreator.create(
+            "userWithAllowedEmail",
+            emailWithAllowedEmailDomain,
+            "User with allowed emil",
+            /* displayName= */ null);
+
+    setAsRootCodeOwners(userWithAllowedEmail);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(ROOT_PATH, emailWithAllowedEmailDomain);
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                emailWithAllowedEmailDomain, getCodeOwnerConfigFilePath(ROOT_PATH)),
+            String.format(
+                "domain %s of email %s is allowed",
+                emailWithAllowedEmailDomain.substring(emailWithAllowedEmailDomain.indexOf('@') + 1),
+                emailWithAllowedEmailDomain));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.allowedEmailDomain", value = "example.net")
+  public void checkEmailWithNonAllowedDomain() throws Exception {
+    String emailWithNonAllowedEmailDomain = "foo@example.com";
+    TestAccount userWithAllowedEmail =
+        accountCreator.create(
+            "userWithNonAllowedEmail",
+            emailWithNonAllowedEmailDomain,
+            "User with non-allowed emil",
+            /* displayName= */ null);
+
+    setAsRootCodeOwners(userWithAllowedEmail);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo =
+        checkCodeOwner(ROOT_PATH, emailWithNonAllowedEmailDomain);
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                emailWithNonAllowedEmailDomain, getCodeOwnerConfigFilePath(ROOT_PATH)),
+            String.format(
+                "domain %s of email %s is not allowed",
+                emailWithNonAllowedEmailDomain.substring(
+                    emailWithNonAllowedEmailDomain.indexOf('@') + 1),
+                emailWithNonAllowedEmailDomain));
+  }
+
+  @Test
+  public void checkAllUsersWildcard() throws Exception {
+    CodeOwnerCheckInfo checkCodeOwnerInfo =
+        checkCodeOwner(ROOT_PATH, CodeOwnerResolver.ALL_USERS_WILDCARD);
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo).canReadRefNotSet();
+    assertThat(checkCodeOwnerInfo).canSeeChangeNotSet();
+    assertThat(checkCodeOwnerInfo).canApproveChangeNotSet();
+    assertThat(checkCodeOwnerInfo).hasCodeOwnerConfigFilePathsThat().isEmpty();
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotOwnedByAllUsers();
+  }
+
+  @Test
+  public void checkAllUsersWildcard_ownedByAllUsers() throws Exception {
+    setAsRootCodeOwners(CodeOwnerResolver.ALL_USERS_WILDCARD);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo =
+        checkCodeOwner(ROOT_PATH, CodeOwnerResolver.ALL_USERS_WILDCARD);
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                CodeOwnerResolver.ALL_USERS_WILDCARD, getCodeOwnerConfigFilePath(ROOT_PATH)));
+  }
+
+  @Test
+  public void checkDefaultCodeOwner() throws Exception {
+    TestAccount defaultCodeOwner =
+        accountCreator.create(
+            "defaultCodeOwner",
+            "defaultCodeOwner@example.com",
+            "Default Code Owner",
+            /* displayName= */ null);
+    setAsDefaultCodeOwners(defaultCodeOwner);
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, defaultCodeOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo).hasCodeOwnerConfigFilePathsThat().isEmpty();
+    assertThat(checkCodeOwnerInfo).isDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in default code owner config",
+                defaultCodeOwner.email()),
+            String.format("resolved to account %s", defaultCodeOwner.id()));
+  }
+
+  @Test
+  public void checkDefaultCodeOwner_ownedByAllUsers() throws Exception {
+    TestAccount defaultCodeOwner =
+        accountCreator.create(
+            "defaultCodeOwner",
+            "defaultCodeOwner@example.com",
+            "Default Code Owner",
+            /* displayName= */ null);
+    setAsDefaultCodeOwner(CodeOwnerResolver.ALL_USERS_WILDCARD);
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, defaultCodeOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo).hasCodeOwnerConfigFilePathsThat().isEmpty();
+    assertThat(checkCodeOwnerInfo).isDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in default code owner config",
+                CodeOwnerResolver.ALL_USERS_WILDCARD),
+            String.format("resolved to account %s", defaultCodeOwner.id()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "globalCodeOwner@example.com")
+  public void checkGlobalCodeOwner() throws Exception {
+    TestAccount globalCodeOwner =
+        accountCreator.create(
+            "globalCodeOwner",
+            "globalCodeOwner@example.com",
+            "Global Code Owner",
+            /* displayName= */ null);
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, globalCodeOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo).hasCodeOwnerConfigFilePathsThat().isEmpty();
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format("found email %s as global code owner", globalCodeOwner.email()),
+            String.format("resolved to account %s", globalCodeOwner.id()));
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.globalCodeOwner",
+      value = CodeOwnerResolver.ALL_USERS_WILDCARD)
+  public void checkGlobalCodeOwner_ownedByAllUsers() throws Exception {
+    TestAccount globalCodeOwner =
+        accountCreator.create(
+            "globalCodeOwner",
+            "globalCodeOwner@example.com",
+            "Global Code Owner",
+            /* displayName= */ null);
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, globalCodeOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo).hasCodeOwnerConfigFilePathsThat().isEmpty();
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as global code owner", CodeOwnerResolver.ALL_USERS_WILDCARD),
+            String.format("resolved to account %s", globalCodeOwner.id()));
+  }
+
+  @Test
+  public void checkCodeOwnerForOtherUser() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+    setAsCodeOwners("/foo/", codeOwner);
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, codeOwner.email(), user.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath("/foo/"));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                codeOwner.email(), getCodeOwnerConfigFilePath("/foo/")),
+            String.format("account %s is visible to user %s", codeOwner.id(), user.username()),
+            String.format("resolved to account %s", codeOwner.id()));
+  }
+
+  @Test
+  public void cannotCheckForNonExistingUser() throws Exception {
+    String nonExistingEmail = "non-existing@example.com";
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class, () -> checkCodeOwner("/", user.email(), nonExistingEmail));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(String.format("user %s not found", nonExistingEmail));
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void checkNonVisibleCodeOwnerForOtherUser() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+
+    setAsRootCodeOwners(codeOwner);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo =
+        checkCodeOwner(ROOT_PATH, codeOwner.email(), user.email());
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                codeOwner.email(), getCodeOwnerConfigFilePath(ROOT_PATH)),
+            String.format(
+                "cannot resolve code owner email %s: account %s is not visible to user %s",
+                codeOwner.email(), codeOwner.id(), user.username()));
+  }
+
+  @Test
+  public void checkNonVisibleCodeOwnerForOtherUser_secondaryEmail() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+    String secondaryEmail = "codeOwnerSecondary@example.com";
+    accountOperations
+        .account(codeOwner.id())
+        .forUpdate()
+        .addSecondaryEmail(secondaryEmail)
+        .update();
+
+    setAsRootCodeOwners(secondaryEmail);
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(ROOT_PATH, secondaryEmail, user.email());
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath(ROOT_PATH));
+    assertThat(checkCodeOwnerInfo).isNotDefaultCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotGlobalCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotOwnedByAllUsers();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "found email %s as code owner in %s",
+                secondaryEmail, getCodeOwnerConfigFilePath(ROOT_PATH)),
+            String.format(
+                "cannot resolve code owner email %s: account %s is referenced by secondary email"
+                    + " but user %s cannot see secondary emails",
+                secondaryEmail, codeOwner.id(), user.username()));
+  }
+
+  @Test
+  public void debugLogsContainUnresolvedImports() throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
+
+    CodeOwnerConfigReference unresolvableCodeOwnerConfigReferenceCodeOwnerConfigNotFound =
+        CodeOwnerConfigReference.create(
+            CodeOwnerConfigImportMode.ALL, "non-existing/" + getCodeOwnerConfigFileName());
+
+    CodeOwnerConfigReference unresolvableCodeOwnerConfigReferenceProjectNotFound =
+        CodeOwnerConfigReference.builder(
+                CodeOwnerConfigImportMode.ALL, getCodeOwnerConfigFileName())
+            .setProject(Project.nameKey("non-existing"))
+            .build();
+
+    Project.NameKey nonReadableProject =
+        projectOperations.newProject().name("non-readable").create();
+    ConfigInput configInput = new ConfigInput();
+    configInput.state = ProjectState.HIDDEN;
+    gApi.projects().name(nonReadableProject.get()).config(configInput);
+    CodeOwnerConfigReference unresolvableCodeOwnerConfigReferenceProjectNotReadable =
+        CodeOwnerConfigReference.builder(
+                CodeOwnerConfigImportMode.ALL, getCodeOwnerConfigFileName())
+            .setProject(nonReadableProject)
+            .build();
+
+    CodeOwnerConfig.Key codeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath(ROOT_PATH)
+            .addImport(unresolvableCodeOwnerConfigReferenceCodeOwnerConfigNotFound)
+            .addImport(unresolvableCodeOwnerConfigReferenceProjectNotFound)
+            .addImport(unresolvableCodeOwnerConfigReferenceProjectNotReadable)
+            .create();
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(ROOT_PATH, user.email());
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "Code owner config %s:%s:%s imports:\n"
+                    + "* %s (global import, import mode = ALL)\n"
+                    + "  * failed to resolve (code owner config not found)\n"
+                    + "* %s:%s (global import, import mode = ALL)\n"
+                    + "  * failed to resolve (project not found)\n"
+                    + "* %s:%s (global import, import mode = ALL)\n"
+                    + "  * failed to resolve (project state doesn't allow read)",
+                project,
+                "master",
+                getCodeOwnerConfigFilePath(codeOwnerConfigKey.folderPath().toString()),
+                unresolvableCodeOwnerConfigReferenceCodeOwnerConfigNotFound.filePath(),
+                unresolvableCodeOwnerConfigReferenceProjectNotFound.project().get(),
+                unresolvableCodeOwnerConfigReferenceProjectNotFound.filePath(),
+                unresolvableCodeOwnerConfigReferenceProjectNotReadable.project().get(),
+                unresolvableCodeOwnerConfigReferenceProjectNotReadable.filePath()),
+            String.format(
+                "The import of %s:%s:%s in %s:%s:%s cannot be resolved:"
+                    + " code owner config does not exist (revision = %s)",
+                project,
+                "master",
+                JgitPath.of(unresolvableCodeOwnerConfigReferenceCodeOwnerConfigNotFound.filePath())
+                    .getAsAbsolutePath(),
+                project,
+                "master",
+                getCodeOwnerConfigFilePath(codeOwnerConfigKey.folderPath().toString()),
+                projectOperations.project(project).getHead("master").name()),
+            String.format(
+                "The import of %s:%s:%s in %s:%s:%s cannot be resolved: project %s not found",
+                unresolvableCodeOwnerConfigReferenceProjectNotFound.project().get(),
+                "master",
+                JgitPath.of(unresolvableCodeOwnerConfigReferenceProjectNotFound.filePath())
+                    .getAsAbsolutePath(),
+                project,
+                "master",
+                getCodeOwnerConfigFilePath(codeOwnerConfigKey.folderPath().toString()),
+                unresolvableCodeOwnerConfigReferenceProjectNotFound.project().get()),
+            String.format(
+                "The import of %s:%s:%s in %s:%s:%s cannot be resolved:"
+                    + " state of project %s doesn't permit read",
+                unresolvableCodeOwnerConfigReferenceProjectNotReadable.project().get(),
+                "master",
+                JgitPath.of(unresolvableCodeOwnerConfigReferenceProjectNotReadable.filePath())
+                    .getAsAbsolutePath(),
+                project,
+                "master",
+                getCodeOwnerConfigFilePath(codeOwnerConfigKey.folderPath().toString()),
+                unresolvableCodeOwnerConfigReferenceProjectNotReadable.project().get()));
+  }
+
+  @Test
+  public void debugLogsContainUnresolvedTransitiveImports() throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath(ROOT_PATH)
+        .addImport(
+            CodeOwnerConfigReference.create(
+                CodeOwnerConfigImportMode.ALL, "/foo/" + getCodeOwnerConfigFileName()))
+        .create();
+
+    CodeOwnerConfigReference unresolvableCodeOwnerConfigReference =
+        CodeOwnerConfigReference.create(
+            CodeOwnerConfigImportMode.ALL, "non-existing/" + getCodeOwnerConfigFileName());
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addImport(unresolvableCodeOwnerConfigReference)
+        .create();
+
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(ROOT_PATH, user.email());
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "Code owner config %s:%s:%s imports:\n"
+                    + "* %s (global import, import mode = ALL)\n"
+                    + "  * %s (global import, import mode = ALL)\n"
+                    + "    * failed to resolve (code owner config not found)",
+                project,
+                "master",
+                getCodeOwnerConfigFilePath("/"),
+                getCodeOwnerConfigFilePath("/foo/"),
+                unresolvableCodeOwnerConfigReference.filePath()),
+            String.format(
+                "The import of %s:%s:%s in %s:%s:%s cannot be resolved:"
+                    + " code owner config does not exist (revision = %s)",
+                project,
+                "master",
+                JgitPath.of(unresolvableCodeOwnerConfigReference.filePath()).getAsAbsolutePath(),
+                project,
+                "master",
+                getCodeOwnerConfigFilePath("/foo/"),
+                projectOperations.project(project).getHead("master").name()));
+  }
+
+  @Test
+  public void checkPerFileCodeOwner() throws Exception {
+    TestAccount txtOwner =
+        accountCreator.create(
+            "txtCodeOwner", "txtCodeOwner@example.com", "Txt Code Owner", /* displayName= */ null);
+    TestAccount mdOwner =
+        accountCreator.create(
+            "mdCodeOwner", "mdCodeOwner@example.com", "Md Code Owner", /* displayName= */ null);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression(testPathExpressions.matchFileType("txt"))
+                .addCodeOwnerEmail(txtOwner.email())
+                .build())
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression(testPathExpressions.matchFileType("md"))
+                .addCodeOwnerEmail(mdOwner.email())
+                .build())
+        .create();
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, mdOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath("/foo/"));
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "per-file code owner set with path expressions [%s] matches",
+                testPathExpressions.matchFileType("md")),
+            String.format(
+                "found email %s as code owner in %s",
+                mdOwner.email(), getCodeOwnerConfigFilePath("/foo/")),
+            String.format("resolved to account %s", mdOwner.id()));
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatDoNotContainAnyOf(
+            String.format(
+                "path expressions [%s] matches", testPathExpressions.matchFileType("txt")));
+  }
+
+  @Test
+  public void checkPerFileCodeOwnerWhenParentCodeOwnersAreIgnored() throws Exception {
+    skipTestIfIgnoreParentCodeOwnersNotSupportedByCodeOwnersBackend();
+
+    TestAccount fileCodeOwner =
+        accountCreator.create(
+            "fileCodeOwner",
+            "fileCodeOwner@example.com",
+            "File Code Owner",
+            /* displayName= */ null);
+    TestAccount folderCodeOwner =
+        accountCreator.create(
+            "folderCodeOwner",
+            "folderCodeOwner@example.com",
+            "Folder Code Owner",
+            /* displayName= */ null);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(folderCodeOwner.email())
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression(testPathExpressions.matchFileType("md"))
+                .setIgnoreGlobalAndParentCodeOwners()
+                .addCodeOwnerEmail(fileCodeOwner.email())
+                .build())
+        .create();
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, folderCodeOwner.email());
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo).hasCodeOwnerConfigFilePathsThat().isEmpty();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "per-file code owner set with path expressions [%s] matches",
+                testPathExpressions.matchFileType("md")),
+            String.format(
+                "found matching per-file code owner set (with path expressions = [%s]) that ignores"
+                    + " parent code owners, hence ignoring the folder code owners",
+                testPathExpressions.matchFileType("md")));
+
+    checkCodeOwnerInfo = checkCodeOwner(path, fileCodeOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath("/foo/"));
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "per-file code owner set with path expressions [%s] matches",
+                testPathExpressions.matchFileType("md")),
+            String.format(
+                "found matching per-file code owner set (with path expressions = [%s]) that ignores"
+                    + " parent code owners, hence ignoring the folder code owners",
+                testPathExpressions.matchFileType("md")),
+            String.format(
+                "found email %s as code owner in %s",
+                fileCodeOwner.email(), getCodeOwnerConfigFilePath("/foo/")),
+            String.format("resolved to account %s", fileCodeOwner.id()));
+  }
+
+  @Test
+  public void checkCodeOwnerFromImportedConfig() throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
+
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addImport(
+            CodeOwnerConfigReference.create(
+                CodeOwnerConfigImportMode.ALL, "/bar/" + getCodeOwnerConfigFileName()))
+        .create();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/bar/")
+        .addImport(
+            CodeOwnerConfigReference.create(
+                CodeOwnerConfigImportMode.ALL, "/baz/" + getCodeOwnerConfigFileName()))
+        .create();
+
+    setAsCodeOwners("/baz/", codeOwner);
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, codeOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath("/foo/"));
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "Code owner config %s:%s:/foo/%s imports:\n"
+                    + "* /bar/%s (global import, import mode = ALL)\n"
+                    + "  * /baz/%s (global import, import mode = ALL)",
+                project,
+                "master",
+                getCodeOwnerConfigFileName(),
+                getCodeOwnerConfigFileName(),
+                getCodeOwnerConfigFileName()),
+            String.format(
+                "found email %s as code owner in %s",
+                codeOwner.email(), getCodeOwnerConfigFilePath("/foo/")),
+            String.format("resolved to account %s", codeOwner.id()));
+  }
+
+  @Test
+  public void checkCodeOwnerFromImportedPerFileConfig() throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
+
+    TestAccount mdCodeOwner =
+        accountCreator.create(
+            "mdCodeOwner", "mdCodeOwner@example.com", "Md Code Owner", /* displayName= */ null);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addImport(
+            CodeOwnerConfigReference.create(
+                CodeOwnerConfigImportMode.ALL, "/bar/" + getCodeOwnerConfigFileName()))
+        .create();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/bar/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression(testPathExpressions.matchFileType("md"))
+                .addImport(
+                    CodeOwnerConfigReference.create(
+                        CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
+                        "/baz/" + getCodeOwnerConfigFileName()))
+                .build())
+        .create();
+
+    setAsCodeOwners("/baz/", mdCodeOwner);
+
+    // 1. check for mdCodeOwner and path of an md file
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, mdCodeOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo)
+        .hasCodeOwnerConfigFilePathsThat()
+        .containsExactly(getCodeOwnerConfigFilePath("/foo/"));
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "Code owner config %s:%s:/foo/%s imports:\n"
+                    + "* /bar/%s (global import, import mode = ALL)\n"
+                    + "  * per-file code owner set with path expressions [%s] matches\n"
+                    + "  * /baz/%s (per-file import, import mode = GLOBAL_CODE_OWNER_SETS_ONLY,"
+                    + " path expressions = [%s])",
+                project,
+                "master",
+                getCodeOwnerConfigFileName(),
+                getCodeOwnerConfigFileName(),
+                testPathExpressions.matchFileType("md"),
+                getCodeOwnerConfigFileName(),
+                testPathExpressions.matchFileType("md")),
+            String.format(
+                "found email %s as code owner in %s",
+                mdCodeOwner.email(), getCodeOwnerConfigFilePath("/foo/")),
+            String.format("resolved to account %s", mdCodeOwner.id()));
+
+    // 2. check for user and path of an md file
+    checkCodeOwnerInfo = checkCodeOwner(path, user.email());
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo).hasCodeOwnerConfigFilePathsThat().isEmpty();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "Code owner config %s:%s:/foo/%s imports:\n"
+                    + "* /bar/%s (global import, import mode = ALL)\n"
+                    + "  * per-file code owner set with path expressions [%s] matches\n"
+                    + "  * /baz/%s (per-file import, import mode = GLOBAL_CODE_OWNER_SETS_ONLY,"
+                    + " path expressions = [%s])",
+                project,
+                "master",
+                getCodeOwnerConfigFileName(),
+                getCodeOwnerConfigFileName(),
+                testPathExpressions.matchFileType("md"),
+                getCodeOwnerConfigFileName(),
+                testPathExpressions.matchFileType("md")),
+            String.format("resolved to account %s", user.id()));
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatDoNotContainAnyOf(String.format("email %s", user.email()));
+
+    // 3. check for mdCodeOwner and path of an txt file
+    path = "/foo/bar/baz.txt";
+    checkCodeOwnerInfo = checkCodeOwner(path, mdCodeOwner.email());
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo).hasCodeOwnerConfigFilePathsThat().isEmpty();
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatContainAllOf(
+            String.format(
+                "Code owner config %s:%s:/foo/%s imports:\n"
+                    + "* /bar/%s (global import, import mode = ALL)",
+                project, "master", getCodeOwnerConfigFileName(), getCodeOwnerConfigFileName()),
+            String.format("resolved to account %s", mdCodeOwner.id()));
+    assertThat(checkCodeOwnerInfo)
+        .hasDebugLogsThatDoNotContainAnyOf(String.format("email %s", mdCodeOwner.email()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  public void checkFallbackCodeOwner_AllUsers() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+    setAsCodeOwners("/foo/", codeOwner);
+
+    // 1. Check for a file to which fallback code owners do not apply because code owners are
+    // defined
+    String path = "/foo/bar/baz.md";
+
+    // 1a. by a code owner
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, codeOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotFallbackCodeOwner();
+
+    // 1b. by a non code owner
+    checkCodeOwnerInfo = checkCodeOwner(path, user.email());
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotFallbackCodeOwner();
+
+    // 2. Check for a file to which fallback code owners apply because no code owners are defined
+    path = "/other/bar/baz.md";
+    checkCodeOwnerInfo = checkCodeOwner(path, user.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isFallbackCodeOwner();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "PROJECT_OWNERS")
+  public void checkFallbackCodeOwner_ProjectOwners() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+    setAsCodeOwners("/foo/", codeOwner);
+
+    // 1. Check for a file to which fallback code owners do not apply because code owners are
+    // defined
+    String path = "/foo/bar/baz.md";
+
+    // 1a. by a code owner
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, codeOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotFallbackCodeOwner();
+
+    // 1b. by a project owner
+    checkCodeOwnerInfo = checkCodeOwner(path, admin.email());
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotFallbackCodeOwner();
+
+    // 1c. by a non code owner
+    checkCodeOwnerInfo = checkCodeOwner(path, user.email());
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotFallbackCodeOwner();
+
+    // 2. Check for a file to which fallback code owners apply because no code owners are defined
+    path = "/other/bar/baz.md";
+
+    // 2b. by a project owner
+    checkCodeOwnerInfo = checkCodeOwner(path, admin.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isFallbackCodeOwner();
+
+    // 2b. by a non project owner
+    checkCodeOwnerInfo = checkCodeOwner(path, user.email());
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotFallbackCodeOwner();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  public void noFallbackCodeOwnerIfParentCodeOwnersIgnored() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .ignoreParentCodeOwners()
+        .create();
+
+    // 1. Check for a file to which parent code owners are ignored
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, user.email());
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotFallbackCodeOwner();
+
+    // 2. Check for a file to which parent code owners are not ignored
+    path = "/other/bar/baz.md";
+    checkCodeOwnerInfo = checkCodeOwner(path, user.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isFallbackCodeOwner();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void noFallbackCodeOwnerIfNonVisibleRelevantCodeOwnerExists() throws Exception {
+    TestAccount nonVisibleCodeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(nonVisibleCodeOwner.email())
+        .create();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // verify that the account is not visible
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.accounts().id(nonVisibleCodeOwner.id().get()));
+
+    // allow user to call the check code owner REST endpoint
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability("code-owners-" + CheckCodeOwnerCapability.ID).group(REGISTERED_USERS))
+        .update();
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, admin.email());
+    assertThat(checkCodeOwnerInfo).isNotCodeOwner();
+    assertThat(checkCodeOwnerInfo).isNotFallbackCodeOwner();
+  }
+
+  @Test
+  public void noEmptyDebugLogs() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+    setAsCodeOwners("/foo/", codeOwner);
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, codeOwner.email());
+    assertThat(checkCodeOwnerInfo).hasDebugLogsThatDoNotContainAnyOf("");
+  }
+
+  @Test
+  public void checkCodeOwnerThatCannotReadRef() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+    setAsCodeOwners("/foo/", codeOwner);
+
+    // Make read permission on master branch exclusive for admins, so that the code owner cannot
+    // read master.
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(adminGroupUuid()))
+        .setExclusiveGroup(permissionKey(Permission.READ).ref("refs/heads/master"), true)
+        .update();
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo = checkCodeOwner(path, codeOwner.email());
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo).cannotReadRef();
+    assertThat(checkCodeOwnerInfo).canSeeChangeNotSet();
+    assertThat(checkCodeOwnerInfo).canApproveChangeNotSet();
+  }
+
+  @Test
+  public void cannotCheckForNonExistingChange() throws Exception {
+    String nonExistingChange = "non-existing";
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> checkCodeOwnerForChange("/", user.email(), nonExistingChange));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(String.format("change %s not found", nonExistingChange));
+  }
+
+  @Test
+  public void cannotCheckForNonVisibleChange() throws Exception {
+    String changeId = createChange().getChangeId();
+    gApi.changes().id(changeId).setPrivate(true);
+
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability("code-owners-" + CheckCodeOwnerCapability.ID).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class, () -> checkCodeOwnerForChange("/", user.email(), changeId));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(String.format("change %s not found", changeId));
+  }
+
+  @Test
+  public void cannotCheckForChangeOfOtherBranch() throws Exception {
+    // Create another branch
+    String branchName = "foo";
+    BranchInput branchInput = new BranchInput();
+    branchInput.ref = branchName;
+    branchInput.revision = projectOperations.project(project).getHead("master").name();
+    gApi.projects().name(project.get()).branch(branchInput.ref).create(branchInput);
+
+    String changeId = createChange("refs/for/" + branchName).getChangeId();
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class, () -> checkCodeOwnerForChange("/", user.email(), changeId));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("target branch of specified change must match branch from the request URL");
+  }
+
+  @Test
+  public void checkCodeOwnerThatCannotSeeChange_privateChange() throws Exception {
+    String changeId = createChange().getChangeId();
+    gApi.changes().id(changeId).setPrivate(true);
+
+    testCheckCodeOwnerThatCannotSeeChange(changeId, /* canReadRef= */ true);
+  }
+
+  @Test
+  public void checkCodeOwnerThatCannotSeeChange_cannotReadRef() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    // Make read permission on master branch exclusive for admins, so that the code owner cannot
+    // read master.
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(adminGroupUuid()))
+        .setExclusiveGroup(permissionKey(Permission.READ).ref("refs/heads/master"), true)
+        .update();
+
+    testCheckCodeOwnerThatCannotSeeChange(changeId, /* canReadRef= */ false);
+  }
+
+  private void testCheckCodeOwnerThatCannotSeeChange(String changeId, boolean canReadRef)
+      throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+    setAsCodeOwners("/foo/", codeOwner);
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo =
+        checkCodeOwnerForChange(path, codeOwner.email(), changeId);
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    if (canReadRef) {
+      assertThat(checkCodeOwnerInfo).canReadRef();
+    } else {
+      assertThat(checkCodeOwnerInfo).cannotReadRef();
+    }
+    assertThat(checkCodeOwnerInfo).cannotSeeChange();
+    assertThat(checkCodeOwnerInfo).canApproveChange();
+  }
+
+  @Test
+  public void checkCodeOwnerThatCannotApproveChange() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    // Remove permission to vote on the Code-Review label.
+    projectOperations
+        .allProjectsForUpdate()
+        .remove(labelPermissionKey("Code-Review").ref("refs/heads/*"))
+        .update();
+
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "Code Owner", /* displayName= */ null);
+    setAsCodeOwners("/foo/", codeOwner);
+
+    String path = "/foo/bar/baz.md";
+    CodeOwnerCheckInfo checkCodeOwnerInfo =
+        checkCodeOwnerForChange(path, codeOwner.email(), changeId);
+    assertThat(checkCodeOwnerInfo).isCodeOwner();
+    assertThat(checkCodeOwnerInfo).isResolvable();
+    assertThat(checkCodeOwnerInfo).canReadRef();
+    assertThat(checkCodeOwnerInfo).canSeeChange();
+    assertThat(checkCodeOwnerInfo).cannotApproveChange();
+  }
+
+  private CodeOwnerCheckInfo checkCodeOwner(String path, String email) throws RestApiException {
+    return checkCodeOwner(path, email, /* user= */ null);
+  }
+
+  private CodeOwnerCheckInfo checkCodeOwner(String path, String email, @Nullable String user)
+      throws RestApiException {
+    return projectCodeOwnersApiFactory
+        .project(project)
+        .branch("master")
+        .checkCodeOwner()
+        .path(path)
+        .email(email)
+        .user(user)
+        .check();
+  }
+
+  private CodeOwnerCheckInfo checkCodeOwnerForChange(
+      String path, String email, @Nullable String change) throws RestApiException {
+    return projectCodeOwnersApiFactory
+        .project(project)
+        .branch("master")
+        .checkCodeOwner()
+        .path(path)
+        .email(email)
+        .change(change)
+        .check();
+  }
+
+  private String getCodeOwnerConfigFilePath(String folderPath) {
+    assertThat(folderPath).startsWith("/");
+    assertThat(folderPath).endsWith("/");
+    return folderPath + getCodeOwnerConfigFileName();
+  }
+
+  private void setAsRootCodeOwners(String... emails) {
+    TestCodeOwnerConfigCreation.Builder builder =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath(ROOT_PATH);
+    Arrays.stream(emails).forEach(builder::addCodeOwnerEmail);
+    builder.create();
+  }
+
+  private void setAsDefaultCodeOwner(String email) {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch(RefNames.REFS_CONFIG)
+        .folderPath(ROOT_PATH)
+        .addCodeOwnerEmail(email)
+        .create();
+  }
+
+  private void addEmail(Account.Id accountId, String email) throws Exception {
+    accountsUpdate
+        .get()
+        .update(
+            "Test update",
+            accountId,
+            (a, u) ->
+                u.addExternalId(
+                    ExternalId.create(
+                        "foo",
+                        "bar" + accountId.get(),
+                        accountId,
+                        email,
+                        /* hashedPassword= */ null)));
+  }
+}
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 2e5e73b..fda0093 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java
@@ -15,45 +15,68 @@
 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.acceptance.testsuite.project.TestProjectUpdate.block;
 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.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.TestProjectInput;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.PrivateInternals_DynamicMapImpl;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.gerrit.plugins.codeowners.acceptance.testsuite.TestCodeOwnerConfigCreation;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigImportMode;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigImportType;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigReference;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigUpdate;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerResolver;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerSet;
+import com.google.gerrit.plugins.codeowners.backend.PathExpressionMatcher;
+import com.google.gerrit.plugins.codeowners.backend.config.BackendConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig;
 import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
 import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersCodeOwnerConfigParser;
 import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend;
 import com.google.gerrit.plugins.codeowners.backend.proto.ProtoCodeOwnerConfigParser;
-import com.google.gerrit.plugins.codeowners.config.BackendConfig;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerConfigValidationPolicy;
+import com.google.gerrit.plugins.codeowners.validation.SkipCodeOwnerConfigValidationCapability;
+import com.google.gerrit.plugins.codeowners.validation.SkipCodeOwnerConfigValidationPushOption;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.inject.Inject;
+import com.google.inject.Key;
+import com.google.inject.util.Providers;
+import java.nio.file.Path;
+import java.util.Optional;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -68,6 +91,7 @@
   private BackendConfig backendConfig;
   private FindOwnersCodeOwnerConfigParser findOwnersCodeOwnerConfigParser;
   private ProtoCodeOwnerConfigParser protoCodeOwnerConfigParser;
+  private DynamicMap<CodeOwnerBackend> codeOwnerBackends;
 
   @Before
   public void setUpCodeOwnersPlugin() throws Exception {
@@ -76,6 +100,8 @@
         plugin.getSysInjector().getInstance(FindOwnersCodeOwnerConfigParser.class);
     protoCodeOwnerConfigParser =
         plugin.getSysInjector().getInstance(ProtoCodeOwnerConfigParser.class);
+    codeOwnerBackends =
+        plugin.getSysInjector().getInstance(new Key<DynamicMap<CodeOwnerBackend>>() {});
   }
 
   @Test
@@ -101,6 +127,23 @@
   }
 
   @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void canUploadConfigWithoutIssuesInInitialCommit() throws Exception {
+    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
+
+    // Create a code owner config without issues.
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
+            format(
+                CodeOwnerConfig.builder(codeOwnerConfigKey, TEST_REVISION)
+                    .addCodeOwnerSet(CodeOwnerSet.createWithoutPathExpressions(admin.email()))
+                    .build()));
+    assertOkWithHints(r, "code owner config files validated, no issues found");
+  }
+
+  @Test
   public void canUploadConfigWhichAssignsCodeOwnershipToAllUsers() throws Exception {
     testCanUploadConfigWhichAssignsCodeOwnershipToAllUsers();
   }
@@ -130,7 +173,10 @@
   }
 
   @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
   public void canSubmitConfigWithoutIssues() throws Exception {
+    setAsDefaultCodeOwners(admin);
+
     CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
 
     // Create a code owner config without issues.
@@ -152,8 +198,7 @@
 
   @Test
   public void canUploadConfigWithoutIssues_withImport() throws Exception {
-    // imports are not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
 
     CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
 
@@ -198,8 +243,7 @@
 
   @Test
   public void canUploadConfigWithoutIssues_withImportFromOtherProject() throws Exception {
-    // imports are not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
 
     CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
 
@@ -243,8 +287,7 @@
 
   @Test
   public void canUploadConfigWithoutIssues_withImportFromOtherProjectAndBranch() throws Exception {
-    // imports are not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
 
     CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
 
@@ -289,10 +332,10 @@
   }
 
   @Test
-  public void canUploadConfigWithoutIssues_withImportOfConfigThatIsAddedInSameCommit()
-      throws Exception {
-    // imports are not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+  public void
+      canUploadConfigWithImportOfConfigThatIsAddedInSameCommit_importModeGlobalCodeOwnersOnly()
+          throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
 
     CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
     CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig = createCodeOwnerConfigKey("/foo/");
@@ -302,7 +345,6 @@
             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",
@@ -330,6 +372,41 @@
   }
 
   @Test
+  public void canUploadConfigWithImportOfConfigThatIsAddedInSameCommit_importModeAll()
+      throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
+
+    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig = createCodeOwnerConfigKey("/foo/");
+
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        CodeOwnerConfigReference.create(
+            CodeOwnerConfigImportMode.ALL,
+            codeOwnerConfigOperations.codeOwnerConfig(keyOfImportedCodeOwnerConfig).getFilePath());
+
+    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()).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 {
@@ -358,6 +435,175 @@
   }
 
   @Test
+  public void userCannotSkipCodeOwnerConfigValidationWithoutCapability() throws Exception {
+    PushOneCommit.Result r =
+        uploadNonParseableConfigWithPushOption(
+            user, String.format("code-owners~%s", SkipCodeOwnerConfigValidationPushOption.NAME));
+    assertErrorWithMessages(
+        r,
+        "skipping code owner config validation not allowed",
+        String.format(
+            "%s for plugin code-owners not permitted", SkipCodeOwnerConfigValidationCapability.ID));
+  }
+
+  @Test
+  public void adminCanSkipCodeOwnerConfigValidation() throws Exception {
+    PushOneCommit.Result r =
+        uploadNonParseableConfigWithPushOption(
+            admin, String.format("code-owners~%s", SkipCodeOwnerConfigValidationPushOption.NAME));
+    assertOkWithHints(
+        r,
+        "skipping validation of code owner config files",
+        String.format(
+            "the validation is skipped due to the --code-owners~%s push option",
+            SkipCodeOwnerConfigValidationPushOption.NAME));
+  }
+
+  @Test
+  public void canUploadNonParseableConfigWithSkipOption() throws Exception {
+    allowRegisteredUsersToSkipValidation();
+
+    // with --code-owners~skip-validation
+    PushOneCommit.Result r =
+        uploadNonParseableConfigWithPushOption(
+            user, String.format("code-owners~%s", SkipCodeOwnerConfigValidationPushOption.NAME));
+    assertOkWithHints(
+        r,
+        "skipping validation of code owner config files",
+        String.format(
+            "the validation is skipped due to the --code-owners~%s push option",
+            SkipCodeOwnerConfigValidationPushOption.NAME));
+
+    // with --code-owners~skip-validation=true
+    r =
+        uploadNonParseableConfigWithPushOption(
+            user,
+            String.format("code-owners~%s=true", SkipCodeOwnerConfigValidationPushOption.NAME));
+    assertOkWithHints(
+        r,
+        "skipping validation of code owner config files",
+        String.format(
+            "the validation is skipped due to the --code-owners~%s push option",
+            SkipCodeOwnerConfigValidationPushOption.NAME));
+
+    // with --code-owners~skip-validation=TRUE
+    r =
+        uploadNonParseableConfigWithPushOption(
+            user,
+            String.format("code-owners~%s=TRUE", SkipCodeOwnerConfigValidationPushOption.NAME));
+    assertOkWithHints(
+        r,
+        "skipping validation of code owner config files",
+        String.format(
+            "the validation is skipped due to the --code-owners~%s push option",
+            SkipCodeOwnerConfigValidationPushOption.NAME));
+  }
+
+  @Test
+  public void cannotUploadNonParseableConfigIfSkipOptionIsFalse() throws Exception {
+    allowRegisteredUsersToSkipValidation();
+
+    // with --code-owners~skip-validation=false
+    PushOneCommit.Result r =
+        uploadNonParseableConfigWithPushOption(
+            user,
+            String.format("code-owners~%s=false", SkipCodeOwnerConfigValidationPushOption.NAME));
+    String abbreviatedCommit = abbreviateName(r.getCommit());
+    r.assertErrorStatus(
+        String.format(
+            "commit %s: [code-owners] %s", abbreviatedCommit, "invalid code owner config files"));
+  }
+
+  @Test
+  public void cannotUploadNonParseableConfigWithInvalidSkipOption() throws Exception {
+    allowRegisteredUsersToSkipValidation();
+
+    PushOneCommit.Result r =
+        uploadNonParseableConfigWithPushOption(
+            user,
+            String.format("code-owners~%s=INVALID", SkipCodeOwnerConfigValidationPushOption.NAME));
+    assertErrorWithMessages(
+        r,
+        "invalid push option",
+        String.format(
+            "Invalid value for --code-owners~%s push option: INVALID",
+            SkipCodeOwnerConfigValidationPushOption.NAME));
+  }
+
+  @Test
+  public void cannotUploadNonParseableConfigIfSkipOptionIsSetMultipleTimes() throws Exception {
+    allowRegisteredUsersToSkipValidation();
+
+    PushOneCommit.Result r =
+        uploadNonParseableConfigWithPushOption(
+            user,
+            String.format("code-owners~%s", SkipCodeOwnerConfigValidationPushOption.NAME),
+            String.format("code-owners~%s=false", SkipCodeOwnerConfigValidationPushOption.NAME));
+    assertErrorWithMessages(
+        r,
+        "invalid push option",
+        String.format(
+            "--code-owners~%s push option can be specified only once, received multiple values: [, false]",
+            SkipCodeOwnerConfigValidationPushOption.NAME));
+  }
+
+  private PushOneCommit.Result uploadNonParseableConfigWithPushOption(
+      TestAccount testAccount, String... pushOptions) throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, testAccount);
+    PushOneCommit push =
+        pushFactory.create(
+            testAccount.newIdent(),
+            userRepo,
+            "Add code owners",
+            codeOwnerConfigOperations
+                .codeOwnerConfig(createCodeOwnerConfigKey("/"))
+                .getJGitFilePath(),
+            "INVALID");
+    push.setPushOptions(ImmutableList.copyOf(pushOptions));
+    return push.to("refs/for/master");
+  }
+
+  private void allowRegisteredUsersToSkipValidation() {
+    // grant the global capability that is required to use the
+    // --code-owners~skip-validation push option to registered users
+    projectOperations
+        .allProjectsForUpdate()
+        .add(
+            allowCapability("code-owners-" + SkipCodeOwnerConfigValidationCapability.ID)
+                .group(REGISTERED_USERS))
+        .update();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnCommitReceived", value = "forced")
+  public void
+      cannotUploadNonParseableConfigIfCodeOwnersFunctionalityIsDisabledButValidationIsEnforced()
+          throws Exception {
+    disableCodeOwnersForProject(project);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
+
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
+            "INVALID");
+    assertFatalWithMessages(
+        r,
+        "invalid code owner config files",
+        String.format(
+            "invalid code owner config file '%s' (project = %s, branch = master):\n  %s",
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath(),
+            project,
+            getParsingErrorMessage(
+                ImmutableMap.of(
+                    FindOwnersBackend.class,
+                    "invalid line: INVALID",
+                    ProtoBackend.class,
+                    "1:8: expected \"{\""))));
+  }
+
+  @Test
   @GerritConfig(name = "plugin.code-owners.enableValidationOnCommitReceived", value = "dry_run")
   public void canUploadNonParseableConfigIfValidationIsDoneAsDryRun() throws Exception {
     CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
@@ -371,8 +617,40 @@
         r,
         "invalid code owner config files",
         String.format(
-            "invalid code owner config file '%s':\n  %s",
+            "invalid code owner config file '%s' (project = %s, branch = master):\n  %s",
             codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath(),
+            project,
+            getParsingErrorMessage(
+                ImmutableMap.of(
+                    FindOwnersBackend.class,
+                    "invalid line: INVALID",
+                    ProtoBackend.class,
+                    "1:8: expected \"{\""))));
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.enableValidationOnCommitReceived",
+      value = "forced_dry_run")
+  public void
+      canUploadNonParseableConfigIfCodeOwnersFunctionalityIsDisabledButDryRunValidationIsEnforced()
+          throws Exception {
+    disableCodeOwnersForProject(project);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
+
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
+            "INVALID");
+    assertOkWithFatals(
+        r,
+        "invalid code owner config files",
+        String.format(
+            "invalid code owner config file '%s' (project = %s, branch = master):\n  %s",
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath(),
+            project,
             getParsingErrorMessage(
                 ImmutableMap.of(
                     FindOwnersBackend.class,
@@ -402,7 +680,10 @@
 
   @Test
   @GerritConfig(name = "plugin.code-owners.enableValidationOnCommitReceived", value = "false")
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
   public void onReceiveCommitValidationDisabled() throws Exception {
+    setAsDefaultCodeOwners(admin);
+
     // upload a change with a code owner config that has issues (non-resolvable code owners)
     String unknownEmail = "non-existing-email@example.com";
     CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
@@ -432,7 +713,7 @@
         .isEqualTo(
             String.format(
                 "Failed to submit 1 change due to the following problems:\n"
-                    + "Change %d: invalid code owner config files:\n"
+                    + "Change %d: [code-owners] invalid code owner config files:\n"
                     + "  ERROR: code owner email '%s' in '%s' cannot be resolved for %s",
                 r.getChange().getId().get(),
                 unknownEmail,
@@ -490,8 +771,9 @@
         r,
         "invalid code owner config files",
         String.format(
-            "invalid code owner config file '%s':\n  %s",
+            "invalid code owner config file '%s' (project = %s, branch = master):\n  %s",
             codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath(),
+            project,
             getParsingErrorMessage(
                 ImmutableMap.of(
                     FindOwnersBackend.class,
@@ -604,8 +886,44 @@
         r,
         "invalid code owner config files",
         String.format(
-            "invalid code owner config file '%s':\n  %s",
+            "invalid code owner config file '%s' (project = %s, branch = master):\n  %s",
             codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath(),
+            project,
+            getParsingErrorMessage(
+                ImmutableMap.of(
+                    FindOwnersBackend.class,
+                    "invalid line: INVALID",
+                    ProtoBackend.class,
+                    "1:8: expected \"{\""))));
+  }
+
+  @Test
+  public void cannotUpdateConfigToBeNonParseable() throws Exception {
+    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
+
+    // Create a code owner config without issues.
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
+            format(
+                CodeOwnerConfig.builder(codeOwnerConfigKey, TEST_REVISION)
+                    .addCodeOwnerSet(CodeOwnerSet.createWithoutPathExpressions(admin.email()))
+                    .build()));
+    r.assertOkStatus();
+
+    r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getJGitFilePath(),
+            "INVALID");
+    assertFatalWithMessages(
+        r,
+        "invalid code owner config files",
+        String.format(
+            "invalid code owner config file '%s' (project = %s, branch = master):\n  %s",
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath(),
+            project,
             getParsingErrorMessage(
                 ImmutableMap.of(
                     FindOwnersBackend.class,
@@ -634,8 +952,9 @@
         r,
         "invalid code owner config files",
         String.format(
-            "invalid code owner config file '%s':\n  %s",
+            "invalid code owner config file '%s' (project = %s, branch = master):\n  %s",
             codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey1).getFilePath(),
+            project,
             getParsingErrorMessage(
                 ImmutableMap.of(
                     FindOwnersBackend.class,
@@ -643,8 +962,9 @@
                     ProtoBackend.class,
                     "1:8: expected \"{\""))),
         String.format(
-            "invalid code owner config file '%s':\n  %s",
+            "invalid code owner config file '%s' (project = %s, branch = master):\n  %s",
             codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey2).getFilePath(),
+            project,
             getParsingErrorMessage(
                 ImmutableMap.of(
                     FindOwnersBackend.class,
@@ -766,10 +1086,11 @@
 
     String abbreviatedCommit = abbreviateName(r.getCommit());
     r.assertErrorStatus(
-        String.format("commit %s: %s", abbreviatedCommit, "invalid code owner config files"));
+        String.format(
+            "commit %s: [code-owners] %s", abbreviatedCommit, "invalid code owner config files"));
     r.assertMessage(
         String.format(
-            "error: commit %s: %s",
+            "error: commit %s: [code-owners] %s",
             abbreviatedCommit,
             String.format(
                 "code owner email '%s' in '%s' cannot be resolved for %s",
@@ -780,7 +1101,7 @@
     // the pre-existing issue is returned as warning
     r.assertMessage(
         String.format(
-            "warning: commit %s: code owner email '%s' in '%s' cannot be resolved for %s",
+            "warning: commit %s: [code-owners] code owner email '%s' in '%s' cannot be resolved for %s",
             abbreviatedCommit,
             unknownEmail1,
             codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath(),
@@ -790,7 +1111,10 @@
   }
 
   @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
   public void cannotSubmitConfigWithNewIssues() throws Exception {
+    setAsDefaultCodeOwners(admin);
+
     CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
 
     // disable the code owners functionality so that we can upload a a change with a code owner
@@ -825,7 +1149,7 @@
         .isEqualTo(
             String.format(
                 "Failed to submit 1 change due to the following problems:\n"
-                    + "Change %d: invalid code owner config files:\n"
+                    + "Change %d: [code-owners] invalid code owner config files:\n"
                     + "  ERROR: code owner email '%s' in '%s' cannot be resolved for %s",
                 r.getChange().getId().get(),
                 unknownEmail,
@@ -874,7 +1198,7 @@
     r.assertOkStatus();
     r.assertMessage(
         String.format(
-            "error: commit %s: %s",
+            "error: commit %s: [code-owners] %s",
             abbreviatedCommit,
             String.format(
                 "code owner email '%s' in '%s' cannot be resolved for %s",
@@ -885,7 +1209,8 @@
     // the pre-existing issue is returned as warning
     r.assertMessage(
         String.format(
-            "warning: commit %s: code owner email '%s' in '%s' cannot be resolved for %s",
+            "warning: commit %s: [code-owners] code owner email '%s' in '%s' cannot be resolved"
+                + " for %s",
             abbreviatedCommit,
             unknownEmail1,
             codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath(),
@@ -896,8 +1221,11 @@
 
   @Test
   @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
   public void cannotSubmitConfigWithCodeOwnersThatAreNotVisibleToThePatchSetUploader()
       throws Exception {
+    setAsDefaultCodeOwners(admin);
+
     // Create a new user that is not a member of any group. This means 'user' and 'admin' are not
     // visible to this user since they do not share any group.
     TestAccount user2 = accountCreator.user2();
@@ -938,7 +1266,7 @@
         .isEqualTo(
             String.format(
                 "Failed to submit 1 change due to the following problems:\n"
-                    + "Change %d: invalid code owner config files:\n"
+                    + "Change %d: [code-owners] invalid code owner config files:\n"
                     + "  ERROR: code owner email '%s' in '%s' cannot be resolved for %s",
                 r.getChange().getId().get(),
                 admin.email(),
@@ -948,8 +1276,11 @@
 
   @Test
   @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
   public void canSubmitConfigWithCodeOwnersThatAreNotVisibleToTheSubmitterButVisibleToTheUploader()
       throws Exception {
+    setAsDefaultCodeOwners(admin);
+
     // Create a new user that is not a member of any group. This means 'user' and 'admin' are not
     // visible to this user since they do not share any group.
     TestAccount user2 = accountCreator.user2();
@@ -986,6 +1317,102 @@
   }
 
   @Test
+  public void uploadConfigWithGlobalSelfImportReportsAWarning() throws Exception {
+    testUploadConfigWithSelfImport(CodeOwnerConfigImportType.GLOBAL);
+  }
+
+  @Test
+  public void uploadConfigWithPerFileSelfImportReportsAWarning() throws Exception {
+    testUploadConfigWithSelfImport(CodeOwnerConfigImportType.PER_FILE);
+  }
+
+  private void testUploadConfigWithSelfImport(CodeOwnerConfigImportType importType)
+      throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
+
+    // create a code owner config that imports itself
+    CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig = createCodeOwnerConfigKey("/");
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        CodeOwnerConfigReference.builder(
+                CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
+                codeOwnerConfigOperations
+                    .codeOwnerConfig(keyOfImportingCodeOwnerConfig)
+                    .getFilePath())
+            .setProject(project)
+            .build();
+    CodeOwnerConfig codeOwnerConfig =
+        createCodeOwnerConfigWithImport(
+            keyOfImportingCodeOwnerConfig, importType, codeOwnerConfigReference);
+
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations
+                .codeOwnerConfig(keyOfImportingCodeOwnerConfig)
+                .getJGitFilePath(),
+            format(codeOwnerConfig));
+    assertOkWithWarnings(
+        r,
+        "invalid code owner config files",
+        String.format(
+            "invalid %s import in '%s': code owner config imports itself",
+            importType.getType(),
+            codeOwnerConfigOperations
+                .codeOwnerConfig(keyOfImportingCodeOwnerConfig)
+                .getFilePath()));
+  }
+
+  @Test
+  public void canUploadConfigWithGlobalImportOfFileWithExtensionFromSameFolder() throws Exception {
+    testUploadConfigWithImportOfFileWithExtensionFromSameFolder(CodeOwnerConfigImportType.GLOBAL);
+  }
+
+  @Test
+  public void canUploadConfigWithPerFileImportOfFileWithExtensionFromSameFolder() throws Exception {
+    testUploadConfigWithImportOfFileWithExtensionFromSameFolder(CodeOwnerConfigImportType.PER_FILE);
+  }
+
+  private void testUploadConfigWithImportOfFileWithExtensionFromSameFolder(
+      CodeOwnerConfigImportType importType) throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
+
+    // create a code owner config that imports a code owner config from the same folder but with an
+    // extension in the file name
+    CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig = createCodeOwnerConfigKey("/");
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .fileName(getCodeOwnerConfigFileName() + "_extension")
+            .addCodeOwnerEmail(user.email())
+            .create();
+    GitUtil.fetch(testRepo, "refs/*:refs/*");
+    testRepo.reset(projectOperations.project(project).getHead("master"));
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        CodeOwnerConfigReference.builder(
+                CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
+                codeOwnerConfigOperations
+                    .codeOwnerConfig(keyOfImportedCodeOwnerConfig)
+                    .getFilePath())
+            .setProject(project)
+            .build();
+    CodeOwnerConfig codeOwnerConfig =
+        createCodeOwnerConfigWithImport(
+            keyOfImportingCodeOwnerConfig, importType, codeOwnerConfigReference);
+
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations
+                .codeOwnerConfig(keyOfImportingCodeOwnerConfig)
+                .getJGitFilePath(),
+            format(codeOwnerConfig));
+    r.assertOkStatus();
+  }
+
+  @Test
   public void cannotUploadConfigWithGlobalImportFromNonExistingProject() throws Exception {
     testUploadConfigWithImportFromNonExistingProject(CodeOwnerConfigImportType.GLOBAL);
   }
@@ -997,8 +1424,7 @@
 
   private void testUploadConfigWithImportFromNonExistingProject(
       CodeOwnerConfigImportType importType) throws Exception {
-    // imports are not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
 
     // create a code owner config that imports a code owner config from a non-existing project
     CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig = createCodeOwnerConfigKey("/");
@@ -1044,8 +1470,7 @@
 
   private void testUploadConfigWithImportFromNonVisibleProject(CodeOwnerConfigImportType importType)
       throws Exception {
-    // imports are not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
 
     // create a non-visible project with a code owner config file that we try to import
     Project.NameKey nonVisibleProject =
@@ -1107,8 +1532,7 @@
 
   private void testUploadConfigWithImportFromHiddenProject(CodeOwnerConfigImportType importType)
       throws Exception {
-    // imports are not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
 
     // create a hidden project with a code owner config file
     Project.NameKey hiddenProject =
@@ -1167,8 +1591,7 @@
 
   private void testUploadConfigWithImportFromNonExistingBranch(CodeOwnerConfigImportType importType)
       throws Exception {
-    // imports are not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
 
     // create a code owner config that imports a code owner config from a non-existing branch
     CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig = createCodeOwnerConfigKey("/");
@@ -1215,8 +1638,7 @@
 
   private void testUploadConfigWithImportFromNonVisibleBranch(CodeOwnerConfigImportType importType)
       throws Exception {
-    // imports are not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
 
     CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig = createCodeOwnerConfigKey("/");
 
@@ -1280,8 +1702,7 @@
 
   private void testUploadConfigWithImportOfNonCodeOwnerConfigFile(
       CodeOwnerConfigImportType importType) throws Exception {
-    // imports are not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
 
     // create a code owner config that imports a non code owner config file
     CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig = createCodeOwnerConfigKey("/");
@@ -1325,8 +1746,7 @@
 
   private void testUploadConfigWithImportOfNonExistingCodeOwnerConfig(
       CodeOwnerConfigImportType importType) throws Exception {
-    // imports are not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
 
     // create a code owner config that imports a non-existing code owner config
     CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig = createCodeOwnerConfigKey("/");
@@ -1355,13 +1775,217 @@
         r,
         "invalid code owner config files",
         String.format(
-            "invalid %s import in '%s': '%s' does not exist (project = %s, branch = master)",
+            "invalid %s import in '%s': '%s' does not exist (project = %s, branch = master,"
+                + " revision = %s)",
             importType.getType(),
             codeOwnerConfigOperations.codeOwnerConfig(keyOfImportingCodeOwnerConfig).getFilePath(),
             codeOwnerConfigOperations
                 .codeOwnerConfig(keyOfNonExistingCodeOwnerConfig)
                 .getFilePath(),
-            project.get()));
+            project.get(),
+            r.getCommit().name()));
+  }
+
+  @Test
+  public void
+      forMergeCommitsNonResolvableGlobalImportsFromOtherProjectsAreReportedAsWarningsIfImportsDontSpecifyBranch()
+          throws Exception {
+    testForMergeCommitsThatNonResolvableImportsFromOtherProjectsAreReportedAsWarningsIfImportsDontSpecifyBranch(
+        CodeOwnerConfigImportType.GLOBAL);
+  }
+
+  @Test
+  public void
+      forMergeCommitsNonResolvablePerFileImportsFromOtherProjectsAreReportedAsWarningsIfImportsDontSpecifyBranch()
+          throws Exception {
+    testForMergeCommitsThatNonResolvableImportsFromOtherProjectsAreReportedAsWarningsIfImportsDontSpecifyBranch(
+        CodeOwnerConfigImportType.PER_FILE);
+  }
+
+  private void
+      testForMergeCommitsThatNonResolvableImportsFromOtherProjectsAreReportedAsWarningsIfImportsDontSpecifyBranch(
+          CodeOwnerConfigImportType importType) throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
+
+    // Create a second project from which we will import a code owner config.
+    Project.NameKey otherProject = projectOperations.newProject().create();
+
+    // Create a target branch for into which we will merge later.
+    String targetBranchName = "target";
+    BranchInput branchInput = new BranchInput();
+    branchInput.ref = targetBranchName;
+    branchInput.revision = projectOperations.project(project).getHead("master").name();
+    gApi.projects().name(project.get()).branch(branchInput.ref).create(branchInput);
+    branchInput.revision = projectOperations.project(otherProject).getHead("master").name();
+    gApi.projects().name(otherProject.get()).branch(branchInput.ref).create(branchInput);
+
+    // Create the code owner config file in the second project that we will import.
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(otherProject)
+            .branch("master")
+            .folderPath("/foo/")
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    // Create a code owner config that imports the code owner config from the other project, without
+    // specifying the branch for the import (if the branch is not specified the code owner config is
+    // imported from the same branch that contains the importing code owner config).
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        CodeOwnerConfigReference.builder(
+                CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
+                codeOwnerConfigOperations
+                    .codeOwnerConfig(keyOfImportedCodeOwnerConfig)
+                    .getFilePath())
+            .setProject(otherProject)
+            .build();
+    TestCodeOwnerConfigCreation.Builder codeOwnerConfigBuilder =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/");
+    switch (importType) {
+      case GLOBAL:
+        codeOwnerConfigBuilder.addImport(codeOwnerConfigReference);
+        break;
+      case PER_FILE:
+        codeOwnerConfigBuilder.addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression("foo")
+                .addImport(codeOwnerConfigReference)
+                .build());
+        break;
+      default:
+        throw new IllegalStateException("unknown import type: " + importType);
+    }
+    CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig = codeOwnerConfigBuilder.create();
+    GitUtil.fetch(testRepo, "refs/*:refs/*");
+
+    // Create the merge commit.
+    RevCommit parent1 = projectOperations.project(project).getHead(targetBranchName);
+    RevCommit parent2 = projectOperations.project(project).getHead("master");
+    PushOneCommit m =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "merge",
+            codeOwnerConfigOperations
+                .codeOwnerConfig(keyOfImportingCodeOwnerConfig)
+                .getJGitFilePath(),
+            format(codeOwnerConfigOperations.codeOwnerConfig(keyOfImportingCodeOwnerConfig).get()));
+    m.setParents(ImmutableList.of(parent1, parent2));
+    PushOneCommit.Result r = m.to("refs/for/" + targetBranchName);
+    assertOkWithWarnings(
+        r,
+        "invalid code owner config files",
+        String.format(
+            "invalid %s import in '%s': '%s' does not exist (project = %s, branch = %s,"
+                + " revision = %s)",
+            importType.getType(),
+            codeOwnerConfigOperations.codeOwnerConfig(keyOfImportingCodeOwnerConfig).getFilePath(),
+            codeOwnerConfigOperations.codeOwnerConfig(keyOfImportedCodeOwnerConfig).getFilePath(),
+            otherProject.get(),
+            targetBranchName,
+            projectOperations.project(otherProject).getHead(targetBranchName).getName()));
+  }
+
+  @Test
+  public void
+      forMergeCommitsNonResolvableGlobalImportsFromOtherProjectsAreReportedAsErrorsIfImportsSpecifyBranch()
+          throws Exception {
+    testForMergeCommitsThatNonResolvableImportsFromOtherProjectsAreReportedAsErrorsIfImportsSpecifyBranch(
+        CodeOwnerConfigImportType.GLOBAL);
+  }
+
+  @Test
+  public void
+      forMergeCommitsNonResolvablePerFileImportsFromOtherProjectsAreReportedAsErrorsIfImportsSpecifyBranch()
+          throws Exception {
+    testForMergeCommitsThatNonResolvableImportsFromOtherProjectsAreReportedAsErrorsIfImportsSpecifyBranch(
+        CodeOwnerConfigImportType.PER_FILE);
+  }
+
+  private void
+      testForMergeCommitsThatNonResolvableImportsFromOtherProjectsAreReportedAsErrorsIfImportsSpecifyBranch(
+          CodeOwnerConfigImportType importType) throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
+
+    // Create a second project from which we will import a non-existing code owner config.
+    Project.NameKey otherProject = projectOperations.newProject().create();
+
+    // Create a target branch for into which we will merge later.
+    String targetBranchName = "target";
+    BranchInput branchInput = new BranchInput();
+    branchInput.ref = targetBranchName;
+    branchInput.revision = projectOperations.project(project).getHead("master").name();
+    gApi.projects().name(project.get()).branch(branchInput.ref).create(branchInput);
+
+    // Create a code owner config that imports a non-existing code owner config from the other
+    // project, with specifying the branch for the import. When this code owner config is merged
+    // into another branch later we expect that it is rejected by the validation.
+    CodeOwnerConfig.Key keyOfNonExistingCodeOwnerConfig =
+        CodeOwnerConfig.Key.create(project, "master", "/foo/");
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        CodeOwnerConfigReference.builder(
+                CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
+                codeOwnerConfigOperations
+                    .codeOwnerConfig(keyOfNonExistingCodeOwnerConfig)
+                    .getFilePath())
+            .setProject(otherProject)
+            .setBranch("master")
+            .build();
+    TestCodeOwnerConfigCreation.Builder codeOwnerConfigBuilder =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/");
+    switch (importType) {
+      case GLOBAL:
+        codeOwnerConfigBuilder.addImport(codeOwnerConfigReference);
+        break;
+      case PER_FILE:
+        codeOwnerConfigBuilder.addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression("foo")
+                .addImport(codeOwnerConfigReference)
+                .build());
+        break;
+      default:
+        throw new IllegalStateException("unknown import type: " + importType);
+    }
+    CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig = codeOwnerConfigBuilder.create();
+    GitUtil.fetch(testRepo, "refs/*:refs/*");
+
+    // Create the merge commit.
+    RevCommit parent1 = projectOperations.project(project).getHead(targetBranchName);
+    RevCommit parent2 = projectOperations.project(project).getHead("master");
+    PushOneCommit m =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "merge",
+            codeOwnerConfigOperations
+                .codeOwnerConfig(keyOfImportingCodeOwnerConfig)
+                .getJGitFilePath(),
+            format(codeOwnerConfigOperations.codeOwnerConfig(keyOfImportingCodeOwnerConfig).get()));
+    m.setParents(ImmutableList.of(parent1, parent2));
+    PushOneCommit.Result r = m.to("refs/for/" + targetBranchName);
+    assertErrorWithMessages(
+        r,
+        "invalid code owner config files",
+        String.format(
+            "invalid %s import in '%s': '%s' does not exist (project = %s, branch = master,"
+                + " revision = %s)",
+            importType.getType(),
+            codeOwnerConfigOperations.codeOwnerConfig(keyOfImportingCodeOwnerConfig).getFilePath(),
+            codeOwnerConfigOperations
+                .codeOwnerConfig(keyOfNonExistingCodeOwnerConfig)
+                .getFilePath(),
+            otherProject.get(),
+            projectOperations.project(otherProject).getHead("master").getName()));
   }
 
   @Test
@@ -1376,8 +2000,7 @@
 
   private void testUploadConfigWithImportOfNonParseableCodeOwnerConfig(
       CodeOwnerConfigImportType importType) throws Exception {
-    // imports are not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
 
     CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
         CodeOwnerConfig.Key.create(project, "master", "/foo/");
@@ -1448,16 +2071,34 @@
   }
 
   private void testValidateMergeCommitCreatedViaTheCreateChangeRestApi() throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
+
     // Create another branch.
     String branchName = "stable";
     createBranch(BranchNameKey.create(project, branchName));
 
-    // Create a code owner config file in the other branch.
+    // Create a code owner config file in the other branch that can be imported.
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch(branchName)
+            .folderPath("/foo/")
+            .addCodeOwnerEmail(admin.email())
+            .create();
+
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        CodeOwnerConfigReference.create(
+            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
+            codeOwnerConfigOperations.codeOwnerConfig(keyOfImportedCodeOwnerConfig).getFilePath());
+
+    // Create a code owner config file in the other branch that contains an import.
     codeOwnerConfigOperations
         .newCodeOwnerConfig()
         .project(project)
         .branch(branchName)
         .folderPath("/")
+        .addImport(codeOwnerConfigReference)
         .addCodeOwnerEmail(user.email())
         .create();
 
@@ -1488,7 +2129,16 @@
     testCanSubmitNonParseableConfig();
   }
 
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "forced_dry_run")
+  public void canSubmitNonParseableConfigIfValidationIsDoneAsForcedDryRun() throws Exception {
+    disableCodeOwnersForProject(project);
+    testCanSubmitNonParseableConfig();
+  }
+
   private void testCanSubmitNonParseableConfig() throws Exception {
+    setAsDefaultCodeOwners(admin);
+
     CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
 
     // disable the code owners functionality so that we can upload a non-parseable code owner config
@@ -1512,6 +2162,48 @@
   }
 
   @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "forced")
+  public void
+      cannotSubmitConfigWithIssuesIfCodeOwnersFunctionalityIsDisabledButValidationIsEnforced()
+          throws Exception {
+    disableCodeOwnersForProject(project);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
+
+    // upload a change with a code owner config that has issues (non-resolvable code owners)
+    String unknownEmail = "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(unknownEmail))
+                    .build()));
+    r.assertOkStatus();
+
+    // approve the change
+    approve(r.getChangeId());
+
+    // try to submit the change
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().submit());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Failed to submit 1 change due to the following problems:\n"
+                    + "Change %d: [code-owners] invalid code owner config files:\n"
+                    + "  ERROR: code owner email '%s' in '%s' cannot be resolved for %s",
+                r.getChange().getId().get(),
+                unknownEmail,
+                codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath(),
+                identifiedUserFactory.create(admin.id()).getLoggableName()));
+  }
+
+  @Test
   @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "false")
   public void canSubmitConfigWithIssuesIfValidationIsDisabled() throws Exception {
     testCanSubmitConfigWithIssues();
@@ -1524,6 +2216,8 @@
   }
 
   private void testCanSubmitConfigWithIssues() throws Exception {
+    setAsDefaultCodeOwners(admin);
+
     CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
 
     // disable the code owners functionality so that we can upload a code owner config with issues
@@ -1551,6 +2245,331 @@
     assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
   }
 
+  @Test
+  @GerritConfig(name = "plugin.code-owners.rejectNonResolvableCodeOwners", value = "false")
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
+  public void canUploadAndSubmitConfigWithUnresolvableCodeOwners() throws Exception {
+    setAsDefaultCodeOwners(admin);
+
+    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
+
+    // upload a code owner config that has issues (non-resolvable code owners)
+    String unknownEmail = "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(unknownEmail))
+                    .build()));
+    assertOkWithWarnings(
+        r,
+        "invalid code owner config files",
+        String.format(
+            "code owner email '%s' in '%s' cannot be resolved for %s",
+            unknownEmail,
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath(),
+            identifiedUserFactory.create(admin.id()).getLoggableName()));
+
+    // 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.rejectNonResolvableImports", value = "false")
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
+  public void canUploadAndSubmitConfigWithUnresolvableImports() throws Exception {
+    setAsDefaultCodeOwners(admin);
+
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
+
+    CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig = createCodeOwnerConfigKey("/");
+
+    // upload a code owner config that has issues (non-resolvable imports)
+    Project.NameKey nonExistingProject = Project.nameKey("non-existing");
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        CodeOwnerConfigReference.builder(
+                CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
+                codeOwnerConfigOperations
+                    .codeOwnerConfig(CodeOwnerConfig.Key.create(nonExistingProject, "master", "/"))
+                    .getFilePath())
+            .setProject(nonExistingProject)
+            .build();
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations
+                .codeOwnerConfig(keyOfImportingCodeOwnerConfig)
+                .getJGitFilePath(),
+            format(
+                CodeOwnerConfig.builder(keyOfImportingCodeOwnerConfig, TEST_REVISION)
+                    .addImport(codeOwnerConfigReference)
+                    .addCodeOwnerSet(
+                        CodeOwnerSet.builder()
+                            .addPathExpression("foo")
+                            .addImport(codeOwnerConfigReference)
+                            .build())
+                    .build()));
+    assertOkWithWarnings(
+        r,
+        "invalid code owner config files",
+        String.format(
+            "invalid %s import in '%s': project '%s' not found",
+            CodeOwnerConfigImportType.GLOBAL.getType(),
+            codeOwnerConfigOperations.codeOwnerConfig(keyOfImportingCodeOwnerConfig).getFilePath(),
+            nonExistingProject.get()),
+        String.format(
+            "invalid %s import in '%s': project '%s' not found",
+            CodeOwnerConfigImportType.PER_FILE.getType(),
+            codeOwnerConfigOperations.codeOwnerConfig(keyOfImportingCodeOwnerConfig).getFilePath(),
+            nonExistingProject.get()));
+
+    // 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.rejectNonResolvableCodeOwners", value = "true")
+  @GerritConfig(name = "plugin.code-owners.rejectNonResolvableImports", value = "true")
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnCommitReceived", value = "false")
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "false")
+  public void rejectConfigOptionsAreIgnoredIfValidationIsDisabled() throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
+
+    setAsDefaultCodeOwners(admin);
+
+    CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig = createCodeOwnerConfigKey("/");
+
+    // upload a code owner config that has issues (non-resolvable code owners and non-resolvable
+    // imports)
+    String unknownEmail = "non-existing-email@example.com";
+    Project.NameKey nonExistingProject = Project.nameKey("non-existing");
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        CodeOwnerConfigReference.builder(
+                CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
+                codeOwnerConfigOperations
+                    .codeOwnerConfig(CodeOwnerConfig.Key.create(nonExistingProject, "master", "/"))
+                    .getFilePath())
+            .setProject(nonExistingProject)
+            .build();
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations
+                .codeOwnerConfig(keyOfImportingCodeOwnerConfig)
+                .getJGitFilePath(),
+            format(
+                CodeOwnerConfig.builder(keyOfImportingCodeOwnerConfig, TEST_REVISION)
+                    .addImport(codeOwnerConfigReference)
+                    .addCodeOwnerSet(CodeOwnerSet.createWithoutPathExpressions(unknownEmail))
+                    .addCodeOwnerSet(
+                        CodeOwnerSet.builder()
+                            .addPathExpression("foo")
+                            .addImport(codeOwnerConfigReference)
+                            .build())
+                    .build()));
+    assertOkWithHints(
+        r,
+        "skipping validation of code owner config files",
+        "code owners config validation is disabled");
+
+    // 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.backend", value = FailingCodeOwnerBackend.ID)
+  public void pushFailsOnInternalError() throws Exception {
+    try (AutoCloseable registration = registerTestBackend(new FailingCodeOwnerBackend())) {
+      PushOneCommit.Result r = createChange("Add code owners", "OWNERS", "content");
+      r.assertErrorStatus("internal error");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = FailingCodeOwnerBackend.ID)
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnCommitReceived", value = "DRY_RUN")
+  public void pushSucceedsOnInternalErrorIfValidationIsDoneAsDryRun() throws Exception {
+    try (AutoCloseable registration = registerTestBackend(new FailingCodeOwnerBackend())) {
+      PushOneCommit.Result r = createChange("Add code owners", "OWNERS", "content");
+      r.assertOkStatus();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = FailingCodeOwnerBackend.ID)
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "PROJECT_OWNERS")
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
+  public void submitFailsOnInternalError() throws Exception {
+    try (AutoCloseable registration = registerTestBackend(new FailingCodeOwnerBackend())) {
+      disableCodeOwnersForProject(project);
+      PushOneCommit.Result r = createChange("Add code owners", "OWNERS", "content");
+      r.assertOkStatus();
+      enableCodeOwnersForProject(project);
+      approve(r.getChangeId());
+      IllegalStateException exception =
+          assertThrows(
+              IllegalStateException.class,
+              () -> gApi.changes().id(r.getChangeId()).current().submit());
+      assertThat(exception).hasMessageThat().isEqualTo(FailingCodeOwnerBackend.EXCEPTION_MESSAGE);
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = FailingCodeOwnerBackend.ID)
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "DRY_RUN")
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "PROJECT_OWNERS")
+  public void submitSucceedsOnInternalErrorIfValidationIsDoneAsDryRun() throws Exception {
+    try (AutoCloseable registration = registerTestBackend(new FailingCodeOwnerBackend())) {
+      disableCodeOwnersForProject(project);
+      PushOneCommit.Result r = createChange("Add code owners", "OWNERS", "content");
+      r.assertOkStatus();
+      enableCodeOwnersForProject(project);
+      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 = "true")
+  public void disableValidationForBranch() throws Exception {
+    setAsDefaultCodeOwners(admin);
+
+    // Disable the validation for the master branch.
+    updateCodeOwnersConfig(
+        project,
+        codeOwnersConfig -> {
+          codeOwnersConfig.setString(
+              GeneralConfig.SECTION_VALIDATION,
+              "refs/heads/master",
+              GeneralConfig.KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
+              CodeOwnerConfigValidationPolicy.FALSE.name());
+          codeOwnersConfig.setString(
+              GeneralConfig.SECTION_VALIDATION,
+              "refs/heads/master",
+              GeneralConfig.KEY_ENABLE_VALIDATION_ON_SUBMIT,
+              CodeOwnerConfigValidationPolicy.FALSE.name());
+        });
+
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations
+                .codeOwnerConfig(createCodeOwnerConfigKey("/"))
+                .getJGitFilePath(),
+            "INVALID");
+    assertOkWithHints(
+        r,
+        "skipping validation of code owner config files",
+        "code owners config validation is disabled");
+
+    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 = "true")
+  public void disableRejectionOfNonResolvableCodeOwnersForBranch() throws Exception {
+    setAsDefaultCodeOwners(admin);
+
+    // Disable the rejection of non-resolvable code owners for the master branch.
+    updateCodeOwnersConfig(
+        project,
+        codeOwnersConfig ->
+            codeOwnersConfig.setBoolean(
+                GeneralConfig.SECTION_VALIDATION,
+                "refs/heads/master",
+                GeneralConfig.KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS,
+                false));
+
+    CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
+    String unknownEmail = "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(unknownEmail))
+                    .build()));
+    assertOkWithWarnings(
+        r,
+        "invalid code owner config files",
+        String.format(
+            "code owner email '%s' in '%s' cannot be resolved for %s",
+            unknownEmail,
+            codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath(),
+            identifiedUserFactory.create(admin.id()).getLoggableName()));
+
+    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 = "true")
+  public void disableRejectionOfNonResolvableImportsForBranch() throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
+
+    setAsDefaultCodeOwners(admin);
+
+    // Disable the rejection of non-resolvable imports for the master branch.
+    updateCodeOwnersConfig(
+        project,
+        codeOwnersConfig ->
+            codeOwnersConfig.setBoolean(
+                GeneralConfig.SECTION_VALIDATION,
+                "refs/heads/master",
+                GeneralConfig.KEY_REJECT_NON_RESOLVABLE_IMPORTS,
+                false));
+
+    // create a code owner config that imports a code owner config from a non-existing project
+    CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig = createCodeOwnerConfigKey("/");
+    Project.NameKey nonExistingProject = Project.nameKey("non-existing");
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        CodeOwnerConfigReference.builder(
+                CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
+                codeOwnerConfigOperations
+                    .codeOwnerConfig(CodeOwnerConfig.Key.create(nonExistingProject, "master", "/"))
+                    .getFilePath())
+            .setProject(nonExistingProject)
+            .build();
+    CodeOwnerConfig codeOwnerConfig =
+        createCodeOwnerConfigWithImport(
+            keyOfImportingCodeOwnerConfig,
+            CodeOwnerConfigImportType.GLOBAL,
+            codeOwnerConfigReference);
+
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations
+                .codeOwnerConfig(keyOfImportingCodeOwnerConfig)
+                .getJGitFilePath(),
+            format(codeOwnerConfig));
+    assertOkWithWarnings(
+        r,
+        "invalid code owner config files",
+        String.format(
+            "invalid %s import in '%s': project '%s' not found",
+            CodeOwnerConfigImportType.GLOBAL.getType(),
+            codeOwnerConfigOperations.codeOwnerConfig(keyOfImportingCodeOwnerConfig).getFilePath(),
+            nonExistingProject.get()));
+
+    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,
@@ -1591,13 +2610,6 @@
             backendConfig.getDefaultBackend().getClass().getName()));
   }
 
-  private String getParsingErrorMessage(
-      ImmutableMap<Class<? extends CodeOwnerBackend>, String> messagesByBackend) {
-    CodeOwnerBackend codeOwnerBackend = backendConfig.getDefaultBackend();
-    assertThat(messagesByBackend).containsKey(codeOwnerBackend.getClass());
-    return messagesByBackend.get(codeOwnerBackend.getClass());
-  }
-
   private String abbreviateName(AnyObjectId id) throws Exception {
     return ObjectIds.abbreviateName(id, testRepo.getRevWalk().getObjectReader());
   }
@@ -1615,7 +2627,8 @@
     pushResult.assertOkStatus();
     for (String hint : hints) {
       pushResult.assertMessage(
-          String.format("hint: commit %s: %s", abbreviateName(pushResult.getCommit()), hint));
+          String.format(
+              "hint: commit %s: [code-owners] %s", abbreviateName(pushResult.getCommit()), hint));
     }
     pushResult.assertNotMessage("fatal");
     pushResult.assertNotMessage("error");
@@ -1627,7 +2640,8 @@
     pushResult.assertOkStatus();
     for (String error : errors) {
       pushResult.assertMessage(
-          String.format("fatal: commit %s: %s", abbreviateName(pushResult.getCommit()), error));
+          String.format(
+              "fatal: commit %s: [code-owners] %s", abbreviateName(pushResult.getCommit()), error));
     }
     pushResult.assertNotMessage("error");
     pushResult.assertNotMessage("warning");
@@ -1639,7 +2653,8 @@
     pushResult.assertOkStatus();
     for (String error : errors) {
       pushResult.assertMessage(
-          String.format("error: commit %s: %s", abbreviateName(pushResult.getCommit()), error));
+          String.format(
+              "error: commit %s: [code-owners] %s", abbreviateName(pushResult.getCommit()), error));
     }
     pushResult.assertNotMessage("fatal");
     pushResult.assertNotMessage("warning");
@@ -1651,7 +2666,9 @@
     pushResult.assertOkStatus();
     for (String warning : warnings) {
       pushResult.assertMessage(
-          String.format("warning: commit %s: %s", abbreviateName(pushResult.getCommit()), warning));
+          String.format(
+              "warning: commit %s: [code-owners] %s",
+              abbreviateName(pushResult.getCommit()), warning));
     }
     pushResult.assertNotMessage("fatal");
     pushResult.assertNotMessage("error");
@@ -1661,9 +2678,11 @@
   private void assertErrorWithMessages(
       PushOneCommit.Result pushResult, String summaryMessage, String... errors) throws Exception {
     String abbreviatedCommit = abbreviateName(pushResult.getCommit());
-    pushResult.assertErrorStatus(String.format("commit %s: %s", abbreviatedCommit, summaryMessage));
+    pushResult.assertErrorStatus(
+        String.format("commit %s: [code-owners] %s", abbreviatedCommit, summaryMessage));
     for (String error : errors) {
-      pushResult.assertMessage(String.format("error: commit %s: %s", abbreviatedCommit, error));
+      pushResult.assertMessage(
+          String.format("error: commit %s: [code-owners] %s", abbreviatedCommit, error));
     }
     pushResult.assertNotMessage("fatal");
     pushResult.assertNotMessage("warning");
@@ -1673,12 +2692,55 @@
   private void assertFatalWithMessages(
       PushOneCommit.Result pushResult, String summaryMessage, String... errors) throws Exception {
     String abbreviatedCommit = abbreviateName(pushResult.getCommit());
-    pushResult.assertErrorStatus(String.format("commit %s: %s", abbreviatedCommit, summaryMessage));
+    pushResult.assertErrorStatus(
+        String.format("commit %s: [code-owners] %s", abbreviatedCommit, summaryMessage));
     for (String error : errors) {
-      pushResult.assertMessage(String.format("fatal: commit %s: %s", abbreviatedCommit, error));
+      pushResult.assertMessage(
+          String.format("fatal: commit %s: [code-owners] %s", abbreviatedCommit, error));
     }
     pushResult.assertNotMessage("error");
     pushResult.assertNotMessage("warning");
     pushResult.assertNotMessage("hint");
   }
+
+  private AutoCloseable registerTestBackend(CodeOwnerBackend codeOwnerBackend) {
+    RegistrationHandle registrationHandle =
+        ((PrivateInternals_DynamicMapImpl<CodeOwnerBackend>) codeOwnerBackends)
+            .put("gerrit", FailingCodeOwnerBackend.ID, Providers.of(codeOwnerBackend));
+    return registrationHandle::remove;
+  }
+
+  private static class FailingCodeOwnerBackend implements CodeOwnerBackend {
+    static final String ID = "test-backend";
+    static final String EXCEPTION_MESSAGE = "failure from test";
+
+    @Override
+    public boolean isCodeOwnerConfigFile(NameKey project, String fileName) {
+      throw new IllegalStateException(EXCEPTION_MESSAGE);
+    }
+
+    @Override
+    public Optional<CodeOwnerConfig> getCodeOwnerConfig(
+        CodeOwnerConfig.Key codeOwnerConfigKey, RevWalk revWalk, ObjectId revision) {
+      return Optional.empty();
+    }
+
+    @Override
+    public Path getFilePath(CodeOwnerConfig.Key codeOwnerConfigKey) {
+      return codeOwnerConfigKey.filePath("OWNERS");
+    }
+
+    @Override
+    public Optional<CodeOwnerConfig> upsertCodeOwnerConfig(
+        CodeOwnerConfig.Key codeOwnerConfigKey,
+        CodeOwnerConfigUpdate codeOwnerConfigUpdate,
+        IdentifiedUser currentUser) {
+      return Optional.empty();
+    }
+
+    @Override
+    public Optional<PathExpressionMatcher> getPathExpressionMatcher() {
+      return Optional.empty();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerSubmitRuleIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerSubmitRuleIT.java
index d7b1985..5d63b73 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerSubmitRuleIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerSubmitRuleIT.java
@@ -16,23 +16,42 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerStatusInfoSubject.assertThat;
-import static com.google.gerrit.plugins.codeowners.testing.SubmitRequirementInfoSubject.assertThatCollection;
+import static com.google.gerrit.plugins.codeowners.testing.LegacySubmitRequirementInfoSubject.assertThatCollection;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestMetricMaker;
 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.BranchNameKey;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
-import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatus;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatusInfo;
-import com.google.gerrit.plugins.codeowners.testing.SubmitRequirementInfoSubject;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.testing.LegacySubmitRequirementInfoSubject;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
 /** Acceptance test for {@code com.google.gerrit.plugins.codeowners.backend.CodeOwnerSubmitRule}. */
 public class CodeOwnerSubmitRuleIT extends AbstractCodeOwnersIT {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private TestMetricMaker testMetricMaker;
 
   @Test
   public void changeIsSubmittableIfCodeOwnersFuctionalityIsDisabled() throws Exception {
@@ -79,10 +98,6 @@
 
   @Test
   public void changeWithInsufficentReviewersIsNotSubmittable() throws Exception {
-    // create arbitrary code owner config to avoid entering the bootstrapping code path in
-    // CodeOwnerApprovalCheck
-    createArbitraryCodeOwnerConfigFile();
-
     String changeId = createChange("Test Change", "foo/bar.baz", "file content").getChangeId();
 
     // Approve by a non-code-owner.
@@ -90,7 +105,7 @@
 
     // Verify that the code owner status for the changed file is INSUFFICIENT_REVIEWERS.
     CodeOwnerStatusInfo codeOwnerStatus =
-        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus();
+        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().get();
     assertThat(codeOwnerStatus)
         .hasFileCodeOwnerStatusesThat()
         .onlyElement()
@@ -104,7 +119,7 @@
     assertThat(changeInfo.submittable).isFalse();
 
     // Check the submit requirement.
-    SubmitRequirementInfoSubject submitRequirementInfoSubject =
+    LegacySubmitRequirementInfoSubject submitRequirementInfoSubject =
         assertThatCollection(changeInfo.requirements).onlyElement();
     submitRequirementInfoSubject.hasStatusThat().isEqualTo("NOT_READY");
     submitRequirementInfoSubject.hasFallbackTextThat().isEqualTo("Code Owners");
@@ -143,7 +158,7 @@
 
     // Verify that the code owner status for the changed file is PENDING.
     CodeOwnerStatusInfo codeOwnerStatus =
-        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus();
+        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().get();
     assertThat(codeOwnerStatus)
         .hasFileCodeOwnerStatusesThat()
         .onlyElement()
@@ -157,7 +172,7 @@
     assertThat(changeInfo.submittable).isFalse();
 
     // Check the submit requirement.
-    SubmitRequirementInfoSubject submitRequirementInfoSubject =
+    LegacySubmitRequirementInfoSubject submitRequirementInfoSubject =
         assertThatCollection(changeInfo.requirements).onlyElement();
     submitRequirementInfoSubject.hasStatusThat().isEqualTo("NOT_READY");
     submitRequirementInfoSubject.hasFallbackTextThat().isEqualTo("Code Owners");
@@ -193,7 +208,7 @@
 
     // Verify that the code owner status for the changed file is APPROVED.
     CodeOwnerStatusInfo codeOwnerStatus =
-        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus();
+        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().get();
     assertThat(codeOwnerStatus)
         .hasFileCodeOwnerStatusesThat()
         .onlyElement()
@@ -207,7 +222,7 @@
     assertThat(changeInfo.submittable).isTrue();
 
     // Check the submit requirement.
-    SubmitRequirementInfoSubject submitRequirementInfoSubject =
+    LegacySubmitRequirementInfoSubject submitRequirementInfoSubject =
         assertThatCollection(changeInfo.requirements).onlyElement();
     submitRequirementInfoSubject.hasStatusThat().isEqualTo("OK");
     submitRequirementInfoSubject.hasFallbackTextThat().isEqualTo("Code Owners");
@@ -223,10 +238,6 @@
   public void changeWithOverrideApprovalIsSubmittable() throws Exception {
     createOwnersOverrideLabel();
 
-    // create arbitrary code owner config to avoid entering the bootstrapping code path in
-    // CodeOwnerApprovalCheck
-    createArbitraryCodeOwnerConfigFile();
-
     String changeId = createChange("Test Change", "foo/bar.baz", "file content").getChangeId();
 
     // Check that the change is not submittable.
@@ -238,7 +249,7 @@
 
     // Verify that the code owner status for the changed file is APPROVED.
     CodeOwnerStatusInfo codeOwnerStatus =
-        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus();
+        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().get();
     assertThat(codeOwnerStatus)
         .hasFileCodeOwnerStatusesThat()
         .onlyElement()
@@ -251,7 +262,7 @@
     approve(changeId);
 
     // Check the submit requirement.
-    SubmitRequirementInfoSubject submitRequirementInfoSubject =
+    LegacySubmitRequirementInfoSubject submitRequirementInfoSubject =
         assertThatCollection(gApi.changes().id(changeId).get().requirements).onlyElement();
     submitRequirementInfoSubject.hasStatusThat().isEqualTo("OK");
     submitRequirementInfoSubject.hasFallbackTextThat().isEqualTo("Code Owners");
@@ -261,4 +272,342 @@
     gApi.changes().id(changeId).current().submit();
     assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
   }
+
+  @Test
+  public void changeIsNotSubmittableIfDestinationBranchWasDeleted() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String branchName = "tempBranch";
+    createBranch(BranchNameKey.create(project, branchName));
+
+    String changeId = createChange("refs/for/" + branchName).getChangeId();
+
+    // Approve by a code-owner.
+    approve(changeId);
+
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    input.branches = ImmutableList.of(branchName);
+    gApi.projects().name(project.get()).deleteBranches(input);
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMITTABLE);
+    assertThat(changeInfo.submittable).isFalse();
+
+    // Check that the submit requirement.
+    LegacySubmitRequirementInfoSubject submitRequirementInfoSubject =
+        assertThatCollection(changeInfo.requirements).onlyElement();
+    submitRequirementInfoSubject.hasStatusThat().isEqualTo("NOT_READY");
+    submitRequirementInfoSubject.hasFallbackTextThat().isEqualTo("Code Owners");
+    submitRequirementInfoSubject.hasTypeThat().isEqualTo("code-owners");
+
+    // Try to submit the change.
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(changeId).current().submit());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(String.format("destination branch \"refs/heads/%s\" not found.", branchName));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.exemptedUser", value = "exempted-user@example.com")
+  public void changeIsSubmittableIfUserIsExcempted() throws Exception {
+    TestAccount exemptedUser =
+        accountCreator.create(
+            "exemptedUser", "exempted-user@example.com", "Exempted User", /* displayName= */ null);
+
+    PushOneCommit.Result r = createChange(exemptedUser, "Some Change", "foo.txt", "some content");
+    String changeId = r.getChangeId();
+
+    // Apply Code-Review+2 by a non-code-owner to satisfy the MaxWithBlock function of the
+    // Code-Review label.
+    approve(changeId);
+
+    ChangeInfo changeInfo =
+        gApi.changes()
+            .id(changeId)
+            .get(
+                ListChangesOption.SUBMITTABLE,
+                ListChangesOption.ALL_REVISIONS,
+                ListChangesOption.CURRENT_ACTIONS);
+    assertThat(changeInfo.submittable).isTrue();
+
+    // Check that the submit button is enabled.
+    assertThat(changeInfo.revisions.get(r.getCommit().getName()).actions.get("submit").enabled)
+        .isTrue();
+
+    // Check the submit requirement.
+    LegacySubmitRequirementInfoSubject submitRequirementInfoSubject =
+        assertThatCollection(changeInfo.requirements).onlyElement();
+    submitRequirementInfoSubject.hasStatusThat().isEqualTo("OK");
+    submitRequirementInfoSubject.hasFallbackTextThat().isEqualTo("Code Owners");
+    submitRequirementInfoSubject.hasTypeThat().isEqualTo("code-owners");
+
+    // Submit the change.
+    gApi.changes().id(changeId).current().submit();
+    assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void changeIsNotSubmittableIfOwnersFileIsNonParsable() throws Exception {
+    testChangeIsNotSubmittableIfOwnersFileIsNonParsable(/* invalidCodeOwnerConfigInfoUrl= */ null);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.invalidCodeOwnerConfigInfoUrl", value = "http://foo.bar")
+  public void changeIsNotSubmittableIfOwnersFileIsNonParsable_withInvalidCodeOwnerConfigInfoUrl()
+      throws Exception {
+    testChangeIsNotSubmittableIfOwnersFileIsNonParsable("http://foo.bar");
+  }
+
+  private void testChangeIsNotSubmittableIfOwnersFileIsNonParsable(
+      @Nullable String invalidCodeOwnerConfigInfoUrl) throws Exception {
+    // Add a non-parsable code owner config.
+    String nameOfInvalidCodeOwnerConfigFile = getCodeOwnerConfigFileName();
+    createNonParseableCodeOwnerConfig(nameOfInvalidCodeOwnerConfigFile);
+
+    PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
+    String changeId = r.getChangeId();
+
+    // Apply Code-Review+2 to satisfy the MaxWithBlock function of the Code-Review label.
+    approve(changeId);
+
+    ChangeInfo changeInfo =
+        gApi.changes()
+            .id(changeId)
+            .get(
+                ListChangesOption.SUBMITTABLE,
+                ListChangesOption.ALL_REVISIONS,
+                ListChangesOption.CURRENT_ACTIONS);
+    assertThat(changeInfo.submittable).isFalse();
+
+    // Check that the submit button is not visible.
+    assertThat(changeInfo.revisions.get(r.getCommit().getName()).actions.get("submit")).isNull();
+
+    // Check the submit requirement.
+    assertThatCollection(changeInfo.requirements).isEmpty();
+
+    // Try to submit the change.
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(changeId).current().submit());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Failed to submit 1 change due to the following problems:\n"
+                    + "Change %s: submit rule error: Failed to evaluate code owner statuses for"
+                    + " patch set 1 of change %s (cause: invalid code owner config file '%s'"
+                    + " (project = %s, branch = master):\n  %s).%s",
+                changeInfo._number,
+                changeInfo._number,
+                JgitPath.of(nameOfInvalidCodeOwnerConfigFile).getAsAbsolutePath(),
+                project,
+                getParsingErrorMessageForNonParseableCodeOwnerConfig(),
+                invalidCodeOwnerConfigInfoUrl != null
+                    ? String.format("\nFor help check %s.", invalidCodeOwnerConfigInfoUrl)
+                    : ""));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void changeIsSubmittableWithOverrideIfOwnersFileIsNonParsable() throws Exception {
+    createOwnersOverrideLabel();
+
+    // Add a non-parsable code owner config.
+    String nameOfInvalidCodeOwnerConfigFile = getCodeOwnerConfigFileName();
+    createNonParseableCodeOwnerConfig(nameOfInvalidCodeOwnerConfigFile);
+
+    PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
+    String changeId = r.getChangeId();
+
+    // Apply Code-Review+2 to satisfy the MaxWithBlock function of the Code-Review label.
+    approve(changeId);
+
+    ChangeInfo changeInfo =
+        gApi.changes()
+            .id(changeId)
+            .get(
+                ListChangesOption.SUBMITTABLE,
+                ListChangesOption.ALL_REVISIONS,
+                ListChangesOption.CURRENT_ACTIONS);
+    assertThat(changeInfo.submittable).isFalse();
+
+    // Check that the submit button is not visible.
+    assertThat(changeInfo.revisions.get(r.getCommit().getName()).actions.get("submit")).isNull();
+
+    // Check the submit requirement.
+    assertThatCollection(changeInfo.requirements).isEmpty();
+
+    // Try to submit the change.
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(changeId).current().submit());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Failed to submit 1 change due to the following problems:\n"
+                    + "Change %s: submit rule error: Failed to evaluate code owner statuses for"
+                    + " patch set 1 of change %s (cause: invalid code owner config file '%s'"
+                    + " (project = %s, branch = master):\n  %s).",
+                changeInfo._number,
+                changeInfo._number,
+                JgitPath.of(nameOfInvalidCodeOwnerConfigFile).getAsAbsolutePath(),
+                project,
+                getParsingErrorMessageForNonParseableCodeOwnerConfig()));
+
+    // Apply an override.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    // Check the submittable flag.
+    changeInfo =
+        gApi.changes()
+            .id(changeId)
+            .get(
+                ListChangesOption.SUBMITTABLE,
+                ListChangesOption.ALL_REVISIONS,
+                ListChangesOption.CURRENT_ACTIONS);
+    assertThat(changeInfo.submittable).isTrue();
+
+    // Check the submit requirement.
+    LegacySubmitRequirementInfoSubject submitRequirementInfoSubject =
+        assertThatCollection(changeInfo.requirements).onlyElement();
+    submitRequirementInfoSubject.hasStatusThat().isEqualTo("OK");
+    submitRequirementInfoSubject.hasFallbackTextThat().isEqualTo("Code Owners");
+    submitRequirementInfoSubject.hasTypeThat().isEqualTo("code-owners");
+
+    // Submit the change.
+    gApi.changes().id(changeId).current().submit();
+    assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.mergeCommitStrategy",
+      value = "FILES_WITH_CONFLICT_RESOLUTION")
+  public void changeIsSubmittableIfAutoMergeIsNotPresent() throws Exception {
+    setAsDefaultCodeOwners(admin);
+
+    // Create another branch
+    String branchName = "foo";
+    BranchInput branchInput = new BranchInput();
+    branchInput.ref = branchName;
+    branchInput.revision = projectOperations.project(project).getHead("master").name();
+    gApi.projects().name(project.get()).branch(branchInput.ref).create(branchInput);
+
+    // Create 2 parent commits.
+    ObjectId initial = projectOperations.project(project).getHead("master");
+
+    PushOneCommit.Result p1 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "parent 1",
+                ImmutableMap.of("foo", "foo-1.2", "bar", "bar-1.2"))
+            .to("refs/for/master");
+    RevCommit parent1 = p1.getCommit();
+    approve(p1.getChangeId());
+    gApi.changes().id(p1.getChangeId()).current().submit();
+
+    testRepo.reset(initial);
+    PushOneCommit.Result p2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "parent 2",
+                ImmutableMap.of("foo", "foo-2.2", "bar", "bar-2.2"))
+            .to("refs/for/foo");
+    RevCommit parent2 = p2.getCommit();
+    approve(p2.getChangeId());
+    gApi.changes().id(p2.getChangeId()).current().submit();
+
+    // Create the merge commit.
+    PushOneCommit m =
+        pushFactory.create(
+            admin.newIdent(), testRepo, "merge", ImmutableMap.of("foo", "foo-1", "bar", "bar-2"));
+    m.setParents(ImmutableList.of(parent1, parent2));
+    PushOneCommit.Result r = m.to("refs/for/master");
+    r.assertOkStatus();
+    String changeId = r.getChangeId();
+
+    // Apply Code-Review+2 by a non-code-owner to satisfy the MaxWithBlock function of the
+    // Code-Review label.
+    approve(changeId);
+
+    // Delete the auto-merge ref so that the auto-merge needs to be computed in-memory when the
+    // code-owners submit rule is executed.
+    deleteAutoMergeBranch(r.getCommit());
+
+    ChangeInfo changeInfo =
+        gApi.changes()
+            .id(changeId)
+            .get(
+                ListChangesOption.SUBMITTABLE,
+                ListChangesOption.ALL_REVISIONS,
+                ListChangesOption.CURRENT_ACTIONS);
+    assertThat(changeInfo.submittable).isTrue();
+
+    // Check that the submit button is enabled.
+    assertThat(changeInfo.revisions.get(r.getCommit().getName()).actions.get("submit").enabled)
+        .isTrue();
+
+    // Check the submit requirement.
+    LegacySubmitRequirementInfoSubject submitRequirementInfoSubject =
+        assertThatCollection(changeInfo.requirements).onlyElement();
+    submitRequirementInfoSubject.hasStatusThat().isEqualTo("OK");
+    submitRequirementInfoSubject.hasFallbackTextThat().isEqualTo("Code Owners");
+    submitRequirementInfoSubject.hasTypeThat().isEqualTo("code-owners");
+
+    // Submit the change.
+    gApi.changes().id(changeId).current().submit();
+    assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void submitRuleIsInvokedOnlyOnceWhenGettingChangeDetails() throws Exception {
+    PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
+    String changeId = r.getChangeId();
+
+    testMetricMaker.reset();
+    gApi.changes()
+        .id(changeId)
+        .get(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_ACTIONS);
+
+    // Submit rules are computed freshly, but only once.
+    assertThat(testMetricMaker.getCount("plugins/code-owners/count_code_owner_submit_rule_runs"))
+        .isEqualTo(1);
+  }
+
+  @Test
+  public void submitRuleIsNotInvokedWhenQueryingChange() throws Exception {
+    PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
+    String changeId = r.getChangeId();
+
+    testMetricMaker.reset();
+    gApi.changes()
+        .query(changeId)
+        .withOptions(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_ACTIONS)
+        .get();
+
+    // Submit rule evaluation results from the change index are reused
+    assertThat(testMetricMaker.getCount("plugins/code-owners/count_code_owner_submit_rule_runs"))
+        .isEqualTo(0);
+  }
+
+  private void deleteAutoMergeBranch(ObjectId mergeCommit) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      RefUpdate ru = repo.updateRef(RefNames.refsCacheAutomerge(mergeCommit.name()));
+      ru.setForceUpdate(true);
+      assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+      assertThat(repo.exactRef(RefNames.refsCacheAutomerge(mergeCommit.name()))).isNull();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnAddReviewerIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnAddReviewerIT.java
new file mode 100644
index 0000000..91c40db
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnAddReviewerIT.java
@@ -0,0 +1,362 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.acceptance.api;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+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.CodeOwnersOnAddReviewer}.
+ */
+public class CodeOwnersOnAddReviewerIT extends AbstractCodeOwnersIT {
+  @Test
+  @GerritConfig(name = "plugin.code-owners.disabled", value = "true")
+  public void noChangeMessageAddedIfCodeOwnersFuctionalityIsDisabled() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    String changeId = createChange("Test Change", "foo/bar.baz", "file content").getChangeId();
+
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 1.");
+  }
+
+  @Test
+  public void noChangeMessageAddedIfReviewerIsNotACodeOwner() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String changeId = createChange("Test Change", "foo/bar.baz", "file content").getChangeId();
+
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 1.");
+  }
+
+  @Test
+  public void changeMessageListsOwnedPaths() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "%s who was added as reviewer owns the following files:\n* %s\n",
+                user.fullName(), path));
+  }
+
+  @Test
+  public void changeMessageListsOnlyOwnedPaths() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.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();
+
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "%s who was added as reviewer owns the following files:\n* %s\n* %s\n",
+                user.fullName(), path1, path2));
+  }
+
+  @Test
+  public void noChangeMessageAddedIfSameCodeOwnerIsAddedAsReviewerAgain() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    int messageCount = gApi.changes().id(changeId).get().messages.size();
+
+    // Add the same code owner as reviewer again.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Check that no new change message was added.
+    assertThat(gApi.changes().id(changeId).get().messages.size()).isEqualTo(messageCount);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "4")
+  public void pathsInChangeMessageAreLimited_limitNotReached() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(user.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();
+
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "%s who was added as reviewer owns the following files:\n"
+                    + "* %s\n"
+                    + "* %s\n"
+                    + "* %s\n"
+                    + "* %s\n",
+                user.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(user.email())
+        .create();
+
+    String path1 = "foo/bar.baz";
+    String path2 = "foo/baz.bar";
+    String path3 = "bar/foo.baz";
+    String path4 = "bar/baz.foo";
+    String path5 = "baz/foo.bar";
+    String changeId =
+        createChange(
+                "Test Change",
+                ImmutableMap.of(
+                    path1,
+                    "file content",
+                    path2,
+                    "file content",
+                    path3,
+                    "file content",
+                    path4,
+                    "file content",
+                    path5,
+                    "file content"))
+            .getChangeId();
+
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "%s who was added as reviewer owns the following files:\n"
+                    + "* %s\n"
+                    + "* %s\n"
+                    + "* %s\n"
+                    + "(more files)\n",
+                user.fullName(), path4, path3, path5));
+  }
+
+  @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();
+
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 1.");
+  }
+
+  @Test
+  public void noChangeMessageAddedIfDestinationBranchWasDeleted() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    String branchName = "tempBranch";
+    createBranch(BranchNameKey.create(project, branchName));
+
+    String changeId = createChange("refs/for/" + branchName).getChangeId();
+
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    input.branches = ImmutableList.of(branchName);
+    gApi.projects().name(project.get()).deleteBranches(input);
+
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // If the destination branch of the change no longer exits, the owned paths cannot be computed.
+    // Hence no change message is added in this case.
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 1.");
+  }
+
+  @Test
+  public void changeMessageListsOwnedPathsIfReviewerIsAddedViaPostReview() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    // Add reviewer via PostReview.
+    gApi.changes().id(changeId).current().review(ReviewInput.create().reviewer(user.email()));
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "%s who was added as reviewer owns the following files:\n* %s\n",
+                user.fullName(), path));
+  }
+
+  @Test
+  public void reviewerAndCodeOwnerApprovalAddedAtTheSameTime() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    // 'admin' grants a code owner approval (Code-Review+1) and adds 'user' as reviewer.
+    gApi.changes().id(changeId).current().review(ReviewInput.recommend().reviewer(user.email()));
+
+    // We expect that 2 changes messages are added:
+    // 1. change message listing the paths that were approved by voting Code-Review+1
+    // 2. change message listing the paths owned by the new reviewer
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.get(messages, messages.size() - 2).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));
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "%s who was added as reviewer owns the following files:\n* %s\n",
+                user.fullName(), path));
+  }
+}
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 1ea4183..3b1258e 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java
@@ -17,24 +17,33 @@
 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.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.plugins.codeowners.testing.RequiredApprovalSubject.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.entities.BranchNameKey;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.git.ObjectIds;
 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.config.BackendConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.OverrideApprovalConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.RequiredApproval;
+import com.google.gerrit.plugins.codeowners.backend.config.RequiredApprovalConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.StatusConfig;
 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;
-import com.google.gerrit.plugins.codeowners.config.GeneralConfig;
-import com.google.gerrit.plugins.codeowners.config.OverrideApprovalConfig;
-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 com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevObject;
@@ -46,6 +55,10 @@
 
 /**
  * Tests for {@code com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfigValidator}.
+ *
+ * <p>Unit tests for {@code
+ * com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfigValidator} are contained in
+ * {@code com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfigValidatorTest}.
  */
 public class CodeOwnersPluginConfigValidatorIT extends AbstractCodeOwnersIT {
   private CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
@@ -87,7 +100,7 @@
 
     PushResult r = pushRefsMetaConfig();
     assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
-    assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isTrue();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled()).isTrue();
   }
 
   @Test
@@ -104,7 +117,7 @@
 
     PushResult r = pushRefsMetaConfig();
     assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("master"))
         .isTrue();
   }
 
@@ -164,7 +177,7 @@
 
     PushResult r = pushRefsMetaConfig();
     assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
-    assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getBackend("master"))
         .isInstanceOf(ProtoBackend.class);
   }
 
@@ -182,7 +195,7 @@
 
     PushResult r = pushRefsMetaConfig();
     assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
-    assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getBackend("master"))
         .isInstanceOf(ProtoBackend.class);
   }
 
@@ -242,7 +255,8 @@
 
     PushResult r = pushRefsMetaConfig();
     assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
-    RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
+    RequiredApproval requiredApproval =
+        codeOwnersPluginConfiguration.getProjectConfig(project).getRequiredApproval();
     assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
     assertThat(requiredApproval).hasValueThat().isEqualTo(2);
   }
@@ -312,11 +326,11 @@
 
     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("Code-Review");
-    assertThat(overrideApproval).element(0).hasValueThat().isEqualTo(2);
+    ImmutableSortedSet<RequiredApproval> overrideApprovals =
+        codeOwnersPluginConfiguration.getProjectConfig(project).getOverrideApprovals();
+    assertThat(overrideApprovals).hasSize(1);
+    assertThat(overrideApprovals).element(0).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(overrideApprovals).element(0).hasValueThat().isEqualTo(2);
   }
 
   @Test
@@ -409,11 +423,11 @@
 
     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);
+    ImmutableSortedSet<RequiredApproval> overrideApprovals =
+        codeOwnersPluginConfiguration.getProjectConfig(project).getOverrideApprovals();
+    assertThat(overrideApprovals).hasSize(1);
+    assertThat(overrideApprovals).element(0).hasLabelNameThat().isEqualTo("Owners-Override");
+    assertThat(overrideApprovals).element(0).hasValueThat().isEqualTo(1);
   }
 
   @Test
@@ -430,7 +444,7 @@
 
     PushResult r = pushRefsMetaConfig();
     assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
-    assertThat(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getMergeCommitStrategy())
         .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
   }
 
@@ -469,7 +483,7 @@
 
     PushResult r = pushRefsMetaConfig();
     assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
-    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getFallbackCodeOwners())
         .isEqualTo(FallbackCodeOwners.ALL_USERS);
   }
 
@@ -494,6 +508,198 @@
                 + " (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.getProjectConfig(project).getMaxPathsInChangeMessages())
+        .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.");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.disabledBranch", value = "refs/meta/config")
+  public void validationDoesntFailOnRebaseChange_unrelatedChange() throws Exception {
+    // Create two changes for refs/meta/config both with the same parent.
+    GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
+    testRepo.reset("config");
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "Change 1", "a.txt", "content");
+    PushOneCommit.Result r = push.to("refs/for/" + RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+    String changeId1 = r.getChangeId();
+
+    testRepo.reset("config");
+    push = pushFactory.create(admin.newIdent(), testRepo, "Change 2", "b.txt", "content");
+    r = push.to("refs/for/" + RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+    String changeId2 = r.getChangeId();
+
+    // Approve and submit the first change
+    approve(changeId1);
+    gApi.changes().id(changeId1).current().submit();
+
+    // Rebase the second change, throws an exception if the code owner plugin config validation
+    // fails.
+    gApi.changes().id(changeId2).rebase();
+
+    // Second change should have 2 patch sets now.
+    ChangeInfo changeInfo = gApi.changes().id(changeId2).get(CURRENT_REVISION);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision)._number).isEqualTo(2);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.disabledBranch", value = "refs/meta/config")
+  public void validationDoesntFailOnRebaseChange_changeThatUpdatesTheCodeOwnersConfig()
+      throws Exception {
+    // Create two changes for refs/meta/config both with the same parent.
+    GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
+    testRepo.reset("config");
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "Change 1", "a,txt", "content");
+    PushOneCommit.Result r = push.to("refs/for/" + RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+    String changeId1 = r.getChangeId();
+
+    testRepo.reset("config");
+    Config cfg = new Config();
+    cfg.setEnum(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        GeneralConfig.KEY_FALLBACK_CODE_OWNERS,
+        FallbackCodeOwners.ALL_USERS);
+    push =
+        pushFactory.create(
+            admin.newIdent(), testRepo, "Change 2", "code-owners.config", cfg.toText());
+    r = push.to("refs/for/" + RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+    String changeId2 = r.getChangeId();
+
+    // Approve and submit the first change
+    approve(changeId1);
+    gApi.changes().id(changeId1).current().submit();
+
+    // Rebase the second change, throws an exception if the code owner plugin config validation
+    // fails.
+    gApi.changes().id(changeId2).rebase();
+
+    // Second change should have 2 patch sets now.
+    ChangeInfo changeInfo = gApi.changes().id(changeId2).get(CURRENT_REVISION);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision)._number).isEqualTo(2);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.disabledBranch", value = "refs/meta/config")
+  public void validationFailsOnRebaseChange_changeThatCreatesInvalidCodeOwnerConfig()
+      throws Exception {
+    // Create two changes for refs/meta/config both with the same parent.
+    GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
+    testRepo.reset("config");
+    Config cfg = new Config();
+    cfg.setEnum(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        GeneralConfig.KEY_FALLBACK_CODE_OWNERS,
+        FallbackCodeOwners.NONE);
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(), testRepo, "Change 1", "code-owners.config", cfg.toText());
+    PushOneCommit.Result r = push.to("refs/for/" + RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+    String changeId1 = r.getChangeId();
+
+    testRepo.reset("config");
+    cfg = new Config();
+    cfg.setEnum(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        GeneralConfig.KEY_FALLBACK_CODE_OWNERS,
+        FallbackCodeOwners.ALL_USERS);
+    push =
+        pushFactory.create(
+            admin.newIdent(), testRepo, "Change 2", "code-owners.config", cfg.toText());
+    r = push.to("refs/for/" + RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+    String changeId2 = r.getChangeId();
+
+    // Approve and submit the first change
+    approve(changeId1);
+    gApi.changes().id(changeId1).current().submit();
+
+    // Rebase the second change with allowing conflicts. This results in a code-owners.config that
+    // contains conflict markers and hence is rejected as invalid.
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.allowConflicts = true;
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId2).rebase(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "Invalid config file code-owners.config in project %s in branch %s",
+                project, RefNames.REFS_CONFIG));
+  }
+
+  @Test
+  public void validatorForProjectConfigIsInvokedBeforeCodeOwnersConfigValidator() throws Exception {
+    fetchRefsMetaConfig();
+    Config cfg = new Config();
+    cfg.setEnum(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        GeneralConfig.KEY_FALLBACK_CODE_OWNERS,
+        FallbackCodeOwners.ALL_USERS);
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Change 1",
+            ImmutableMap.of("code-owners.config", cfg.toText(), "project.config", "INVALID"));
+    PushOneCommit.Result r = push.to("refs/for/" + RefNames.REFS_CONFIG);
+
+    // The invalid project.config is rejected by the project config validator in Gerrit core before
+    // CodeOwnersPluginConfigValidator is invoked (if CodeOwnersPluginConfigValidator would be
+    // invoked first it would fail with a ConfigInvalidException and the message would be "internal
+    // error").
+    r.assertMessage(
+        String.format(
+            "commit %s: invalid project configuration",
+            ObjectIds.abbreviateName(r.getCommit(), testRepo.getRevWalk().getObjectReader())));
+  }
+
   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 a838795..3777242 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerBranchConfigIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerBranchConfigIT.java
@@ -26,15 +26,15 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerBranchConfigInfo;
-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.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.gerrit.plugins.codeowners.backend.config.BackendConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.OverrideApprovalConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.RequiredApprovalConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.StatusConfig;
+import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -95,7 +95,6 @@
     assertThat(codeOwnerBranchConfigInfo.requiredApproval.value)
         .isEqualTo(RequiredApprovalConfig.DEFAULT_VALUE);
     assertThat(codeOwnerBranchConfigInfo.overrideApproval).isNull();
-    assertThat(codeOwnerBranchConfigInfo.noCodeOwnersDefined).isNull();
   }
 
   @Test
@@ -246,14 +245,6 @@
     assertThat(codeOwnerBranchConfigInfo.general.implicitApprovals).isTrue();
   }
 
-  @Test
-  public void getConfig_bootstrappingMode() throws Exception {
-    configureImplicitApprovals(project);
-    CodeOwnerBranchConfigInfo codeOwnerBranchConfigInfo =
-        projectCodeOwnersApiFactory.project(project).branch("master").getConfig();
-    assertThat(codeOwnerBranchConfigInfo.noCodeOwnersDefined).isTrue();
-  }
-
   private void configureFileExtension(Project.NameKey project, String fileExtension)
       throws Exception {
     setCodeOwnersConfig(
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 c17e4e2..f958012 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerConfigFilesIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerConfigFilesIT.java
@@ -20,15 +20,10 @@
 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;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerSet;
-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.BackendConfig;
-import org.junit.Before;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import org.junit.Test;
 
 /**
@@ -36,13 +31,6 @@
  * com.google.gerrit.plugins.codeowners.restapi.GetCodeOwnerConfigFiles} REST endpoint.
  */
 public class GetCodeOwnerConfigFilesIT extends AbstractCodeOwnersIT {
-  private BackendConfig backendConfig;
-
-  @Before
-  public void setUpCodeOwnersPlugin() throws Exception {
-    backendConfig = plugin.getSysInjector().getInstance(BackendConfig.class);
-  }
-
   @Test
   public void noCodeOwnerConfigFiles() throws Exception {
     assertThat(
@@ -434,14 +422,4 @@
             codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey3).getFilePath())
         .inOrder();
   }
-
-  private String getCodeOwnerConfigFileName() {
-    CodeOwnerBackend backend = backendConfig.getDefaultBackend();
-    if (backend instanceof FindOwnersBackend) {
-      return FindOwnersBackend.CODE_OWNER_CONFIG_FILE_NAME;
-    } else if (backend instanceof ProtoBackend) {
-      return ProtoBackend.CODE_OWNER_CONFIG_FILE_NAME;
-    }
-    throw new IllegalStateException("unknown code owner backend: " + backend.getClass().getName());
-  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerConfigForPathInBranchIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerConfigForPathInBranchIT.java
index 8ccb3ba..3048707 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerConfigForPathInBranchIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerConfigForPathInBranchIT.java
@@ -20,10 +20,10 @@
 
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.plugins.codeowners.JgitPath;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerConfigInfo;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import java.util.Optional;
 import org.junit.Test;
 
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 fe6c044..64f81af 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerProjectConfigIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerProjectConfigIT.java
@@ -30,15 +30,15 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerProjectConfigInfo;
-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.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.gerrit.plugins.codeowners.backend.config.BackendConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.OverrideApprovalConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.RequiredApprovalConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.StatusConfig;
+import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
 import com.google.inject.Inject;
 import org.junit.Before;
 import org.junit.Test;
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerStatusIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerStatusIT.java
index 9b1e590..d8c51e6 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerStatusIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerStatusIT.java
@@ -14,17 +14,26 @@
 
 package com.google.gerrit.plugins.codeowners.acceptance.api;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerStatusInfoSubject.assertThat;
+import static com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusInfoSubject.isFileCodeOwnerStatus;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.ImmutableMap;
 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.request.RequestScopeOperations;
-import com.google.gerrit.extensions.common.ChangeType;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
-import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatus;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatusInfo;
-import com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusInfoSubject;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnersExperimentFeaturesConstants;
+import com.google.gerrit.plugins.codeowners.backend.FileCodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.inject.Inject;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import org.junit.Test;
 
 /**
@@ -42,13 +51,7 @@
   public void getStatus() throws Exception {
     TestAccount user2 = accountCreator.user2();
 
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/")
-        .addCodeOwnerEmail(user.email())
-        .create();
+    setAsCodeOwners("/foo/", user);
 
     String path = "foo/bar.baz";
     PushOneCommit.Result r = createChange("Change Adding A File", path, "file content");
@@ -62,40 +65,470 @@
     recommend(changeId);
 
     CodeOwnerStatusInfo codeOwnerStatus =
-        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus();
+        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().get();
     assertThat(codeOwnerStatus)
         .hasPatchSetNumberThat()
         .isEqualTo(r.getChange().currentPatchSet().id().get());
-    FileCodeOwnerStatusInfoSubject fileCodeOwnerStatusInfoSubject =
-        assertThat(codeOwnerStatus).hasFileCodeOwnerStatusesThat().onlyElement();
-    fileCodeOwnerStatusInfoSubject.hasChangeTypeThat().isEqualTo(ChangeType.ADDED);
-    fileCodeOwnerStatusInfoSubject.hasNewPathStatusThat().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusInfoSubject
-        .hasNewPathStatusThat()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.PENDING);
+
+    assertThat(codeOwnerStatus)
+        .hasFileCodeOwnerStatusesThat()
+        .comparingElementsUsing(isFileCodeOwnerStatus())
+        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.PENDING));
+    assertThat(codeOwnerStatus).hasMoreThat().isNull();
+  }
+
+  @Test
+  public void getStatusWithStart() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    String path1 = "/foo/bar/baz.md";
+    String path2 = "/foo/baz/bar.md";
+    String path3 = "/bar/foo.md";
+    String path4 = "/bar/baz.md";
+
+    PushOneCommit.Result r =
+        createChange(
+            "Change Adding A File",
+            ImmutableMap.of(
+                JgitPath.of(path1).get(),
+                "file content",
+                JgitPath.of(path2).get(),
+                "file content",
+                JgitPath.of(path3).get(),
+                "file content",
+                JgitPath.of(path4).get(),
+                "file content"));
+    String changeId = r.getChangeId();
+
+    // Add a reviewer that is a code owner.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Add a Code-Review+1 (= code owner approval) from a user that is not a code owner.
+    requestScopeOperations.setApiUser(user2.id());
+    recommend(changeId);
+
+    CodeOwnerStatusInfo codeOwnerStatus =
+        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().withStart(0).get();
+    assertThat(codeOwnerStatus)
+        .hasPatchSetNumberThat()
+        .isEqualTo(r.getChange().currentPatchSet().id().get());
+    assertThat(codeOwnerStatus)
+        .hasFileCodeOwnerStatusesThat()
+        .comparingElementsUsing(isFileCodeOwnerStatus())
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path4, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+            FileCodeOwnerStatus.addition(path3, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.PENDING),
+            FileCodeOwnerStatus.addition(path2, CodeOwnerStatus.PENDING))
+        .inOrder();
+
+    codeOwnerStatus =
+        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().withStart(1).get();
+    assertThat(codeOwnerStatus)
+        .hasPatchSetNumberThat()
+        .isEqualTo(r.getChange().currentPatchSet().id().get());
+    assertThat(codeOwnerStatus)
+        .hasFileCodeOwnerStatusesThat()
+        .comparingElementsUsing(isFileCodeOwnerStatus())
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path3, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.PENDING),
+            FileCodeOwnerStatus.addition(path2, CodeOwnerStatus.PENDING))
+        .inOrder();
+
+    codeOwnerStatus =
+        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().withStart(2).get();
+    assertThat(codeOwnerStatus)
+        .hasPatchSetNumberThat()
+        .isEqualTo(r.getChange().currentPatchSet().id().get());
+    assertThat(codeOwnerStatus)
+        .hasFileCodeOwnerStatusesThat()
+        .comparingElementsUsing(isFileCodeOwnerStatus())
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.PENDING),
+            FileCodeOwnerStatus.addition(path2, CodeOwnerStatus.PENDING))
+        .inOrder();
+
+    codeOwnerStatus =
+        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().withStart(3).get();
+    assertThat(codeOwnerStatus)
+        .hasPatchSetNumberThat()
+        .isEqualTo(r.getChange().currentPatchSet().id().get());
+    assertThat(codeOwnerStatus)
+        .hasFileCodeOwnerStatusesThat()
+        .comparingElementsUsing(isFileCodeOwnerStatus())
+        .containsExactly(FileCodeOwnerStatus.addition(path2, CodeOwnerStatus.PENDING));
+
+    codeOwnerStatus =
+        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().withStart(4).get();
+    assertThat(codeOwnerStatus)
+        .hasPatchSetNumberThat()
+        .isEqualTo(r.getChange().currentPatchSet().id().get());
+    assertThat(codeOwnerStatus).hasFileCodeOwnerStatusesThat().isEmpty();
+  }
+
+  @Test
+  public void getStatusWithLimit() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    String path1 = "/foo/bar/baz.md";
+    String path2 = "/foo/baz/bar.md";
+    String path3 = "/bar/foo.md";
+    String path4 = "/bar/baz.md";
+
+    PushOneCommit.Result r =
+        createChange(
+            "Change Adding A File",
+            ImmutableMap.of(
+                JgitPath.of(path1).get(),
+                "file content",
+                JgitPath.of(path2).get(),
+                "file content",
+                JgitPath.of(path3).get(),
+                "file content",
+                JgitPath.of(path4).get(),
+                "file content"));
+    String changeId = r.getChangeId();
+
+    // Add a reviewer that is a code owner.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Add a Code-Review+1 (= code owner approval) from a user that is not a code owner.
+    requestScopeOperations.setApiUser(user2.id());
+    recommend(changeId);
+
+    CodeOwnerStatusInfo codeOwnerStatus =
+        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().withLimit(1).get();
+    assertThat(codeOwnerStatus)
+        .hasPatchSetNumberThat()
+        .isEqualTo(r.getChange().currentPatchSet().id().get());
+    assertThat(codeOwnerStatus)
+        .hasFileCodeOwnerStatusesThat()
+        .comparingElementsUsing(isFileCodeOwnerStatus())
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path4, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    assertThat(codeOwnerStatus).hasMoreThat().isTrue();
+
+    codeOwnerStatus =
+        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().withLimit(2).get();
+    assertThat(codeOwnerStatus)
+        .hasPatchSetNumberThat()
+        .isEqualTo(r.getChange().currentPatchSet().id().get());
+    assertThat(codeOwnerStatus)
+        .hasFileCodeOwnerStatusesThat()
+        .comparingElementsUsing(isFileCodeOwnerStatus())
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path4, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+            FileCodeOwnerStatus.addition(path3, CodeOwnerStatus.INSUFFICIENT_REVIEWERS))
+        .inOrder();
+    assertThat(codeOwnerStatus).hasMoreThat().isTrue();
+
+    codeOwnerStatus =
+        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().withLimit(3).get();
+    assertThat(codeOwnerStatus)
+        .hasPatchSetNumberThat()
+        .isEqualTo(r.getChange().currentPatchSet().id().get());
+    assertThat(codeOwnerStatus)
+        .hasFileCodeOwnerStatusesThat()
+        .comparingElementsUsing(isFileCodeOwnerStatus())
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path4, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+            FileCodeOwnerStatus.addition(path3, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.PENDING))
+        .inOrder();
+    assertThat(codeOwnerStatus).hasMoreThat().isTrue();
+
+    codeOwnerStatus =
+        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().withLimit(4).get();
+    assertThat(codeOwnerStatus)
+        .hasPatchSetNumberThat()
+        .isEqualTo(r.getChange().currentPatchSet().id().get());
+    assertThat(codeOwnerStatus)
+        .hasFileCodeOwnerStatusesThat()
+        .comparingElementsUsing(isFileCodeOwnerStatus())
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path4, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+            FileCodeOwnerStatus.addition(path3, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.PENDING),
+            FileCodeOwnerStatus.addition(path2, CodeOwnerStatus.PENDING))
+        .inOrder();
+    assertThat(codeOwnerStatus).hasMoreThat().isNull();
+  }
+
+  @Test
+  public void getStatusWithLimitForRename() throws Exception {
+    testGetStatusWithLimitForRenamedFile(/* useDiffCache= */ false);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
+  public void getStatusWithLimitForRename_useDiffCache() throws Exception {
+    testGetStatusWithLimitForRenamedFile(/* useDiffCache= */ true);
+  }
+
+  private void testGetStatusWithLimitForRenamedFile(boolean useDiffCache) throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    Path oldPath = Paths.get("/foo/old.bar");
+    Path newPath = Paths.get("/bar/new.bar");
+    String changeId = createChangeWithFileRename(oldPath, newPath);
+
+    // Add a reviewer that is a code owner of the old path.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    CodeOwnerStatusInfo codeOwnerStatus =
+        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().withLimit(1).get();
+    if (useDiffCache) {
+      assertThat(codeOwnerStatus)
+          .hasFileCodeOwnerStatusesThat()
+          .comparingElementsUsing(isFileCodeOwnerStatus())
+          .containsExactly(
+              FileCodeOwnerStatus.rename(
+                  oldPath,
+                  CodeOwnerStatus.PENDING,
+                  newPath,
+                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+      assertThat(codeOwnerStatus).hasMoreThat().isNull();
+    } else {
+      assertThat(codeOwnerStatus)
+          .hasFileCodeOwnerStatusesThat()
+          .comparingElementsUsing(isFileCodeOwnerStatus())
+          .containsExactly(
+              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+      assertThat(codeOwnerStatus).hasMoreThat().isTrue();
+
+      codeOwnerStatus =
+          changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().withLimit(2).get();
+      assertThat(codeOwnerStatus)
+          .hasFileCodeOwnerStatusesThat()
+          .comparingElementsUsing(isFileCodeOwnerStatus())
+          .containsExactly(
+              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+              FileCodeOwnerStatus.deletion(oldPath, CodeOwnerStatus.PENDING));
+      assertThat(codeOwnerStatus).hasMoreThat().isNull();
+    }
+  }
+
+  @Test
+  public void getStatusWithStartAndLimit() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    String path1 = "/foo/bar/baz.md";
+    String path2 = "/foo/baz/bar.md";
+    String path3 = "/bar/foo.md";
+    String path4 = "/bar/baz.md";
+
+    PushOneCommit.Result r =
+        createChange(
+            "Change Adding A File",
+            ImmutableMap.of(
+                JgitPath.of(path1).get(),
+                "file content",
+                JgitPath.of(path2).get(),
+                "file content",
+                JgitPath.of(path3).get(),
+                "file content",
+                JgitPath.of(path4).get(),
+                "file content"));
+    String changeId = r.getChangeId();
+
+    // Add a reviewer that is a code owner.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Add a Code-Review+1 (= code owner approval) from a user that is not a code owner.
+    requestScopeOperations.setApiUser(user2.id());
+    recommend(changeId);
+
+    CodeOwnerStatusInfo codeOwnerStatus =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .getCodeOwnerStatus()
+            .withStart(1)
+            .withLimit(2)
+            .get();
+    assertThat(codeOwnerStatus)
+        .hasPatchSetNumberThat()
+        .isEqualTo(r.getChange().currentPatchSet().id().get());
+    assertThat(codeOwnerStatus)
+        .hasFileCodeOwnerStatusesThat()
+        .comparingElementsUsing(isFileCodeOwnerStatus())
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path3, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.PENDING))
+        .inOrder();
+    assertThat(codeOwnerStatus).hasMoreThat().isTrue();
+  }
+
+  @Test
+  public void startCannotBeNegative() throws Exception {
+    String changeId = createChange().getChangeId();
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                changeCodeOwnersApiFactory
+                    .change(changeId)
+                    .getCodeOwnerStatus()
+                    .withStart(-1)
+                    .get());
+    assertThat(exception).hasMessageThat().isEqualTo("start cannot be negative");
+  }
+
+  @Test
+  public void limitCannotBeNegative() throws Exception {
+    String changeId = createChange().getChangeId();
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                changeCodeOwnersApiFactory
+                    .change(changeId)
+                    .getCodeOwnerStatus()
+                    .withLimit(-1)
+                    .get());
+    assertThat(exception).hasMessageThat().isEqualTo("limit cannot be negative");
+  }
+
+  @Test
+  public void getStatusWithoutLimit() throws Exception {
+    String changeId = createChange().getChangeId();
+    CodeOwnerStatusInfo codeOwnerStatus =
+        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().withLimit(0).get();
+    assertThat(codeOwnerStatus).hasFileCodeOwnerStatusesThat().isNotEmpty();
+    assertThat(codeOwnerStatus).hasMoreThat().isNull();
+  }
+
+  @Test
+  public void getStatusForRenamedFile() throws Exception {
+    testGetStatusForRenamedFile(/* useDiffCache= */ false);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
+  public void getStatusForRenamedFile_useDiffCache() throws Exception {
+    testGetStatusForRenamedFile(/* useDiffCache= */ true);
+  }
+
+  private void testGetStatusForRenamedFile(boolean useDiffCache) throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    setAsCodeOwners("/foo/bar/", user);
+    setAsCodeOwners("/foo/baz/", user2);
+
+    String oldPath = "foo/bar/abc.txt";
+    String newPath = "foo/baz/abc.txt";
+    String changeId = createChangeWithFileRename(oldPath, newPath);
+
+    CodeOwnerStatusInfo codeOwnerStatus =
+        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().get();
+    if (useDiffCache) {
+      assertThat(codeOwnerStatus)
+          .hasFileCodeOwnerStatusesThat()
+          .comparingElementsUsing(isFileCodeOwnerStatus())
+          .containsExactly(
+              FileCodeOwnerStatus.rename(
+                  oldPath,
+                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
+                  newPath,
+                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    } else {
+      assertThat(codeOwnerStatus)
+          .hasFileCodeOwnerStatusesThat()
+          .comparingElementsUsing(isFileCodeOwnerStatus())
+          .containsExactly(
+              FileCodeOwnerStatus.deletion(oldPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    }
+
+    // Add a reviewer that is a code owner of the old path.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    codeOwnerStatus = changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().get();
+    if (useDiffCache) {
+      assertThat(codeOwnerStatus)
+          .hasFileCodeOwnerStatusesThat()
+          .comparingElementsUsing(isFileCodeOwnerStatus())
+          .containsExactly(
+              FileCodeOwnerStatus.rename(
+                  oldPath,
+                  CodeOwnerStatus.PENDING,
+                  newPath,
+                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    } else {
+      assertThat(codeOwnerStatus)
+          .hasFileCodeOwnerStatusesThat()
+          .comparingElementsUsing(isFileCodeOwnerStatus())
+          .containsExactly(
+              FileCodeOwnerStatus.deletion(oldPath, CodeOwnerStatus.PENDING),
+              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    }
+
+    // Add a reviewer that is a code owner of the new path.
+    gApi.changes().id(changeId).addReviewer(user2.email());
+
+    codeOwnerStatus = changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().get();
+    if (useDiffCache) {
+      assertThat(codeOwnerStatus)
+          .hasFileCodeOwnerStatusesThat()
+          .comparingElementsUsing(isFileCodeOwnerStatus())
+          .containsExactly(
+              FileCodeOwnerStatus.rename(
+                  oldPath, CodeOwnerStatus.PENDING, newPath, CodeOwnerStatus.PENDING));
+    } else {
+      assertThat(codeOwnerStatus)
+          .hasFileCodeOwnerStatusesThat()
+          .comparingElementsUsing(isFileCodeOwnerStatus())
+          .containsExactly(
+              FileCodeOwnerStatus.deletion(oldPath, CodeOwnerStatus.PENDING),
+              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.PENDING));
+    }
   }
 
   @Test
   public void getCodeOwnerStatusIfCodeOwnersFunctionalityIsDisabled() throws Exception {
-    // create arbitrary code owner config to avoid entering the bootstrapping code path in
-    // CodeOwnerApprovalCheck
-    createArbitraryCodeOwnerConfigFile();
-
     disableCodeOwnersForProject(project);
     String path = "foo/bar.baz";
     String changeId = createChange("Change Adding A File", path, "file content").getChangeId();
     CodeOwnerStatusInfo codeOwnerStatus =
-        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus();
-    FileCodeOwnerStatusInfoSubject fileCodeOwnerStatusInfoSubject =
-        assertThat(codeOwnerStatus).hasFileCodeOwnerStatusesThat().onlyElement();
-    fileCodeOwnerStatusInfoSubject.hasChangeTypeThat().isEqualTo(ChangeType.ADDED);
-    fileCodeOwnerStatusInfoSubject.hasNewPathStatusThat().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusInfoSubject
-        .hasNewPathStatusThat()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+        changeCodeOwnersApiFactory.change(changeId).getCodeOwnerStatus().get();
+    assertThat(codeOwnerStatus)
+        .hasFileCodeOwnerStatusesThat()
+        .comparingElementsUsing(isFileCodeOwnerStatus())
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 }
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 341beaf..c260776 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInBranchIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInBranchIT.java
@@ -16,18 +16,22 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerInfoSubject.hasAccountId;
+import static com.google.gerrit.plugins.codeowners.testing.CodeOwnersInfoSubject.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.fail;
 
 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.Account;
 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;
-import com.google.gerrit.plugins.codeowners.api.CodeOwnerInfo;
 import com.google.gerrit.plugins.codeowners.api.CodeOwners;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnersInfo;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerSetModification;
 import com.google.inject.Inject;
@@ -54,6 +58,70 @@
   }
 
   @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()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(admin.email())
+        .addCodeOwnerEmail(user2.email())
+        .addCodeOwnerEmail(user3.email())
+        .addCodeOwnerEmail(user4.email())
+        .create();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("/foo/bar.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .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.
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .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 =
+        codeOwnersInfo.codeOwners.stream()
+            .map(info -> Account.id(info.account._accountId))
+            .collect(toList());
+    boolean foundOtherOrder = false;
+    for (int i = 0; i < 10; i++) {
+      codeOwnersInfo = queryCodeOwners("/foo/bar.md");
+      List<Account.Id> accountIdsInRetrievedOrder2 =
+          codeOwnersInfo.codeOwners.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
   public void getCodeOwnersForRevision() throws Exception {
     // Create an initial code owner config that only has 'admin' as code owner.
     CodeOwnerConfig.Key codeOwnerConfigKey =
@@ -76,16 +144,20 @@
     assertThat(revision1).isNotEqualTo(revision2);
 
     // For the first revision we expect that only 'admin' is returned as code owner.
-    List<CodeOwnerInfo> codeOwnerInfos =
+    CodeOwnersInfo codeOwnersInfo =
         queryCodeOwners(
             getCodeOwnersApi().query().forRevision(revision1.name()), "/foo/bar/baz.md");
-    assertThat(codeOwnerInfos).comparingElementsUsing(hasAccountId()).containsExactly(admin.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id());
 
     // For the second revision we expect that 'admin' and 'user' are returned as code owners.
-    codeOwnerInfos =
+    codeOwnersInfo =
         queryCodeOwners(
             getCodeOwnersApi().query().forRevision(revision2.name()), "/foo/bar/baz.md");
-    assertThat(codeOwnerInfos)
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(admin.id(), user.id());
   }
@@ -151,6 +223,7 @@
 
     // Expect that 'serviceUser' is included.
     assertThat(queryCodeOwners("/foo/bar/baz.md"))
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(admin.id(), serviceUser.id());
   }
@@ -166,6 +239,7 @@
         .addMember(serviceUser.id())
         .update();
     assertThat(queryCodeOwners("/foo/bar/baz.md"))
+        .hasCodeOwnersThat()
         .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 d5a840c..1feefc6 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInChangeIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnersForPathInChangeIT.java
@@ -17,8 +17,11 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerInfoSubject.hasAccountId;
+import static com.google.gerrit.plugins.codeowners.testing.CodeOwnersInfoSubject.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toMap;
+import static org.junit.Assert.fail;
 
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
@@ -26,13 +29,15 @@
 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.Account;
 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;
-import com.google.gerrit.plugins.codeowners.JgitPath;
-import com.google.gerrit.plugins.codeowners.api.CodeOwnerInfo;
 import com.google.gerrit.plugins.codeowners.api.CodeOwners;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnersInfo;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerResolver;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -88,7 +93,7 @@
   }
 
   @Override
-  protected List<CodeOwnerInfo> queryCodeOwners(CodeOwners.QueryRequest queryRequest, String path)
+  protected CodeOwnersInfo queryCodeOwners(CodeOwners.QueryRequest queryRequest, String path)
       throws RestApiException {
     assertWithMessage("test path %s was not registered", path)
         .that(gApi.changes().id(changeId).current().files())
@@ -97,6 +102,65 @@
   }
 
   @Test
+  public void getCodeOwnersOrderIsAlwaysTheSameIfCodeOwnersHaveTheSameScoring() 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()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(admin.email())
+        .addCodeOwnerEmail(user2.email())
+        .addCodeOwnerEmail(user3.email())
+        .addCodeOwnerEmail(user4.email())
+        .create();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("/foo/bar.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .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.
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .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 the same for further requests.
+    List<Account.Id> accountIdsInRetrievedOrder1 =
+        codeOwnersInfo.codeOwners.stream()
+            .map(info -> Account.id(info.account._accountId))
+            .collect(toList());
+    for (int i = 0; i < 10; i++) {
+      codeOwnersInfo = queryCodeOwners("/foo/bar.md");
+      List<Account.Id> accountIdsInRetrievedOrder2 =
+          codeOwnersInfo.codeOwners.stream()
+              .map(info -> Account.id(info.account._accountId))
+              .collect(toList());
+      if (!accountIdsInRetrievedOrder1.equals(accountIdsInRetrievedOrder2)) {
+        fail(
+            String.format(
+                "expected always the same order %s, but order was different %s",
+                accountIdsInRetrievedOrder1, accountIdsInRetrievedOrder2));
+      }
+    }
+  }
+
+  @Test
   public void getCodeOwnersForDeletedFile() throws Exception {
     codeOwnerConfigOperations
         .newCodeOwnerConfig()
@@ -109,9 +173,12 @@
     String path = "/foo/bar/baz.txt";
     String changeId = createChangeWithFileDeletion(path);
 
-    List<CodeOwnerInfo> codeOwnerInfos =
+    CodeOwnersInfo codeOwnersInfo =
         codeOwnersApiFactory.change(changeId, "current").query().get(path);
-    assertThat(codeOwnerInfos).comparingElementsUsing(hasAccountId()).containsExactly(user.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(user.id());
   }
 
   @Test
@@ -138,15 +205,17 @@
     String newPath = "/foo/new/bar.txt";
     String changeId = createChangeWithFileRename(oldPath, newPath);
 
-    List<CodeOwnerInfo> codeOwnerInfosNewPath =
+    CodeOwnersInfo codeOwnersInfoNewPath =
         codeOwnersApiFactory.change(changeId, "current").query().get(newPath);
-    assertThat(codeOwnerInfosNewPath)
+    assertThat(codeOwnersInfoNewPath)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(user.id());
 
-    List<CodeOwnerInfo> codeOwnerInfosOldPath =
+    CodeOwnersInfo codeOwnersInfoOldPath =
         codeOwnersApiFactory.change(changeId, "current").query().get(oldPath);
-    assertThat(codeOwnerInfosOldPath)
+    assertThat(codeOwnersInfoOldPath)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(user2.id());
   }
@@ -184,9 +253,12 @@
     // 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(changeOwner.id());
-    List<CodeOwnerInfo> codeOwnerInfos =
+    CodeOwnersInfo codeOwnersInfo =
         codeOwnersApiFactory.change(changeId, "current").query().get(TEST_PATHS.get(0));
-    assertThat(codeOwnerInfos).comparingElementsUsing(hasAccountId()).containsExactly(user.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(user.id());
   }
 
   @Test
@@ -206,6 +278,7 @@
 
     // Check that both code owners are suggested.
     assertThat(queryCodeOwners("/foo/bar/baz.md"))
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(admin.id(), serviceUser.id());
 
@@ -218,6 +291,7 @@
 
     // Expect that 'serviceUser' is filtered out now.
     assertThat(queryCodeOwners("/foo/bar/baz.md"))
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(admin.id());
   }
@@ -232,7 +306,7 @@
         .forUpdate()
         .addMember(serviceUser.id())
         .update();
-    assertThat(queryCodeOwners("/foo/bar/baz.md")).isEmpty();
+    assertThat(queryCodeOwners("/foo/bar/baz.md")).hasCodeOwnersThat().isEmpty();
   }
 
   @Test
@@ -247,7 +321,112 @@
         .create();
 
     assertThat(queryCodeOwners("/foo/bar/baz.md"))
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountId())
         .containsExactly(user.id());
   }
+
+  @Test
+  public void codeOwnersThatAreReviewersAreReturnedFirst() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/bar/")
+        .addCodeOwnerEmail(user2.email())
+        .create();
+
+    // None of the code owners is a reviewer, hence the sorting is done only by distance.
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(user2.id(), user.id(), admin.id())
+        .inOrder();
+
+    // Add admin as reviewer, now admin should be returned first.
+    gApi.changes().id(changeId).addReviewer(admin.email());
+    codeOwnersInfo = queryCodeOwners("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id(), user2.id(), user.id())
+        .inOrder();
+
+    // Add user as reviewer, now user and admin should be returned first (user before admin, because
+    // user has a better distance score).
+    gApi.changes().id(changeId).addReviewer(user.email());
+    codeOwnersInfo = queryCodeOwners("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(user.id(), admin.id(), user2.id())
+        .inOrder();
+
+    // If all code owners are reviewers, only the distance score matters for the sorting.
+    gApi.changes().id(changeId).addReviewer(user2.email());
+    codeOwnersInfo = queryCodeOwners("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(user2.id(), user.id(), admin.id())
+        .inOrder();
+  }
+
+  @Test
+  public void codeOwnersSortedByDistance_fileOwnedByAllUsers() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(CodeOwnerResolver.ALL_USERS_WILDCARD)
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/bar/")
+        .addCodeOwnerEmail(user2.email())
+        .create();
+
+    // None of the code owners is a reviewer, hence the sorting is done only by distance.
+    CodeOwnersInfo codeOwnersInfo = queryCodeOwners("/foo/bar/baz.md");
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(user2.id(), user.id(), admin.id())
+        .inOrder();
+    assertThat(codeOwnersInfo).hasOwnedByAllUsersThat().isTrue();
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetOwnedPathsIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetOwnedPathsIT.java
new file mode 100644
index 0000000..681513b
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetOwnedPathsIT.java
@@ -0,0 +1,444 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.acceptance.api;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.plugins.codeowners.testing.OwnedPathsInfoSubject.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableMap;
+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.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.gerrit.plugins.codeowners.api.OwnedPathsInfo;
+import com.google.gerrit.plugins.codeowners.restapi.GetOwnedPaths;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+/**
+ * Acceptance test for the {@link com.google.gerrit.plugins.codeowners.restapi.GetOwnedPaths} REST
+ * endpoint.
+ *
+ * <p>Further tests for the {@link com.google.gerrit.plugins.codeowners.restapi.GetOwnedPaths} REST
+ * endpoint that require using the REST API are implemented in {@link
+ * com.google.gerrit.plugins.codeowners.acceptance.restapi.GetOwnedPathRestIT}.
+ */
+public class GetOwnedPathsIT extends AbstractCodeOwnersIT {
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void getOwnedPathRequiresUser() throws Exception {
+    String changeId = createChange().getChangeId();
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                changeCodeOwnersApiFactory
+                    .change(changeId)
+                    .current()
+                    .getOwnedPaths()
+                    .forUser(/* user= */ null)
+                    .get());
+    assertThat(exception).hasMessageThat().isEqualTo("--user required");
+  }
+
+  @Test
+  public void cannotGetOwnedPathForNonExistingUser() throws Exception {
+    String nonExistingUser = "non-existing";
+    String changeId = createChange().getChangeId();
+    UnprocessableEntityException exception =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () ->
+                changeCodeOwnersApiFactory
+                    .change(changeId)
+                    .current()
+                    .getOwnedPaths()
+                    .forUser(nonExistingUser)
+                    .get());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(String.format("Account '%s' not found", nonExistingUser));
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void cannotGetOwnedPathForNonVisibleUser() throws Exception {
+    TestAccount nonVisibleUser =
+        accountCreator.create(
+            "nonVisibleUser",
+            "nonVisibleUser@example.com",
+            "Non-Visible User",
+            /* displayName= */ null);
+    String changeId = createChange().getChangeId();
+    requestScopeOperations.setApiUser(user.id());
+    UnprocessableEntityException exception =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () ->
+                changeCodeOwnersApiFactory
+                    .change(changeId)
+                    .current()
+                    .getOwnedPaths()
+                    .forUser(nonVisibleUser.email())
+                    .get());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(String.format("Account '%s' not found", nonVisibleUser.email()));
+  }
+
+  @Test
+  public void getOwnedPaths() throws Exception {
+    setAsCodeOwners("/foo/", user);
+
+    String path1 = "/foo/bar/baz.md";
+    String path2 = "/foo/baz/bar.md";
+    String path3 = "/bar/foo.md";
+
+    String changeId =
+        createChange(
+                "test change",
+                ImmutableMap.of(
+                    JgitPath.of(path1).get(),
+                    "file content",
+                    JgitPath.of(path2).get(),
+                    "file content",
+                    JgitPath.of(path3).get(),
+                    "file content"))
+            .getChangeId();
+
+    OwnedPathsInfo ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactly(path1, path2).inOrder();
+    assertThat(ownedPathsInfo).hasMoreThat().isNull();
+  }
+
+  @Test
+  public void getOwnedPathForOwnUser() throws Exception {
+    setAsRootCodeOwners(admin);
+
+    String path1 = "/foo/bar/baz.md";
+    String path2 = "/foo/baz/bar.md";
+
+    String changeId =
+        createChange(
+                "test change",
+                ImmutableMap.of(
+                    JgitPath.of(path1).get(),
+                    "file content",
+                    JgitPath.of(path2).get(),
+                    "file content"))
+            .getChangeId();
+
+    OwnedPathsInfo ownedPathsInfo =
+        changeCodeOwnersApiFactory.change(changeId).current().getOwnedPaths().forUser("self").get();
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactly(path1, path2).inOrder();
+  }
+
+  @Test
+  public void getOwnedPathsForNonCodeOwner() throws Exception {
+    setAsCodeOwners("/foo/", admin);
+
+    String path1 = "/foo/bar/baz.md";
+    String path2 = "/foo/baz/bar.md";
+    String path3 = "/bar/foo.md";
+
+    String changeId =
+        createChange(
+                "test change",
+                ImmutableMap.of(
+                    JgitPath.of(path1).get(),
+                    "file content",
+                    JgitPath.of(path2).get(),
+                    "file content",
+                    JgitPath.of(path3).get(),
+                    "file content"))
+            .getChangeId();
+
+    OwnedPathsInfo ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().isEmpty();
+  }
+
+  @Test
+  public void getOwnedPathsWithStart() throws Exception {
+    setAsRootCodeOwners(user);
+
+    String path1 = "/bar/baz.md";
+    String path2 = "/bar/foo.md";
+    String path3 = "/foo/bar/baz.md";
+    String path4 = "/foo/baz/bar.md";
+
+    String changeId =
+        createChange(
+                "test change",
+                ImmutableMap.of(
+                    JgitPath.of(path1).get(),
+                    "file content",
+                    JgitPath.of(path2).get(),
+                    "file content",
+                    JgitPath.of(path3).get(),
+                    "file content",
+                    JgitPath.of(path4).get(),
+                    "file content"))
+            .getChangeId();
+
+    OwnedPathsInfo ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .withStart(0)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo)
+        .hasOwnedPathsThat()
+        .containsExactly(path1, path2, path3, path4)
+        .inOrder();
+
+    ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .withStart(1)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactly(path2, path3, path4).inOrder();
+
+    ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .withStart(2)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactly(path3, path4).inOrder();
+
+    ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .withStart(3)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactly(path4);
+
+    ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .withStart(4)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().isEmpty();
+  }
+
+  @Test
+  public void getOwnedPathsWithLimit() throws Exception {
+    setAsRootCodeOwners(user);
+
+    String path1 = "/bar/baz.md";
+    String path2 = "/bar/foo.md";
+    String path3 = "/foo/bar/baz.md";
+    String path4 = "/foo/baz/bar.md";
+
+    String changeId =
+        createChange(
+                "test change",
+                ImmutableMap.of(
+                    JgitPath.of(path1).get(),
+                    "file content",
+                    JgitPath.of(path2).get(),
+                    "file content",
+                    JgitPath.of(path3).get(),
+                    "file content",
+                    JgitPath.of(path4).get(),
+                    "file content"))
+            .getChangeId();
+
+    OwnedPathsInfo ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .withLimit(1)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactly(path1);
+    assertThat(ownedPathsInfo).hasMoreThat().isTrue();
+
+    ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .withLimit(2)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactly(path1, path2).inOrder();
+    assertThat(ownedPathsInfo).hasMoreThat().isTrue();
+
+    ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .withLimit(3)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactly(path1, path2, path3).inOrder();
+    assertThat(ownedPathsInfo).hasMoreThat().isTrue();
+
+    ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .withLimit(4)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo)
+        .hasOwnedPathsThat()
+        .containsExactly(path1, path2, path3, path4)
+        .inOrder();
+    assertThat(ownedPathsInfo).hasMoreThat().isNull();
+  }
+
+  @Test
+  public void getOwnedPathsWithStartAndLimit() throws Exception {
+    setAsRootCodeOwners(user);
+
+    String path1 = "/bar/baz.md";
+    String path2 = "/bar/foo.md";
+    String path3 = "/foo/bar/baz.md";
+    String path4 = "/foo/baz/bar.md";
+
+    String changeId =
+        createChange(
+                "test change",
+                ImmutableMap.of(
+                    JgitPath.of(path1).get(),
+                    "file content",
+                    JgitPath.of(path2).get(),
+                    "file content",
+                    JgitPath.of(path3).get(),
+                    "file content",
+                    JgitPath.of(path4).get(),
+                    "file content"))
+            .getChangeId();
+
+    OwnedPathsInfo ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .withStart(1)
+            .withLimit(2)
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactly(path2, path3).inOrder();
+    assertThat(ownedPathsInfo).hasMoreThat().isTrue();
+  }
+
+  @Test
+  public void getOwnedPathsLimitedByDefault() throws Exception {
+    setAsRootCodeOwners(user);
+
+    ImmutableMap.Builder<String, String> files = ImmutableMap.builder();
+    for (int i = 1; i <= GetOwnedPaths.DEFAULT_LIMIT + 1; i++) {
+      files.put(String.format("foo-%d.txt", i), "file content");
+    }
+
+    String changeId = createChange("test change", files.build()).getChangeId();
+
+    OwnedPathsInfo ownedPathsInfo =
+        changeCodeOwnersApiFactory
+            .change(changeId)
+            .current()
+            .getOwnedPaths()
+            .forUser(user.email())
+            .get();
+    assertThat(ownedPathsInfo).hasOwnedPathsThat().hasSize(GetOwnedPaths.DEFAULT_LIMIT);
+  }
+
+  @Test
+  public void startCannotBeNegative() throws Exception {
+    String changeId = createChange().getChangeId();
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                changeCodeOwnersApiFactory
+                    .change(changeId)
+                    .current()
+                    .getOwnedPaths()
+                    .withStart(-1)
+                    .forUser(user.email())
+                    .get());
+    assertThat(exception).hasMessageThat().isEqualTo("start cannot be negative");
+  }
+
+  @Test
+  public void limitCannotBeNegative() throws Exception {
+    String changeId = createChange().getChangeId();
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                changeCodeOwnersApiFactory
+                    .change(changeId)
+                    .current()
+                    .getOwnedPaths()
+                    .withLimit(-1)
+                    .forUser(user.email())
+                    .get());
+    assertThat(exception).hasMessageThat().isEqualTo("limit must be positive");
+  }
+
+  @Test
+  public void cannotGetOwnedPathWithoutLimit() throws Exception {
+    String changeId = createChange().getChangeId();
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                changeCodeOwnersApiFactory
+                    .change(changeId)
+                    .current()
+                    .getOwnedPaths()
+                    .withLimit(0)
+                    .forUser(user.email())
+                    .get());
+    assertThat(exception).hasMessageThat().isEqualTo("limit must be positive");
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/NotImplementedIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/NotImplementedIT.java
new file mode 100644
index 0000000..0d8fde0
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/NotImplementedIT.java
@@ -0,0 +1,74 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.acceptance.api;
+
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import com.google.gerrit.plugins.codeowners.api.BranchCodeOwners;
+import com.google.gerrit.plugins.codeowners.api.ChangeCodeOwners;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerConfigs;
+import com.google.gerrit.plugins.codeowners.api.CodeOwners;
+import com.google.gerrit.plugins.codeowners.api.ProjectCodeOwners;
+import com.google.gerrit.plugins.codeowners.api.RevisionCodeOwners;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import org.junit.Test;
+
+/** Tests the {@code NotImplemented} classes of the code owners Java API. */
+public class NotImplementedIT extends AbstractCodeOwnersTest {
+  @Test
+  public void allMethodsThrowNotImplementedException() throws Exception {
+    assertNotImplemented(BranchCodeOwners.NotImplemented.class);
+    assertNotImplemented(ChangeCodeOwners.NotImplemented.class);
+    assertNotImplemented(CodeOwners.NotImplemented.class);
+    assertNotImplemented(ProjectCodeOwners.NotImplemented.class);
+    assertNotImplemented(RevisionCodeOwners.NotImplemented.class);
+    assertNotImplemented(CodeOwnerConfigs.NotImplemented.class);
+  }
+
+  private <T> void assertNotImplemented(Class<T> notImplementedClass) throws Exception {
+    // Instantiate the NotImplemented class.
+    T instance = notImplementedClass.getDeclaredConstructor().newInstance();
+
+    // NotImplemented classes should have exactly 1 interface.
+    Class<?> apiInterface =
+        Iterables.getOnlyElement(Arrays.asList(notImplementedClass.getInterfaces()));
+
+    // Iterator over all methods from the interface (if we would iterator over all methods from the
+    // class, we would also get the methods that are inherited from Object, which we want to skip).
+    for (Method method : apiInterface.getMethods()) {
+      Object[] parameters = new Object[method.getParameterCount()];
+      for (int i = 0; i < method.getParameterCount(); i++) {
+        // For invoking the method use null for each parameter, except if it's a String, then use an
+        // empty String (that's needed because there are some default method implementations that
+        // fail with NPE if a null String is passed in).
+        parameters[i] = method.getParameterTypes()[i].equals(String.class) ? "" : null;
+      }
+      // Invoking the method should throw a NotImplementedException. Since we use reflection to
+      // invoke it, the NotImplementedException is wrapped in a InvocationTargetException.
+      InvocationTargetException ex =
+          assertThrows(InvocationTargetException.class, () -> method.invoke(instance, parameters));
+      assertWithMessage(method.getName())
+          .that(ex)
+          .hasCauseThat()
+          .isInstanceOf(NotImplementedException.class);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerApprovalIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerApprovalIT.java
new file mode 100644
index 0000000..8a8b9c4
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerApprovalIT.java
@@ -0,0 +1,1092 @@
+// 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.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+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.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.HashMap;
+import org.junit.Test;
+
+/** Acceptance test for {@code com.google.gerrit.plugins.codeowners.backend.OnCodeOwnerApproval}. */
+public class OnCodeOwnerApprovalIT extends AbstractCodeOwnersIT {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  @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 changeMessageNotExtended_sameCodeOwnerApprovalAppliedAgain() 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);
+
+    int messageCount = gApi.changes().id(changeId).get().messages.size();
+
+    // Apply the Code-Review+1 approval again
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    // Check that no new change message was added.
+    assertThat(messages.size()).isEqualTo(messageCount);
+
+    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 changeMessageNotExtended_sameCodeOwnerApprovalAppliedAgainTogetherWithOtherLabel()
+      throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Other", " 0", "Approved");
+    gApi.projects().name(project.get()).label("Other").create(input).get();
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allowLabel("Other")
+                .range(0, 1)
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .build())
+        .update();
+
+    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));
+
+    // Apply the Code-Review+1 approval again and add an unrelated vote (Code-Review+1 is ignored).
+    ReviewInput reviewInput = ReviewInput.recommend();
+    reviewInput.labels.put("Other", (short) 1);
+    gApi.changes().id(changeId).current().review(reviewInput);
+
+    messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Other+1");
+  }
+
+  @Test
+  public void changeMessageNotExtended_sameCodeOwnerApprovalAppliedAgainTogetherWithComment()
+      throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Other", " 0", "Approved");
+    gApi.projects().name(project.get()).label("Other").create(input).get();
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allowLabel("Other")
+                .range(0, 1)
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .build())
+        .update();
+
+    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));
+
+    // Apply the Code-Review+1 approval again and add a comment (Code-Review +1 is ignored)
+    ReviewInput.CommentInput commentInput = new ReviewInput.CommentInput();
+    commentInput.line = 1;
+    commentInput.message = "some comment";
+    commentInput.path = path;
+    ReviewInput reviewInput = ReviewInput.recommend();
+    reviewInput.comments = new HashMap<>();
+    reviewInput.comments.put(commentInput.path, Lists.newArrayList(commentInput));
+    gApi.changes().id(changeId).current().review(reviewInput);
+
+    messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1:\n\n(1 comment)");
+  }
+
+  @Test
+  public void changeMessageNotExtended_sameCodeOwnerApprovalAppliedByOtherCodeOwner()
+      throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .addCodeOwnerEmail(user.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));
+
+    // Apply the Code-Review+1 by another code owner
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeId);
+
+    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",
+                user.fullName(), path));
+  }
+
+  @Test
+  public void changeMessageListsPathsThatAreStillApproved_approvalUpgraded() 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
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void
+      changeMessageListsPathsThatAreStillApproved_approvalDowngraded_implicitApprovalsEnabled()
+          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();
+
+    approve(changeId);
+
+    // Downgrade the approval from Code-Review+2 to Code-Review+1
+    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 still explicitly code-owner"
+                    + " approved by %s:\n"
+                    + "* %s\n",
+                admin.fullName(), path));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void
+      changeMessageListsPathsThatAreStillApproved_approvalUpgraded_implicitApprovalsEnabled()
+          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 explicitly code-owner"
+                    + " approved by %s:\n"
+                    + "* %s\n",
+                admin.fullName(), path));
+  }
+
+  @Test
+  public void changeMessageListsPathsThatAreStillApproved_approvalDowngraded() 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();
+
+    approve(changeId);
+
+    // Downgrade the approval from Code-Review+2 to Code-Review+1
+    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 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", "foo/bar.baz", "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 path5 = "baz/foo.bar";
+    String changeId =
+        createChange(
+                "Test Change",
+                ImmutableMap.of(
+                    path1,
+                    "file content",
+                    path2,
+                    "file content",
+                    path3,
+                    "file content",
+                    path4,
+                    "file content",
+                    path5,
+                    "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"
+                    + "(more files)\n",
+                admin.fullName(), path4, path3, path5));
+  }
+
+  @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");
+  }
+
+  @Test
+  public void changeMessageListsNewlyApprovedPathsIfCommentsAreAddedOnPostReview()
+      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();
+
+    ReviewInput.CommentInput commentInput = new ReviewInput.CommentInput();
+    commentInput.line = 1;
+    commentInput.message = "some comment";
+    commentInput.path = path;
+    ReviewInput reviewInput = ReviewInput.recommend();
+    reviewInput.comments = new HashMap<>();
+    reviewInput.comments.put(commentInput.path, Lists.newArrayList(commentInput));
+    gApi.changes().id(changeId).current().review(reviewInput);
+
+    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"
+                    + "(1 comment)\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 changeMessageNotExtendedIfUsersPostsOnOldPatchSet() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(admin.email())
+        .create();
+
+    String changeId = createChange("Test Change", "foo/bar.baz", "file content").getChangeId();
+
+    // create a second patch set
+    amendChange(changeId);
+
+    // vote on the first patch set
+    gApi.changes().id(changeId).revision(1).review(ReviewInput.recommend());
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Code-Review+1");
+  }
+
+  @Test
+  public void changeMessageNotExtendedIfDestinationBranchWasDeleted() throws Exception {
+    String branchName = "tempBranch";
+    createBranch(BranchNameKey.create(project, branchName));
+
+    String changeId = createChange("refs/for/" + branchName).getChangeId();
+
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    input.branches = ImmutableList.of(branchName);
+    gApi.projects().name(project.get()).deleteBranches(input);
+
+    // Approve by a code-owner.
+    recommend(changeId);
+
+    // If the destination branch of the change no longer exits, the owned paths cannot be computed.
+    // Hence the change message cannot be extended in this case.
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Code-Review+1");
+  }
+
+  @Test
+  public void changeMessageExtendedIfCodeOwnerApprovalIsIgnoredDueToSelfApproval()
+      throws Exception {
+    setAsRootCodeOwners(admin);
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.ignoreSelfApproval = true;
+    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+
+    String changeId = createChange().getChangeId();
+
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            "Patch Set 1: Code-Review+1\n\n"
+                + "The vote Code-Review+1 is ignored as code-owner approval since the label"
+                + " doesn't allow self approval of the patch set uploader.\n");
+  }
+
+  @Test
+  public void changeMessageExtendedIfUpgradedCodeOwnerApprovalIsIgnoredDueToSelfApproval()
+      throws Exception {
+    setAsRootCodeOwners(admin);
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.ignoreSelfApproval = true;
+    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+
+    String changeId = createChange().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(
+            "Patch Set 1: Code-Review+2\n\n"
+                + "The vote Code-Review+2 is ignored as code-owner approval since the label"
+                + " doesn't allow self approval of the patch set uploader.\n");
+  }
+
+  @Test
+  public void changeMessageExtendedIfDowngradedCodeOwnerApprovalIsIgnoredDueToSelfApproval()
+      throws Exception {
+    setAsRootCodeOwners(admin);
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.ignoreSelfApproval = true;
+    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+
+    String changeId = createChange().getChangeId();
+
+    approve(changeId);
+
+    // Downgrade the approval from Code-Review+2 to Code-Review+1
+    recommend(changeId);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            "Patch Set 1: Code-Review+1\n\n"
+                + "The vote Code-Review+1 is ignored as code-owner approval since the label"
+                + " doesn't allow self approval of the patch set uploader.\n");
+  }
+
+  @Test
+  public void changeMessageNotExtendedIfIgnoredCodeOwnerApprovalIsRemoved() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.ignoreSelfApproval = true;
+    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+
+    String changeId = createChange().getChangeId();
+
+    recommend(changeId);
+
+    // Remove the code-owner 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("Patch Set 1: -Code-Review");
+  }
+
+  @Test
+  public void changeMessageExtendedIfNonSelfApprovalCodeOwnerApprovalIsApplied() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.ignoreSelfApproval = true;
+    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    requestScopeOperations.setApiUser(user.id());
+    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",
+                user.fullName(), path));
+  }
+
+  @Test
+  public void changeMessageNotExtendedIfNonApprovalIsDowngraded() 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();
+
+    // Apply the Code-Review-1.
+    gApi.changes().id(changeId).current().review(ReviewInput.dislike());
+
+    // Change Code-Review-1 to Code-Review-2
+    gApi.changes().id(changeId).current().review(ReviewInput.reject());
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Code-Review-2");
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerOverrrideIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerOverrrideIT.java
new file mode 100644
index 0000000..1265466
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/OnCodeOwnerOverrrideIT.java
@@ -0,0 +1,643 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.acceptance.api;
+
+import static com.google.common.truth.Truth.assertThat;
+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.common.collect.Iterables;
+import com.google.common.collect.Lists;
+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.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.regex.Pattern;
+import org.junit.Test;
+
+/** Acceptance test for {@code com.google.gerrit.plugins.codeowners.backend.OnCodeOwnerOverride}. */
+public class OnCodeOwnerOverrrideIT extends AbstractCodeOwnersIT {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.disabled", value = "true")
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void changeMessageNotExtendedIfCodeOwnersFuctionalityIsDisabled() throws Exception {
+    createOwnersOverrideLabel();
+
+    String changeId = createChange().getChangeId();
+
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Owners-Override+1");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void changeMessageExtendedIfCodeOwnersOverrideIsApplied() throws Exception {
+    createOwnersOverrideLabel();
+
+    String changeId = createChange().getChangeId();
+
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Owners-Override+1\n\n"
+                    + "By voting Owners-Override+1 the code-owners submit requirement is overridden by %s\n",
+                admin.fullName()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void changeMessageNotExtendedIfCodeOwnersOverrideIsReApplied() throws Exception {
+    createOwnersOverrideLabel();
+
+    String changeId = createChange().getChangeId();
+
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    int messageCount = gApi.changes().id(changeId).get().messages.size();
+
+    // Apply the Owners-Override+1 approval again
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    // Check that a no new change message was added.
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(messages.size()).isEqualTo(messageCount);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void changeMessageExtendedIfCodeOwnersOverrideIsAppliedByOtherUser() throws Exception {
+    createOwnersOverrideLabel();
+
+    String changeId = createChange().getChangeId();
+
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Owners-Override+1\n\n"
+                    + "By voting Owners-Override+1 the code-owners submit requirement is overridden by %s\n",
+                admin.fullName()));
+
+    // Apply the Owners-Override+1 approval by another user
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Owners-Override+1\n\n"
+                    + "By voting Owners-Override+1 the code-owners submit requirement is overridden by %s\n",
+                user.fullName()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void changeMessageExtendedIfCodeOwnersOverrideIsUpgraded() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+2", "Override", "+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();
+
+    String changeId = createChange().getChangeId();
+
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    // Upgrade the approval from Owners-Override+1 to Owners-Override+2
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 2));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Owners-Override+2\n\n"
+                    + "By voting Owners-Override+2 the code-owners submit requirement is still"
+                    + " overridden by %s\n",
+                admin.fullName()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void changeMessageExtendedIfCodeOwnersOverrideIsDowngraded() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+2", "Override", "+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();
+
+    String changeId = createChange().getChangeId();
+
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 2));
+
+    // Downgrade the approval from Owners-Override+2 to Owners-Override+1
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Owners-Override+1\n\n"
+                    + "By voting Owners-Override+1 the code-owners submit requirement is still"
+                    + " overridden by %s\n",
+                admin.fullName()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void changeMessageExtendedIfCodeOwnersOverrideIsRemoved() throws Exception {
+    createOwnersOverrideLabel();
+
+    String changeId = createChange().getChangeId();
+
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    // Remove the override approval
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 0));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: -Owners-Override\n\n"
+                    + "By removing the Owners-Override vote the code-owners submit requirement is"
+                    + " no longer overridden by %s\n",
+                admin.fullName()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void changeMessageExtendedIfCodeOwnersOverrideIsChangedToNegativeValue() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Override", " 0", "No Override", "-1", "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(-1, 1)
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .build())
+        .update();
+
+    String changeId = createChange().getChangeId();
+
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    // Vote with Owners-Override-1
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", -1));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Owners-Override-1\n\n"
+                    + "By voting Owners-Override-1 the code-owners submit requirement is no longer"
+                    + " overridden by %s\n",
+                admin.fullName()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void changeMessageNotExtendedIfNonCodeOwnersOverrideIsApplied() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Approval", " 0", "No Approval");
+    gApi.projects().name(project.get()).label("Other").create(input).get();
+
+    // Allow to vote on the Owners-Override label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allowLabel("Other")
+                .range(0, 1)
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .build())
+        .update();
+
+    String changeId = createChange().getChangeId();
+
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Other", 1));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Other+1");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void changeMessageExtendedIfCodeOwnersOverrideIsAppliedTogetherWithComment()
+      throws Exception {
+    createOwnersOverrideLabel();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    ReviewInput.CommentInput commentInput = new ReviewInput.CommentInput();
+    commentInput.line = 1;
+    commentInput.message = "some comment";
+    commentInput.path = path;
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.labels = new HashMap<>();
+    reviewInput.labels.put("Owners-Override", (short) 1);
+    reviewInput.comments = new HashMap<>();
+    reviewInput.comments.put(commentInput.path, Lists.newArrayList(commentInput));
+    gApi.changes().id(changeId).current().review(reviewInput);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Owners-Override+1\n\n"
+                    + "(1 comment)\n\n"
+                    + "By voting Owners-Override+1 the code-owners submit requirement is"
+                    + " overridden by %s\n",
+                admin.fullName()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void changeMessageNotExtendedIfCodeOwnersOverrideIsAppliedOnOldPatchSet()
+      throws Exception {
+    createOwnersOverrideLabel();
+
+    String changeId = createChange().getChangeId();
+
+    // create a second patch set
+    amendChange(changeId);
+
+    // vote on the first patch set
+    gApi.changes().id(changeId).revision(1).review(new ReviewInput().label("Owners-Override", 1));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Owners-Override+1");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void changeMessageExtendedIfDestinationBranchWasDeleted() throws Exception {
+    createOwnersOverrideLabel();
+
+    String branchName = "tempBranch";
+    createBranch(BranchNameKey.create(project, branchName));
+
+    String changeId = createChange("refs/for/" + branchName).getChangeId();
+
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    input.branches = ImmutableList.of(branchName);
+    gApi.projects().name(project.get()).deleteBranches(input);
+
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Owners-Override+1\n\n"
+                    + "By voting Owners-Override+1 the code-owners submit requirement is overridden by %s\n",
+                admin.fullName()));
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.overrideApproval",
+      values = {"Owners-Override+1", "Global-Override+1"})
+  public void changeMessageExtendedIfMultipleCodeOwnersOverridesAreAppliedTogether()
+      throws Exception {
+    createOwnersOverrideLabel();
+    createOwnersOverrideLabel("Global-Override");
+
+    String changeId = createChange().getChangeId();
+
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.labels = new HashMap<>();
+    reviewInput.labels.put("Owners-Override", (short) 1);
+    reviewInput.labels.put("Global-Override", (short) 1);
+    gApi.changes().id(changeId).current().review(reviewInput);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .matches(
+            Pattern.quote("Patch Set 1: ")
+                + "("
+                + Pattern.quote("Owners-Override+1 Global-Override+1")
+                + "|"
+                + Pattern.quote("Global-Override+1 Owners-Override+1")
+                + ")"
+                + Pattern.quote(
+                    String.format(
+                        "\n\nBy voting Global-Override+1 the code-owners submit requirement is"
+                            + " overridden by %s\n\n"
+                            + "By voting Owners-Override+1 the code-owners submit requirement is"
+                            + " overridden by %s\n",
+                        admin.fullName(), admin.fullName())));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void changeMessageExtendedIfCodeOwnersOverrideAndCodeOwnerApprovalAreAppliedTogether()
+      throws Exception {
+    setAsRootCodeOwners(admin);
+
+    createOwnersOverrideLabel();
+
+    String path = "foo/bar.baz";
+    String changeId = createChange("Test Change", path, "file content").getChangeId();
+
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.labels = new HashMap<>();
+    reviewInput.labels.put("Owners-Override", (short) 1);
+    reviewInput.labels.put("Code-Review", (short) 1);
+    gApi.changes().id(changeId).current().review(reviewInput);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message).startsWith("Patch Set 1: ");
+    assertThat(Iterables.getLast(messages).message)
+        .containsMatch(
+            "("
+                + Pattern.quote("Owners-Override+1 Code-Review+1")
+                + "|"
+                + Pattern.quote("Code-Review+1 Owners-Override+1")
+                + ")");
+    assertThat(Iterables.getLast(messages).message)
+        .contains(
+            String.format(
+                "By voting Owners-Override+1 the code-owners submit requirement is"
+                    + " overridden by %s\n",
+                admin.fullName()));
+    assertThat(Iterables.getLast(messages).message)
+        .contains(
+            String.format(
+                "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.overrideApproval", value = "Owners-Override+1")
+  public void changeMessageExtendedIfCodeOwnerOverrideIsIgnoredDueToSelfApproval()
+      throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Override", " 0", "No Override");
+    input.ignoreSelfApproval = true;
+    gApi.projects().name(project.get()).label("Owners-Override").create(input);
+
+    // Allow to vote on the Owners-Override label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allowLabel("Owners-Override")
+                .range(0, 1)
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .build())
+        .update();
+
+    String changeId = createChange().getChangeId();
+
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            "Patch Set 1: Owners-Override+1\n\n"
+                + "The vote Owners-Override+1 is ignored as code-owner override since the label"
+                + " doesn't allow self approval of the patch set uploader.\n");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void changeMessageExtendedIfUpgradedCodeOwnerOverrideIsIgnoredDueToSelfApproval()
+      throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+2", "Override", "+1", "Override", " 0", "No Override");
+    input.ignoreSelfApproval = true;
+    gApi.projects().name(project.get()).label("Owners-Override").create(input);
+
+    // 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();
+
+    String changeId = createChange().getChangeId();
+
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    // Upgrade the approval from Owners-Override+1 to Owners-Override+2
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 2));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            "Patch Set 1: Owners-Override+2\n\n"
+                + "The vote Owners-Override+2 is ignored as code-owner override since the label"
+                + " doesn't allow self approval of the patch set uploader.\n");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void changeMessageExtendedIfDowngradedCodeOwnerOverrideIsIgnoredDueToSelfApproval()
+      throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+2", "Override", "+1", "Override", " 0", "No Override");
+    input.ignoreSelfApproval = true;
+    gApi.projects().name(project.get()).label("Owners-Override").create(input);
+
+    // 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();
+
+    String changeId = createChange().getChangeId();
+
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 2));
+
+    // Downgrade the approval from Owners-Override+2 to Owners-Override+1
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            "Patch Set 1: Owners-Override+1\n\n"
+                + "The vote Owners-Override+1 is ignored as code-owner override since the label"
+                + " doesn't allow self approval of the patch set uploader.\n");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void changeMessageNotExtendedIfIgnoredCodeOwnerOverrideIsRemoved() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Override", " 0", "No Override");
+    input.ignoreSelfApproval = true;
+    gApi.projects().name(project.get()).label("Owners-Override").create(input);
+
+    // Allow to vote on the Owners-Override label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allowLabel("Owners-Override")
+                .range(0, 1)
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .build())
+        .update();
+
+    String changeId = createChange().getChangeId();
+
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    // Remove the override approval
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 0));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: -Owners-Override");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void changeMessageExtendedIfNonSelfApprovalCodeOwnerOverrideIsApplied() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Override", " 0", "No Override");
+    input.ignoreSelfApproval = true;
+    gApi.projects().name(project.get()).label("Owners-Override").create(input);
+
+    // Allow to vote on the Owners-Override label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allowLabel("Owners-Override")
+                .range(0, 1)
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .build())
+        .update();
+
+    String changeId = createChange().getChangeId();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            String.format(
+                "Patch Set 1: Owners-Override+1\n\n"
+                    + "By voting Owners-Override+1 the code-owners submit requirement is overridden by %s\n",
+                user.fullName()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void changeMessageNotExtendedIfNonApprovalIsDowngraded() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values =
+        ImmutableMap.of("+1", "Override", " 0", "No Override", "-1", "Dislike", "-2", "Blocked");
+    gApi.projects().name(project.get()).label("Owners-Override").create(input);
+
+    // Allow to vote on the Owners-Override label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allowLabel("Owners-Override")
+                .range(-2, 1)
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .build())
+        .update();
+
+    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();
+
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", -1));
+
+    // Downgrade the vote from -1 to -2.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", -2));
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Owners-Override-2");
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/PutCodeOwnerProjectConfigIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/PutCodeOwnerProjectConfigIT.java
new file mode 100644
index 0000000..5834499
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/PutCodeOwnerProjectConfigIT.java
@@ -0,0 +1,673 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.acceptance.api;
+
+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.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.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerProjectConfigInfo;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerProjectConfigInput;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.RequiredApproval;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerConfigValidationPolicy;
+import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.project.DeleteRef;
+import com.google.inject.Inject;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Acceptance test for the {@link
+ * com.google.gerrit.plugins.codeowners.restapi.PutCodeOwnerProjectConfig} REST endpoint.
+ *
+ * <p>Further tests for the {@link
+ * com.google.gerrit.plugins.codeowners.restapi.PutCodeOwnerProjectConfig} REST endpoint that
+ * require using the REST API are implemented in {@link
+ * com.google.gerrit.plugins.codeowners.acceptance.restapi.PutCodeOwnerProjectConfigRestIT}.
+ */
+public class PutCodeOwnerProjectConfigIT extends AbstractCodeOwnersIT {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private DeleteRef deleteRef;
+
+  private CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
+
+  @Before
+  public void setup() throws Exception {
+    codeOwnersPluginConfiguration =
+        plugin.getSysInjector().getInstance(CodeOwnersPluginConfiguration.class);
+  }
+
+  @Test
+  public void requiresCallerToBeProjectOwner() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    AuthException authException =
+        assertThrows(
+            AuthException.class,
+            () ->
+                projectCodeOwnersApiFactory
+                    .project(project)
+                    .updateConfig(new CodeOwnerProjectConfigInput()));
+    assertThat(authException).hasMessageThat().isEqualTo("write refs/meta/config not permitted");
+  }
+
+  @Test
+  public void disableAndReenableCodeOwnersFunctionality() throws Exception {
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled()).isFalse();
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.disabled = true;
+    CodeOwnerProjectConfigInfo updatedConfig =
+        projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(updatedConfig.status.disabled).isTrue();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled()).isTrue();
+
+    input.disabled = false;
+    updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(updatedConfig.status.disabled).isNull();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled()).isFalse();
+  }
+
+  @Test
+  public void setDisabledBranches() throws Exception {
+    createBranch(BranchNameKey.create(project, "foo"));
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("master"))
+        .isFalse();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("foo")).isFalse();
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.disabledBranches = ImmutableList.of("refs/heads/master", "refs/heads/foo");
+    CodeOwnerProjectConfigInfo updatedConfig =
+        projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(updatedConfig.status.disabledBranches)
+        .containsExactly("refs/heads/master", "refs/heads/foo");
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("master"))
+        .isTrue();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("foo")).isTrue();
+
+    input = new CodeOwnerProjectConfigInput();
+    input.disabledBranches = ImmutableList.of();
+    updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(updatedConfig.status.disabledBranches).isNull();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("master"))
+        .isFalse();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("foo")).isFalse();
+  }
+
+  @Test
+  public void setDisabledBranchesRegEx() throws Exception {
+    createBranch(BranchNameKey.create(project, "foo"));
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("master"))
+        .isFalse();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("foo")).isFalse();
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.disabledBranches = ImmutableList.of("refs/heads/*");
+    CodeOwnerProjectConfigInfo updatedConfig =
+        projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(updatedConfig.status.disabledBranches)
+        .containsExactly("refs/heads/master", "refs/heads/foo");
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("master"))
+        .isTrue();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("foo")).isTrue();
+  }
+
+  @Test
+  public void setDisabledBranchThatDoesntExist() throws Exception {
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("foo")).isFalse();
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.disabledBranches = ImmutableList.of("refs/heads/foo");
+    CodeOwnerProjectConfigInfo updatedConfig =
+        projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    // status.disabledBranches does only contain existing branches
+    assertThat(updatedConfig.status.disabledBranches).isNull();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("foo")).isTrue();
+
+    createBranch(BranchNameKey.create(project, "foo"));
+    assertThat(projectCodeOwnersApiFactory.project(project).getConfig().status.disabledBranches)
+        .containsExactly("refs/heads/foo");
+  }
+
+  @Test
+  public void cannotSetInvalidDisabledBranch() throws Exception {
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.disabledBranches = ImmutableList.of("^refs/heads/[");
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> projectCodeOwnersApiFactory.project(project).updateConfig(input));
+
+    assertThat(exception)
+        .hasMessageThat()
+        .contains(
+            "invalid config:\n"
+                + "* Disabled branch '^refs/heads/[' that is configured in code-owners.config"
+                + " (parameter codeOwners.disabledBranch) is invalid: Unclosed character class");
+  }
+
+  @Test
+  public void setFileExtension() throws Exception {
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getFileExtension())
+        .isEmpty();
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.fileExtension = "foo";
+    CodeOwnerProjectConfigInfo updatedConfig =
+        projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(updatedConfig.general.fileExtension).isEqualTo("foo");
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getFileExtension())
+        .value()
+        .isEqualTo("foo");
+
+    input.fileExtension = "";
+    updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(updatedConfig.general.fileExtension).isNull();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getFileExtension())
+        .isEmpty();
+  }
+
+  @Test
+  public void setRequiredApproval() throws Exception {
+    RequiredApproval requiredApproval =
+        codeOwnersPluginConfiguration.getProjectConfig(project).getRequiredApproval();
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(1);
+
+    String otherLabel = "Other";
+    LabelDefinitionInput labelInput = new LabelDefinitionInput();
+    labelInput.values = ImmutableMap.of("+2", "Approval", "+1", "LGTM", " 0", "No Vote");
+    gApi.projects().name(project.get()).label(otherLabel).create(labelInput).get();
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.requiredApproval = otherLabel + "+2";
+    CodeOwnerProjectConfigInfo updatedConfig =
+        projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(updatedConfig.requiredApproval.label).isEqualTo(otherLabel);
+    assertThat(updatedConfig.requiredApproval.value).isEqualTo(2);
+    requiredApproval =
+        codeOwnersPluginConfiguration.getProjectConfig(project).getRequiredApproval();
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo(otherLabel);
+    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
+
+    input.requiredApproval = "";
+    updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(updatedConfig.requiredApproval.label).isEqualTo("Code-Review");
+    assertThat(updatedConfig.requiredApproval.value).isEqualTo(1);
+    requiredApproval =
+        codeOwnersPluginConfiguration.getProjectConfig(project).getRequiredApproval();
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(1);
+  }
+
+  @Test
+  public void setInvalidRequiredApproval() throws Exception {
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.requiredApproval = "Non-Existing-Label+2";
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> projectCodeOwnersApiFactory.project(project).updateConfig(input));
+    assertThat(exception)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "invalid config:\n"
+                    + "* Required approval 'Non-Existing-Label+2' that is configured in"
+                    + " code-owners.config (parameter codeOwners.requiredApproval) is invalid:"
+                    + " Label Non-Existing-Label doesn't exist for project %s.",
+                project));
+  }
+
+  @Test
+  public void setOverrideApproval() throws Exception {
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getOverrideApprovals())
+        .isEmpty();
+
+    String overrideLabel1 = "Bypass-Owners";
+    String overrideLabel2 = "Owners-Override";
+    createOwnersOverrideLabel(overrideLabel1);
+    createOwnersOverrideLabel(overrideLabel2);
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.overrideApprovals = ImmutableList.of(overrideLabel1 + "+1", overrideLabel2 + "+1");
+    CodeOwnerProjectConfigInfo updatedConfig =
+        projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(updatedConfig.overrideApproval).hasSize(2);
+    assertThat(updatedConfig.overrideApproval.get(0).label).isEqualTo(overrideLabel1);
+    assertThat(updatedConfig.overrideApproval.get(0).value).isEqualTo(1);
+    assertThat(updatedConfig.overrideApproval.get(1).label).isEqualTo(overrideLabel2);
+    assertThat(updatedConfig.overrideApproval.get(1).value).isEqualTo(1);
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getOverrideApprovals())
+        .hasSize(2);
+
+    input.overrideApprovals = ImmutableList.of();
+    updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(updatedConfig.overrideApproval).isNull();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getOverrideApprovals())
+        .isEmpty();
+  }
+
+  @Test
+  public void setInvalidOverrideApproval() throws Exception {
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.overrideApprovals = ImmutableList.of("Non-Existing-Label+2");
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> projectCodeOwnersApiFactory.project(project).updateConfig(input));
+    assertThat(exception)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "invalid config:\n"
+                    + "* Required approval 'Non-Existing-Label+2' that is configured in"
+                    + " code-owners.config (parameter codeOwners.overrideApproval) is invalid:"
+                    + " Label Non-Existing-Label doesn't exist for project %s.",
+                project));
+  }
+
+  @Test
+  public void setFallbackCodeOwners() throws Exception {
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getFallbackCodeOwners())
+        .isEqualTo(FallbackCodeOwners.NONE);
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.fallbackCodeOwners = FallbackCodeOwners.ALL_USERS;
+    CodeOwnerProjectConfigInfo updatedConfig =
+        projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(updatedConfig.general.fallbackCodeOwners).isEqualTo(FallbackCodeOwners.ALL_USERS);
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getFallbackCodeOwners())
+        .isEqualTo(FallbackCodeOwners.ALL_USERS);
+
+    input.fallbackCodeOwners = FallbackCodeOwners.NONE;
+    updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(updatedConfig.general.fallbackCodeOwners).isEqualTo(FallbackCodeOwners.NONE);
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getFallbackCodeOwners())
+        .isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  public void setGlobalCodeOwners() throws Exception {
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getGlobalCodeOwners())
+        .isEmpty();
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.globalCodeOwners = ImmutableList.of(user.email(), "foo.bar@example.com");
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).getGlobalCodeOwners().stream()
+                .map(CodeOwnerReference::email)
+                .collect(toImmutableSet()))
+        .containsExactly(user.email(), "foo.bar@example.com");
+
+    input.globalCodeOwners = ImmutableList.of();
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getGlobalCodeOwners())
+        .isEmpty();
+  }
+
+  @Test
+  public void setExemptedUsers() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getExemptedAccounts())
+        .isEmpty();
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.exemptedUsers = ImmutableList.of(user.email(), user2.email());
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getExemptedAccounts())
+        .containsExactly(user.id(), user2.id());
+
+    input.exemptedUsers = ImmutableList.of();
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getExemptedAccounts())
+        .isEmpty();
+  }
+
+  @Test
+  public void setMergeCommitStrategy() throws Exception {
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getMergeCommitStrategy())
+        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.mergeCommitStrategy = MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION;
+    CodeOwnerProjectConfigInfo updatedConfig =
+        projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(updatedConfig.general.mergeCommitStrategy)
+        .isEqualTo(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getMergeCommitStrategy())
+        .isEqualTo(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
+
+    input.mergeCommitStrategy = MergeCommitStrategy.ALL_CHANGED_FILES;
+    updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(updatedConfig.general.mergeCommitStrategy)
+        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getMergeCommitStrategy())
+        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
+  }
+
+  @Test
+  public void setImplicitApprovals() throws Exception {
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).areImplicitApprovalsEnabled())
+        .isFalse();
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.implicitApprovals = true;
+    CodeOwnerProjectConfigInfo updatedConfig =
+        projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(updatedConfig.general.implicitApprovals).isTrue();
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).areImplicitApprovalsEnabled())
+        .isTrue();
+
+    input.implicitApprovals = false;
+    updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(updatedConfig.general.implicitApprovals).isNull();
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).areImplicitApprovalsEnabled())
+        .isFalse();
+  }
+
+  @Test
+  public void setOverrideInfoUrl() throws Exception {
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getOverrideInfoUrl())
+        .isEmpty();
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.overrideInfoUrl = "http://foo.bar";
+    CodeOwnerProjectConfigInfo updatedConfig =
+        projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(updatedConfig.general.overrideInfoUrl).isEqualTo("http://foo.bar");
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getOverrideInfoUrl())
+        .value()
+        .isEqualTo("http://foo.bar");
+
+    input.overrideInfoUrl = "";
+    updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(updatedConfig.general.overrideInfoUrl).isNull();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getOverrideInfoUrl())
+        .isEmpty();
+  }
+
+  @Test
+  public void setInvalidCodeOwnerConfigInfoUrl() throws Exception {
+    assertThat(
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .getInvalidCodeOwnerConfigInfoUrl())
+        .isEmpty();
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.invalidCodeOwnerConfigInfoUrl = "http://foo.bar";
+    CodeOwnerProjectConfigInfo updatedConfig =
+        projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(updatedConfig.general.invalidCodeOwnerConfigInfoUrl).isEqualTo("http://foo.bar");
+    assertThat(
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .getInvalidCodeOwnerConfigInfoUrl())
+        .value()
+        .isEqualTo("http://foo.bar");
+
+    input.invalidCodeOwnerConfigInfoUrl = "";
+    updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(updatedConfig.general.invalidCodeOwnerConfigInfoUrl).isNull();
+    assertThat(
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .getInvalidCodeOwnerConfigInfoUrl())
+        .isEmpty();
+  }
+
+  @Test
+  public void setReadOnly() throws Exception {
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).areCodeOwnerConfigsReadOnly())
+        .isFalse();
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.readOnly = true;
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).areCodeOwnerConfigsReadOnly())
+        .isTrue();
+
+    input.readOnly = false;
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).areCodeOwnerConfigsReadOnly())
+        .isFalse();
+  }
+
+  @Test
+  public void setExemptPureReverts() throws Exception {
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).arePureRevertsExempted())
+        .isFalse();
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.exemptPureReverts = true;
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).arePureRevertsExempted())
+        .isTrue();
+
+    input.exemptPureReverts = false;
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).arePureRevertsExempted())
+        .isFalse();
+  }
+
+  @Test
+  public void setEnableValidationOnCommitReceived() throws Exception {
+    assertThat(
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .getCodeOwnerConfigValidationPolicyForCommitReceived("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.enableValidationOnCommitReceived = CodeOwnerConfigValidationPolicy.FALSE;
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .getCodeOwnerConfigValidationPolicyForCommitReceived("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+
+    input.enableValidationOnCommitReceived = CodeOwnerConfigValidationPolicy.TRUE;
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .getCodeOwnerConfigValidationPolicyForCommitReceived("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+  }
+
+  @Test
+  public void setEnableValidationOnSubmit() throws Exception {
+    assertThat(
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .getCodeOwnerConfigValidationPolicyForSubmit("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.enableValidationOnSubmit = CodeOwnerConfigValidationPolicy.TRUE;
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .getCodeOwnerConfigValidationPolicyForSubmit("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+
+    input.enableValidationOnSubmit = CodeOwnerConfigValidationPolicy.FALSE;
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .getCodeOwnerConfigValidationPolicyForSubmit("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  public void setRejectNonResolvableCodeOwners() throws Exception {
+    assertThat(
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .rejectNonResolvableCodeOwners("master"))
+        .isTrue();
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.rejectNonResolvableCodeOwners = false;
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .rejectNonResolvableCodeOwners("master"))
+        .isFalse();
+
+    input.rejectNonResolvableCodeOwners = true;
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .rejectNonResolvableCodeOwners("master"))
+        .isTrue();
+  }
+
+  @Test
+  public void setRejectNonResolvableImports() throws Exception {
+    assertThat(
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .rejectNonResolvableImports("master"))
+        .isTrue();
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.rejectNonResolvableImports = false;
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .rejectNonResolvableImports("master"))
+        .isFalse();
+
+    input.rejectNonResolvableImports = true;
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .rejectNonResolvableImports("master"))
+        .isTrue();
+  }
+
+  @Test
+  public void setMaxPathsInChangeMessages() throws Exception {
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).getMaxPathsInChangeMessages())
+        .isEqualTo(GeneralConfig.DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.maxPathsInChangeMessages = 10;
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).getMaxPathsInChangeMessages())
+        .isEqualTo(10);
+
+    input.maxPathsInChangeMessages = 0;
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).getMaxPathsInChangeMessages())
+        .isEqualTo(0);
+
+    input.maxPathsInChangeMessages = GeneralConfig.DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES;
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).getMaxPathsInChangeMessages())
+        .isEqualTo(GeneralConfig.DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
+  }
+
+  @Test
+  @UseClockStep
+  public void checkCommitData() throws Exception {
+    RevCommit head1 = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.disabled = true;
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+
+    // Check message, author and committer.
+    RevCommit head2 = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    assertThat(head2).isNotEqualTo(head1);
+    assertThat(head2.getFullMessage()).isEqualTo("Update code-owners configuration");
+    assertThat(head2.getAuthorIdent().getEmailAddress()).isEqualTo(admin.email());
+    assertThat(head2.getCommitterIdent().getName()).isEqualTo("Gerrit Code Review");
+
+    input.disabled = false;
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+
+    // Check that timestamps differ for each commit.
+    RevCommit head3 = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    assertThat(head3).isNotEqualTo(head2);
+    assertThat(head3.getAuthorIdent().getWhen()).isGreaterThan(head2.getAuthorIdent().getWhen());
+    assertThat(head3.getCommitterIdent().getWhen())
+        .isGreaterThan(head2.getCommitterIdent().getWhen());
+  }
+
+  @Test
+  public void noOpUpdate() throws Exception {
+    RevCommit oldHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    projectCodeOwnersApiFactory.project(project).updateConfig(new CodeOwnerProjectConfigInput());
+    RevCommit newHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    assertThat(newHead).isEqualTo(oldHead);
+  }
+
+  @Test
+  public void updateConfigWhenRefsMetaConfigIsMissing() throws Exception {
+    ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
+    deleteRef.deleteSingleRef(projectState, RefNames.REFS_CONFIG);
+
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled()).isFalse();
+
+    CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
+    input.disabled = true;
+    projectCodeOwnersApiFactory.project(project).updateConfig(input);
+
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled()).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/RenameEmailIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/RenameEmailIT.java
index 5878ce4..8a920dd 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/RenameEmailIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/RenameEmailIT.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.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;
@@ -43,8 +42,6 @@
 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;
@@ -72,12 +69,10 @@
   @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);
   }
@@ -153,6 +148,15 @@
   }
 
   @Test
+  public void requiresAuthenticatedUser() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException authException =
+        assertThrows(
+            AuthException.class, () -> renameEmail(project, "master", new RenameEmailInput()));
+    assertThat(authException).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
   public void renameEmailRequiresDirectPushPermissionsForNonProjectOwner() throws Exception {
     String secondaryEmail = "user-foo@example.com";
     accountOperations.account(user.id()).forUpdate().addSecondaryEmail(secondaryEmail).update();
@@ -180,8 +184,7 @@
 
   @Test
   public void renameEmail_noUpdateIfEmailIsNotContainedInCodeOwnerConfigs() throws Exception {
-    // renaming email is not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfRenameEmailNotSupportedByCodeOwnersBackend();
 
     codeOwnerConfigOperations
         .newCodeOwnerConfig()
@@ -211,8 +214,7 @@
 
   @Test
   public void renameOwnEmailWithDirectPushPermission() throws Exception {
-    // renaming email is not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfRenameEmailNotSupportedByCodeOwnersBackend();
 
     CodeOwnerConfig.Key codeOwnerConfigKey1 =
         codeOwnerConfigOperations
@@ -264,8 +266,7 @@
 
   @Test
   public void renameOtherEmailWithDirectPushPermission() throws Exception {
-    // renaming email is not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfRenameEmailNotSupportedByCodeOwnersBackend();
 
     CodeOwnerConfig.Key codeOwnerConfigKey1 =
         codeOwnerConfigOperations
@@ -324,8 +325,7 @@
 
   @Test
   public void renameOwnEmailAsProjectOwner() throws Exception {
-    // renaming email is not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfRenameEmailNotSupportedByCodeOwnersBackend();
 
     CodeOwnerConfig.Key codeOwnerConfigKey1 =
         codeOwnerConfigOperations
@@ -369,8 +369,7 @@
 
   @Test
   public void renameOtherEmailAsProjectOwner() throws Exception {
-    // renaming email is not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfRenameEmailNotSupportedByCodeOwnersBackend();
 
     CodeOwnerConfig.Key codeOwnerConfigKey1 =
         codeOwnerConfigOperations
@@ -414,8 +413,7 @@
 
   @Test
   public void renameEmail_callingUserBecomesCommitAuthor() throws Exception {
-    // renaming email is not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfRenameEmailNotSupportedByCodeOwnersBackend();
 
     codeOwnerConfigOperations
         .newCodeOwnerConfig()
@@ -439,8 +437,7 @@
 
   @Test
   public void renameEmailWithDefaultCommitMessage() throws Exception {
-    // renaming email is not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfRenameEmailNotSupportedByCodeOwnersBackend();
 
     codeOwnerConfigOperations
         .newCodeOwnerConfig()
@@ -464,8 +461,7 @@
 
   @Test
   public void renameEmailWithSpecifiedCommitMessage() throws Exception {
-    // renaming email is not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfRenameEmailNotSupportedByCodeOwnersBackend();
 
     codeOwnerConfigOperations
         .newCodeOwnerConfig()
@@ -490,8 +486,7 @@
 
   @Test
   public void renameEmail_specifiedCommitMessageIsTrimmed() throws Exception {
-    // renaming email is not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfRenameEmailNotSupportedByCodeOwnersBackend();
 
     codeOwnerConfigOperations
         .newCodeOwnerConfig()
@@ -517,8 +512,7 @@
 
   @Test
   public void renameEmail_lineCommentsArePreserved() throws Exception {
-    // renaming email is not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfRenameEmailNotSupportedByCodeOwnersBackend();
 
     CodeOwnerConfig.Key codeOwnerConfigKey =
         codeOwnerConfigOperations
@@ -581,8 +575,7 @@
 
   @Test
   public void renameEmail_inlineCommentsArePreserved() throws Exception {
-    // renaming email is not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfRenameEmailNotSupportedByCodeOwnersBackend();
 
     CodeOwnerConfig.Key codeOwnerConfigKey =
         codeOwnerConfigOperations
@@ -643,8 +636,7 @@
 
   @Test
   public void renameEmail_emailInCommentIsReplaced() throws Exception {
-    // renaming email is not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfRenameEmailNotSupportedByCodeOwnersBackend();
 
     CodeOwnerConfig.Key codeOwnerConfigKey =
         codeOwnerConfigOperations
@@ -688,8 +680,7 @@
   @Test
   public void renameEmail_emailThatContainsEmailToBeReplacedAsSubstringStaysIntact()
       throws Exception {
-    // renaming email is not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfRenameEmailNotSupportedByCodeOwnersBackend();
 
     TestAccount otherUser1 =
         accountCreator.create(
@@ -740,8 +731,7 @@
 
   @Test
   public void renameEmailDoesNotTouchCodeOwnerConfigsThatDoNotContainTheEmail() throws Exception {
-    // renaming email is not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfRenameEmailNotSupportedByCodeOwnersBackend();
 
     TestAccount user2 = accountCreator.user2();
 
@@ -798,8 +788,7 @@
 
   @Test
   public void renameEmailDoesNotTouchNonCodeOwnerConfigFiles() throws Exception {
-    // renaming email is not supported for the proto backend
-    assume().that(backendConfig.getDefaultBackend()).isNotInstanceOf(ProtoBackend.class);
+    skipTestIfRenameEmailNotSupportedByCodeOwnersBackend();
 
     CodeOwnerConfig.Key codeOwnerConfigKey =
         codeOwnerConfigOperations
@@ -865,6 +854,11 @@
         .renameEmailInCodeOwnerConfigFiles(input);
   }
 
+  private void skipTestIfRenameEmailNotSupportedByCodeOwnersBackend() {
+    // the proto backend doesn't support renaming emails
+    assumeThatCodeOwnersBackendIsNotProtoBackend();
+  }
+
   private Optional<String> getFileContent(Project.NameKey project, String branch, String fileName) {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/batch/CodeOwnerSubmitRuleBatchIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/batch/CodeOwnerSubmitRuleBatchIT.java
index 9365151..4235792 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/batch/CodeOwnerSubmitRuleBatchIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/batch/CodeOwnerSubmitRuleBatchIT.java
@@ -2,33 +2,37 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
-import static com.google.gerrit.plugins.codeowners.testing.SubmitRequirementInfoSubject.assertThatCollection;
+import static com.google.gerrit.plugins.codeowners.testing.LegacySubmitRequirementInfoSubject.assertThatCollection;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.plugins.codeowners.testing.SubmitRequirementInfoSubject;
+import com.google.gerrit.plugins.codeowners.testing.LegacySubmitRequirementInfoSubject;
 import com.google.inject.Inject;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Test;
 
 /**
- * Test that verifies that the {@link com.google.gerrit.plugins.codeowners.BatchModule} has bound
- * all classes that are needed to run {@code
+ * Test that verifies that the {@link com.google.gerrit.plugins.codeowners.module.BatchModule} has
+ * bound all classes that are needed to run {@code
  * com.google.gerrit.plugins.codeowners.backend.CodeOwnerSubmitRule}.
  */
-@TestPlugin(name = "code-owners", sysModule = "com.google.gerrit.plugins.codeowners.BatchModule")
+@TestPlugin(
+    name = "code-owners",
+    sysModule = "com.google.gerrit.plugins.codeowners.module.BatchModule")
 public class CodeOwnerSubmitRuleBatchIT extends LightweightPluginDaemonTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "PROJECT_OWNERS")
   public void invokeCodeOwnerSubmitRule() throws Exception {
     // Upload a change as a non-code owner.
     TestRepository<InMemoryRepository> testRepo = cloneProject(project, user);
@@ -50,14 +54,14 @@
     assertThat(changeInfo.submittable).isFalse();
 
     // Check the submit requirement.
-    SubmitRequirementInfoSubject submitRequirementInfoSubject =
+    LegacySubmitRequirementInfoSubject submitRequirementInfoSubject =
         assertThatCollection(changeInfo.requirements).onlyElement();
     submitRequirementInfoSubject.hasStatusThat().isEqualTo("NOT_READY");
     submitRequirementInfoSubject.hasFallbackTextThat().isEqualTo("Code Owners");
     submitRequirementInfoSubject.hasTypeThat().isEqualTo("code-owners");
 
-    // Approve by a project owner who is code owner since there is no code owner config file yet,
-    // and hence we are in bootstrapping mode.
+    // Approve by a project owner who is code owner since project owners are configured as fallback
+    // code owners.
     requestScopeOperations.setApiUser(admin.id());
     approve(changeId);
 
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..7aa024d 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/AbstractGetCodeOwnersForPathRestIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/AbstractGetCodeOwnersForPathRestIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerInfoSubject.hasAccountId;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerInfoSubject.hasAccountName;
+import static com.google.gerrit.plugins.codeowners.testing.CodeOwnersInfoSubject.assertThat;
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.collect.ImmutableSet;
@@ -27,11 +28,10 @@
 import com.google.gerrit.extensions.client.ListAccountsOption;
 import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
-import com.google.gerrit.plugins.codeowners.api.CodeOwnerInfo;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnersInfo;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
 import java.util.Arrays;
-import java.util.List;
 import org.junit.Test;
 
 /**
@@ -93,13 +93,17 @@
                 "o=" + ListAccountsOption.DETAILS.name(),
                 "o=" + ListAccountsOption.ALL_EMAILS.name()));
     r.assertOK();
-    List<CodeOwnerInfo> codeOwnerInfos =
-        newGson().fromJson(r.getReader(), new TypeToken<List<CodeOwnerInfo>>() {}.getType());
-    assertThat(codeOwnerInfos).comparingElementsUsing(hasAccountId()).containsExactly(admin.id());
-    assertThat(codeOwnerInfos)
+    CodeOwnersInfo codeOwnersInfo =
+        newGson().fromJson(r.getReader(), new TypeToken<CodeOwnersInfo>() {}.getType());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountName())
         .containsExactly(admin.fullName());
-    assertThat(Iterables.getOnlyElement(codeOwnerInfos).account.secondaryEmails)
+    assertThat(Iterables.getOnlyElement(codeOwnersInfo.codeOwners).account.secondaryEmails)
         .containsExactly(secondaryEmail);
   }
 
@@ -127,13 +131,17 @@
                         ImmutableSet.of(
                             ListAccountsOption.DETAILS, ListAccountsOption.ALL_EMAILS))));
     r.assertOK();
-    List<CodeOwnerInfo> codeOwnerInfos =
-        newGson().fromJson(r.getReader(), new TypeToken<List<CodeOwnerInfo>>() {}.getType());
-    assertThat(codeOwnerInfos).comparingElementsUsing(hasAccountId()).containsExactly(admin.id());
-    assertThat(codeOwnerInfos)
+    CodeOwnersInfo codeOwnersInfo =
+        newGson().fromJson(r.getReader(), new TypeToken<CodeOwnersInfo>() {}.getType());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
+        .comparingElementsUsing(hasAccountId())
+        .containsExactly(admin.id());
+    assertThat(codeOwnersInfo)
+        .hasCodeOwnersThat()
         .comparingElementsUsing(hasAccountName())
         .containsExactly(admin.fullName());
-    assertThat(Iterables.getOnlyElement(codeOwnerInfos).account.secondaryEmails)
+    assertThat(Iterables.getOnlyElement(codeOwnersInfo.codeOwners).account.secondaryEmails)
         .containsExactly(secondaryEmail);
   }
 
@@ -155,6 +163,15 @@
   }
 
   @Test
+  public void cannotGetCodeOwnersWithInvalidHexAccountOption() throws Exception {
+    String invalidHexOption = "INVALID";
+    RestResponse r = adminRestSession.get(getUrl(TEST_PATH, "O=" + invalidHexOption));
+    r.assertBadRequest();
+    assertThat(r.getEntityContent())
+        .isEqualTo(String.format("\"%s\" is not a valid value for \"-O\"", invalidHexOption));
+  }
+
+  @Test
   @GerritConfig(name = "plugin.code-owners.backend", value = "non-existing-backend")
   public void cannotGetCodeOwnersIfPluginConfigurationIsInvalid() throws Exception {
     RestResponse r = adminRestSession.get(getUrl(TEST_PATH));
@@ -165,4 +182,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/BUILD b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/BUILD
index 42c221e..8593c50 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/BUILD
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/BUILD
@@ -14,10 +14,13 @@
     deps = [
         "testbases",
         "//javatests/com/google/gerrit/acceptance/rest/util",
-        "//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/acceptance/testsuite",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/api",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/backend",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/restapi",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/util",
     ],
 )
 
@@ -30,8 +33,8 @@
         "//lib:gson",
         "//lib:guava",
         "//lib/guice",
-        "//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/api",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing",
     ],
 )
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CheckCodeOwnerCapabilityRestIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CheckCodeOwnerCapabilityRestIT.java
new file mode 100644
index 0000000..b546baa
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CheckCodeOwnerCapabilityRestIT.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.acceptance.restapi;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.gerrit.plugins.codeowners.restapi.CheckCodeOwnerCapability;
+import com.google.gerrit.server.restapi.config.ListCapabilities.CapabilityInfo;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.Inject;
+import java.util.Map;
+import org.junit.Test;
+
+/**
+ * Acceptance test for {@link
+ * com.google.gerrit.plugins.codeowners.restapi.CheckCodeOwnerCapability}.
+ */
+public class CheckCodeOwnerCapabilityRestIT extends AbstractCodeOwnersIT {
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void listCapabilities() throws Exception {
+    RestResponse r = adminRestSession.get("/config/server/capabilities");
+    r.assertOK();
+    Map<String, CapabilityInfo> capabilities =
+        newGson()
+            .fromJson(r.getReader(), new TypeToken<Map<String, CapabilityInfo>>() {}.getType());
+    CapabilityInfo capabilityInfo = capabilities.get("code-owners-" + CheckCodeOwnerCapability.ID);
+    assertThat(capabilityInfo.id).isEqualTo("code-owners-" + CheckCodeOwnerCapability.ID);
+    assertThat(capabilityInfo.name).isEqualTo("Check Code Owner");
+  }
+
+  @Test
+  public void getAccountCapabilities() throws Exception {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability("code-owners-" + CheckCodeOwnerCapability.ID).group(REGISTERED_USERS))
+        .update();
+
+    RestResponse r = adminRestSession.get("/accounts/self/capabilities");
+    r.assertOK();
+    Map<String, Object> capabilities =
+        newGson().fromJson(r.getReader(), new TypeToken<Map<String, Object>>() {}.getType());
+    assertThat(capabilities).containsEntry("code-owners-" + CheckCodeOwnerCapability.ID, true);
+  }
+}
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 d70be9f..8149903 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CodeOwnersRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CodeOwnersRestApiBindingsIT.java
@@ -42,18 +42,21 @@
 
   private static final ImmutableList<RestCall> REVISION_ENDPOINTS =
       ImmutableList.of(
-          RestCall.post("/changes/%s/revisions/current/code-owners~code_owners.check_config"));
+          RestCall.post("/changes/%s/revisions/current/code-owners~code_owners.check_config"),
+          RestCall.get("/changes/%s/revisions/current/code-owners~owned_paths"));
 
   private static final ImmutableList<RestCall> PROJECT_ENDPOINTS =
       ImmutableList.of(
           RestCall.get("/projects/%s/code-owners~code_owners.project_config"),
+          RestCall.put("/projects/%s/code-owners~code_owners.project_config"),
           RestCall.post("/projects/%s/code_owners.check_config"));
 
   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.post("/projects/%s/branches/%s/code-owners~code_owners.rename"));
+          RestCall.post("/projects/%s/branches/%s/code-owners~code_owners.rename"),
+          RestCall.get("/projects/%s/branches/%s/code-owners~code_owners.check"));
 
   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/GetCodeOwnerConfigForPathInBranchRestIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetCodeOwnerConfigForPathInBranchRestIT.java
index 92d9ce3..8ae7b98 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetCodeOwnerConfigForPathInBranchRestIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetCodeOwnerConfigForPathInBranchRestIT.java
@@ -39,7 +39,18 @@
 public class GetCodeOwnerConfigForPathInBranchRestIT extends AbstractCodeOwnersTest {
   @Test
   @GerritConfig(name = "plugin.code-owners.enableExperimentalRestEndpoints", value = "true")
-  public void getCodeOwnersForInvalidPath() throws Exception {
+  public void cannotListCodeOwnerConfigs() throws Exception {
+    RestResponse r =
+        adminRestSession.get(
+            String.format(
+                "/projects/%s/branches/%s/code_owners.config/",
+                IdString.fromDecoded(project.get()), IdString.fromDecoded("master")));
+    r.assertNotFound();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableExperimentalRestEndpoints", value = "true")
+  public void getCodeOwnerConfigsForInvalidPath() throws Exception {
     RestResponse r =
         adminRestSession.get(
             String.format(
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetCodeOwnerStatusRestIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetCodeOwnerStatusRestIT.java
index bcbb0cf..8f4959d 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetCodeOwnerStatusRestIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetCodeOwnerStatusRestIT.java
@@ -19,11 +19,11 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.plugins.codeowners.JgitPath;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperations;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -83,10 +83,33 @@
     assertThat(r.getEntityContent())
         .contains(
             String.format(
-                "invalid code owner config file %s (project = %s, branch = refs/heads/master)",
+                "* invalid code owner config file '%s' (project = %s, branch = master):\n"
+                    + "  invalid line: INVALID",
                 filePath, project.get()));
   }
 
+  @Test
+  public void cannotGetStatusWithInvalidStart() throws Exception {
+    String changeId = createChange().getChangeId();
+    RestResponse r =
+        adminRestSession.get(
+            String.format(
+                "/changes/%s/code_owners.status?start=invalid", IdString.fromDecoded(changeId)));
+    r.assertBadRequest();
+    assertThat(r.getEntityContent()).contains("\"invalid\" is not a valid value for \"--start\"");
+  }
+
+  @Test
+  public void cannotGetStatusWithInvalidLimit() throws Exception {
+    String changeId = createChange().getChangeId();
+    RestResponse r =
+        adminRestSession.get(
+            String.format(
+                "/changes/%s/code_owners.status?limit=invalid", IdString.fromDecoded(changeId)));
+    r.assertBadRequest();
+    assertThat(r.getEntityContent()).contains("\"invalid\" is not a valid value for \"--limit\"");
+  }
+
   private CodeOwnerConfig.Key createCodeOwnerConfigKey(String folderPath) {
     return CodeOwnerConfig.Key.create(project, "master", folderPath);
   }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetCodeOwnersForPathInBranchRestIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetCodeOwnersForPathInBranchRestIT.java
index 0fafad8..4a202f5 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetCodeOwnersForPathInBranchRestIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetCodeOwnersForPathInBranchRestIT.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.plugins.codeowners.acceptance.restapi;
 
+import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.extensions.restapi.IdString;
+import org.junit.Test;
 
 /**
  * Acceptance test for the {@link
@@ -35,4 +37,14 @@
         IdString.fromDecoded("master"),
         IdString.fromDecoded(path));
   }
+
+  @Test
+  public void cannotListCodeOwners() throws Exception {
+    RestResponse r =
+        adminRestSession.get(
+            String.format(
+                "/projects/%s/branches/%s/code_owners/",
+                IdString.fromDecoded(project.get()), IdString.fromDecoded("master")));
+    r.assertNotFound();
+  }
 }
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 3c88f2d..99957ad 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetCodeOwnersForPathInChangeRestIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetCodeOwnersForPathInChangeRestIT.java
@@ -19,7 +19,7 @@
 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 com.google.gerrit.plugins.codeowners.util.JgitPath;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -58,6 +58,16 @@
   }
 
   @Test
+  public void cannotListCodeOwners() throws Exception {
+    RestResponse r =
+        adminRestSession.get(
+            String.format(
+                "/changes/%s/revisions/%s/code_owners/",
+                IdString.fromDecoded(changeId), IdString.fromDecoded("current")));
+    r.assertNotFound();
+  }
+
+  @Test
   public void cannotGetCodeOwnersForNonExistingPath() throws Exception {
     String nonExistingPath = "/some/non/existing/path";
     RestResponse r = adminRestSession.get(getUrl(nonExistingPath));
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetOwnedPathRestIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetOwnedPathRestIT.java
new file mode 100644
index 0000000..e5287df
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/GetOwnedPathRestIT.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.acceptance.restapi;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.joining;
+
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import java.util.Arrays;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Acceptance test for the {@link com.google.gerrit.plugins.codeowners.restapi.GetOwnedPaths} REST
+ * endpoint. that require using via REST.
+ *
+ * <p>Acceptance test for the {@link com.google.gerrit.plugins.codeowners.restapi.GetOwnedPaths}
+ * REST endpoint that can use the Java API are implemented in {@link
+ * com.google.gerrit.plugins.codeowners.acceptance.api.GetOwnedPathsIT}.
+ */
+public class GetOwnedPathRestIT extends AbstractCodeOwnersIT {
+  private String changeId;
+
+  @Before
+  public void createTestChange() throws Exception {
+    changeId = createChange().getChangeId();
+  }
+
+  @Test
+  public void cannotGetOwnedPathsWithInvalidStart() throws Exception {
+    RestResponse r = adminRestSession.get(getUrl("user=" + user.email(), "start=invalid"));
+    r.assertBadRequest();
+    assertThat(r.getEntityContent()).contains("\"invalid\" is not a valid value for \"--start\"");
+  }
+
+  @Test
+  public void cannotGetOwnedPathsWithInvalidLimit() throws Exception {
+    RestResponse r = adminRestSession.get(getUrl("user=" + user.email(), "limit=invalid"));
+    r.assertBadRequest();
+    assertThat(r.getEntityContent()).contains("\"invalid\" is not a valid value for \"--limit\"");
+  }
+
+  private String getUrl(String... parameters) {
+    StringBuilder b = new StringBuilder();
+    b.append(getUrl());
+    String paramaterString = Arrays.stream(parameters).collect(joining("&"));
+    if (!paramaterString.isEmpty()) {
+      b.append('?').append(paramaterString);
+    }
+    return b.toString();
+  }
+
+  private String getUrl() {
+    return String.format(
+        "/changes/%s/revisions/%s/owned_paths",
+        IdString.fromDecoded(changeId), IdString.fromDecoded("current"));
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/PutCodeOwnerProjectConfigRestIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/PutCodeOwnerProjectConfigRestIT.java
new file mode 100644
index 0000000..f1ed3e6
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/PutCodeOwnerProjectConfigRestIT.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.acceptance.restapi;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerProjectConfigInput;
+import org.junit.Test;
+
+/**
+ * Acceptance test for the {@link
+ * com.google.gerrit.plugins.codeowners.restapi.PutCodeOwnerProjectConfig} REST endpoint that
+ * require using REST.
+ *
+ * <p>Acceptance test for the {@link
+ * com.google.gerrit.plugins.codeowners.restapi.PutCodeOwnerProjectConfig} REST endpoint that can
+ * use the Java API are implemented in {@link
+ * com.google.gerrit.plugins.codeowners.acceptance.api.PutCodeOwnerProjectConfigIT}.
+ *
+ * <p>The tests in this class do not depend on the used code owner backend, hence we do not need to
+ * extend {@link AbstractCodeOwnersIT}.
+ */
+public class PutCodeOwnerProjectConfigRestIT extends AbstractCodeOwnersTest {
+  @Test
+  public void cannotUpdateConfigAnonymously() throws Exception {
+    RestResponse r =
+        anonymousRestSession.put(
+            String.format(
+                "/projects/%s/code_owners.project_config", IdString.fromDecoded(project.get())),
+            new CodeOwnerProjectConfigInput());
+    r.assertForbidden();
+    assertThat(r.getEntityContent()).contains("Authentication required");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = "non-existing-backend")
+  public void cannotUpdateConfigIfPluginConfigurationIsInvalid() throws Exception {
+    RestResponse r =
+        adminRestSession.put(
+            String.format(
+                "/projects/%s/code_owners.project_config", IdString.fromDecoded(project.get())),
+            new CodeOwnerProjectConfigInput());
+    r.assertConflict();
+    assertThat(r.getEntityContent())
+        .contains(
+            "Invalid configuration of the code-owners plugin. Code owner backend"
+                + " 'non-existing-backend' that is configured in gerrit.config (parameter"
+                + " plugin.code-owners.backend) not found.");
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/RenameEmailRestIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/RenameEmailRestIT.java
index 5978272..70f7b22 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/RenameEmailRestIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/RenameEmailRestIT.java
@@ -23,8 +23,8 @@
 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.config.BackendConfig;
 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;
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/testsuite/BUILD b/javatests/com/google/gerrit/plugins/codeowners/acceptance/testsuite/BUILD
index bcdaec9..e6471af 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/testsuite/BUILD
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/testsuite/BUILD
@@ -6,9 +6,10 @@
     srcs = glob(["*Test.java"]),
     group = "code_owners_testsuite",
     deps = [
-        "//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/acceptance/testsuite",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/backend",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/common",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing",
     ],
 )
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/testsuite/TestPathExpressionsTest.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/testsuite/TestPathExpressionsTest.java
new file mode 100644
index 0000000..cd155ba
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/testsuite/TestPathExpressionsTest.java
@@ -0,0 +1,225 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.acceptance.testsuite;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.PrivateInternals_DynamicMapImpl;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+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.GlobMatcher;
+import com.google.gerrit.plugins.codeowners.backend.PathExpressionMatcher;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.inject.Key;
+import com.google.inject.util.Providers;
+import java.nio.file.Path;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link TestPathExpressions}. */
+public class TestPathExpressionsTest extends AbstractCodeOwnersTest {
+  private TestPathExpressions testPathExpressions;
+  private DynamicMap<CodeOwnerBackend> codeOwnerBackends;
+
+  @Before
+  public void setUp() {
+    testPathExpressions = plugin.getSysInjector().getInstance(TestPathExpressions.class);
+    codeOwnerBackends =
+        plugin.getSysInjector().getInstance(new Key<DynamicMap<CodeOwnerBackend>>() {});
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = "find-owners")
+  public void matchFileType_findOwnersBackend() throws Exception {
+    assertThat(testPathExpressions.matchFileType("md")).isEqualTo("*.md");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = "proto")
+  public void matchFileType_protoBackend() throws Exception {
+    assertThat(testPathExpressions.matchFileType("md")).isEqualTo("....md");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = TestCodeOwnerBackend.ID)
+  public void matchFileType_backendWithGlobMatcher() throws Exception {
+    try (AutoCloseable registration = registerTestBackend(GlobMatcher.INSTANCE)) {
+      assertThat(testPathExpressions.matchFileType("md")).isEqualTo("**.md");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = TestCodeOwnerBackend.ID)
+  public void matchFileType_backendWithUnknownMatcher() throws Exception {
+    PathExpressionMatcher pathExpressionMatcher =
+        new PathExpressionMatcher() {
+          @Override
+          public boolean matches(String pathExpression, Path relativePath) {
+            return false;
+          }
+        };
+    try (AutoCloseable registration = registerTestBackend(pathExpressionMatcher)) {
+      IllegalStateException exception =
+          assertThrows(IllegalStateException.class, () -> testPathExpressions.matchFileType("md"));
+      assertThat(exception)
+          .hasMessageThat()
+          .isEqualTo(
+              String.format(
+                  "path expression matcher %s not supported",
+                  pathExpressionMatcher.getClass().getName()));
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = TestCodeOwnerBackend.ID)
+  public void matchFileType_backendWithoutMatcher() throws Exception {
+    TestCodeOwnerBackend testCodeOwnerBackend =
+        new TestCodeOwnerBackend(/* pathExpressionMatcher= */ null);
+    try (AutoCloseable registration = registerTestBackend(testCodeOwnerBackend)) {
+      IllegalStateException exception =
+          assertThrows(IllegalStateException.class, () -> testPathExpressions.matchFileType("md"));
+      assertThat(exception)
+          .hasMessageThat()
+          .isEqualTo(
+              String.format(
+                  "code owner backend %s doesn't support path expressions",
+                  testCodeOwnerBackend.getClass().getName()));
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = "find-owners")
+  public void matchAllFilesInSubfolder_findOwnersBackend() throws Exception {
+    assertThat(testPathExpressions.matchAllFilesInSubfolder("foo")).isEqualTo("foo/**");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = "proto")
+  public void matchAllFilesInSubfolder_protoBackend() throws Exception {
+    assertThat(testPathExpressions.matchAllFilesInSubfolder("foo")).isEqualTo("foo/...");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = TestCodeOwnerBackend.ID)
+  public void matchAllFilesInSubfolder_backendWithGlobMatcher() throws Exception {
+    try (AutoCloseable registration = registerTestBackend(GlobMatcher.INSTANCE)) {
+      assertThat(testPathExpressions.matchAllFilesInSubfolder("foo")).isEqualTo("foo/**");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = TestCodeOwnerBackend.ID)
+  public void matchAllFilesInSubfolder_backendWithUnknownMatcher() throws Exception {
+    PathExpressionMatcher pathExpressionMatcher =
+        new PathExpressionMatcher() {
+          @Override
+          public boolean matches(String pathExpression, Path relativePath) {
+            return false;
+          }
+        };
+    try (AutoCloseable registration = registerTestBackend(pathExpressionMatcher)) {
+      IllegalStateException exception =
+          assertThrows(
+              IllegalStateException.class,
+              () -> testPathExpressions.matchAllFilesInSubfolder("foo"));
+      assertThat(exception)
+          .hasMessageThat()
+          .isEqualTo(
+              String.format(
+                  "path expression matcher %s not supported",
+                  pathExpressionMatcher.getClass().getName()));
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = TestCodeOwnerBackend.ID)
+  public void matchAllFilesInSubfolder_backendWithoutMatcher() throws Exception {
+    TestCodeOwnerBackend testCodeOwnerBackend =
+        new TestCodeOwnerBackend(/* pathExpressionMatcher= */ null);
+    try (AutoCloseable registration = registerTestBackend(testCodeOwnerBackend)) {
+      IllegalStateException exception =
+          assertThrows(
+              IllegalStateException.class,
+              () -> testPathExpressions.matchAllFilesInSubfolder("foo"));
+      assertThat(exception)
+          .hasMessageThat()
+          .isEqualTo(
+              String.format(
+                  "code owner backend %s doesn't support path expressions",
+                  testCodeOwnerBackend.getClass().getName()));
+    }
+  }
+
+  private AutoCloseable registerTestBackend(PathExpressionMatcher pathExpressionMatcher) {
+    return registerTestBackend(new TestCodeOwnerBackend(pathExpressionMatcher));
+  }
+
+  private AutoCloseable registerTestBackend(CodeOwnerBackend codeOwnerBackend) {
+    RegistrationHandle registrationHandle =
+        ((PrivateInternals_DynamicMapImpl<CodeOwnerBackend>) codeOwnerBackends)
+            .put("gerrit", TestCodeOwnerBackend.ID, Providers.of(codeOwnerBackend));
+    return registrationHandle::remove;
+  }
+
+  private static class TestCodeOwnerBackend implements CodeOwnerBackend {
+    static final String ID = "test-backend";
+
+    @Nullable private final PathExpressionMatcher pathExpressionMatcher;
+
+    public TestCodeOwnerBackend(@Nullable PathExpressionMatcher pathExpressionMatcher) {
+      this.pathExpressionMatcher = pathExpressionMatcher;
+    }
+
+    @Override
+    public boolean isCodeOwnerConfigFile(NameKey project, String fileName) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Optional<CodeOwnerConfig> getCodeOwnerConfig(
+        CodeOwnerConfig.Key codeOwnerConfigKey, RevWalk revWalk, ObjectId revision) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Path getFilePath(CodeOwnerConfig.Key codeOwnerConfigKey) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Optional<CodeOwnerConfig> upsertCodeOwnerConfig(
+        CodeOwnerConfig.Key codeOwnerConfigKey,
+        CodeOwnerConfigUpdate codeOwnerConfigUpdate,
+        IdentifiedUser currentUser) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Optional<PathExpressionMatcher> getPathExpressionMatcher() {
+      return Optional.ofNullable(pathExpressionMatcher);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/AbstractAutoValueTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/AbstractAutoValueTest.java
new file mode 100644
index 0000000..1a3c163
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/AbstractAutoValueTest.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+
+/** Base class for tests of AutoValue classes. */
+abstract class AbstractAutoValueTest extends AbstractCodeOwnersTest {
+  protected <T> void assertThatToStringIncludesAllData(
+      T autoValueObjectToTest, Class<T> autoValueClass) throws Exception {
+    for (Method method : autoValueClass.getDeclaredMethods()) {
+      if (Modifier.isAbstract(method.getModifiers())) {
+        Object result = method.invoke(autoValueObjectToTest);
+        assertThat(autoValueObjectToTest.toString())
+            .contains(String.format("%s=%s", method.getName(), result));
+      }
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackendTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackendTest.java
index 8874141..1bbce45 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackendTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackendTest.java
@@ -21,14 +21,17 @@
 
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
 import com.google.gerrit.plugins.codeowners.testing.backend.TestCodeOwnerConfigStorage;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.gerrit.server.IdentifiedUser;
 import java.nio.file.Paths;
 import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
@@ -190,9 +193,9 @@
   @Test
   public void cannotGetCodeOwnerConfigFromNonExistingRevision() throws Exception {
     CodeOwnerConfig.Key codeOwnerConfigKey = CodeOwnerConfig.Key.create(project, "master", "/");
-    StorageException exception =
+    CodeOwnersInternalServerErrorException exception =
         assertThrows(
-            StorageException.class,
+            CodeOwnersInternalServerErrorException.class,
             () ->
                 codeOwnerBackend.getCodeOwnerConfig(
                     codeOwnerConfigKey,
@@ -406,6 +409,48 @@
   }
 
   @Test
+  public void cannotUpdateInvalidCodeOwnerConfig() throws Exception {
+    CodeOwnerConfig.Key codeOwnerConfigKey = CodeOwnerConfig.Key.create(project, "master", "/");
+
+    // create an invalid code owner config
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(codeOwnerConfigKey.project()))) {
+      Ref ref = testRepo.getRepository().exactRef(codeOwnerConfigKey.ref());
+      RevCommit head = testRepo.getRevWalk().parseCommit(ref.getObjectId());
+      testRepo.update(
+          codeOwnerConfigKey.ref(),
+          testRepo
+              .commit()
+              .parent(head)
+              .message("Add invalid test code owner config")
+              .add(JgitPath.of(codeOwnerConfigKey.filePath(getFileName())).get(), "INVALID"));
+    }
+
+    // Try to update the code owner config.
+    CodeOwnersInternalServerErrorException exception =
+        assertThrows(
+            CodeOwnersInternalServerErrorException.class,
+            () ->
+                codeOwnerBackend.upsertCodeOwnerConfig(
+                    codeOwnerConfigKey,
+                    CodeOwnerConfigUpdate.builder()
+                        .setCodeOwnerSetsModification(
+                            CodeOwnerSetModification.addToOnlySet(user.email()))
+                        .build(),
+                    /* currentUser= */ null));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(String.format("failed to upsert code owner config %s", codeOwnerConfigKey));
+    assertThat(exception).hasCauseThat().isInstanceOf(ConfigInvalidException.class);
+    assertThat(exception.getCause())
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "invalid code owner config file '/%s' (project = %s, branch = master)",
+                getFileName(), project));
+  }
+
+  @Test
   public void cannotUpdateCodeOwnerConfigInNonExistingBranch() throws Exception {
     CodeOwnerConfig.Key codeOwnerConfigKey =
         CodeOwnerConfig.Key.create(project, "non-existing", "/");
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD b/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD
index 0a2c144..f21c5c1 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/BUILD
@@ -10,11 +10,13 @@
     group = f[:f.index(".")],
     deps = [
         ":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/acceptance/testsuite",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/backend",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/common",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing/backend:testutil",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/util",
     ],
 ) for f in glob(
     ["*Test.java"],
@@ -23,7 +25,10 @@
 
 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",
@@ -33,11 +38,13 @@
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
         "//lib:jgit",
+        "//lib:jgit-junit",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
-        "//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/backend",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing/backend:testutil",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/util",
     ],
 )
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/ChangedFileTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/ChangedFileTest.java
index a05799d..2c84634 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/ChangedFileTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/ChangedFileTest.java
@@ -22,8 +22,11 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import com.google.gerrit.plugins.codeowners.common.ChangedFile;
 import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import java.nio.file.Paths;
+import java.util.Optional;
 import org.eclipse.jgit.diff.DiffEntry;
 import org.eclipse.jgit.diff.DiffEntry.ChangeType;
 import org.junit.Rule;
@@ -39,39 +42,55 @@
 
   @Mock private DiffEntry diffEntry;
   @Mock private PatchListEntry patchListEntry;
+  @Mock private FileDiffOutput fileDiffOutput;
 
   @Test
   public void getNewPath_diffEntry() throws Exception {
     String newPath = "foo/bar/baz.txt";
-    setupDiffEntry(newPath, null, ChangeType.ADD);
+    setupDiffEntry(newPath, /* oldPath= */ null, ChangeType.ADD);
     assertThat(ChangedFile.create(diffEntry).newPath()).value().isEqualTo(Paths.get("/" + newPath));
   }
 
   @Test
   public void getNewPath_patchListEntry() throws Exception {
     String newPath = "foo/bar/baz.txt";
-    setupPatchListEntry(newPath, null, Patch.ChangeType.ADDED);
+    setupPatchListEntry(newPath, /* oldPath= */ null, Patch.ChangeType.ADDED);
     assertThat(ChangedFile.create(patchListEntry).newPath())
         .value()
         .isEqualTo(Paths.get("/" + newPath));
   }
 
   @Test
+  public void getNewPath_fileDiffOutput() throws Exception {
+    String newPath = "foo/bar/baz.txt";
+    setupFileDiffOutput(newPath, /* oldPath= */ null, Patch.ChangeType.ADDED);
+    assertThat(ChangedFile.create(fileDiffOutput).newPath())
+        .value()
+        .isEqualTo(Paths.get("/" + newPath));
+  }
+
+  @Test
   public void getNewPathWhenNewPathIsNotSet_diffEntry() throws Exception {
-    setupDiffEntry(null, null, ChangeType.ADD);
+    setupDiffEntry(/* newPath= */ null, /* oldPath= */ null, ChangeType.ADD);
     assertThat(ChangedFile.create(diffEntry).newPath()).isEmpty();
   }
 
   @Test
   public void getNewPathWhenNewPathIsNotSet_patchListEntry() throws Exception {
-    setupPatchListEntry(null, null, Patch.ChangeType.ADDED);
+    setupPatchListEntry(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.ADDED);
     assertThat(ChangedFile.create(patchListEntry).newPath()).isEmpty();
   }
 
   @Test
+  public void getNewPathWhenNewPathIsNotSet_fileDiffOutput() throws Exception {
+    setupFileDiffOutput(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.ADDED);
+    assertThat(ChangedFile.create(fileDiffOutput).newPath()).isEmpty();
+  }
+
+  @Test
   public void hasNewPath_diffEntry() throws Exception {
     String newPath = "foo/bar/baz.txt";
-    setupDiffEntry(newPath, null, ChangeType.ADD);
+    setupDiffEntry(newPath, /* oldPath= */ null, ChangeType.ADD);
 
     ChangedFile changedFile = ChangedFile.create(diffEntry);
     assertThat(changedFile.hasNewPath(Paths.get("/" + newPath))).isTrue();
@@ -81,7 +100,7 @@
   @Test
   public void hasNewPath_patchListEntry() throws Exception {
     String newPath = "foo/bar/baz.txt";
-    setupPatchListEntry(newPath, null, Patch.ChangeType.ADDED);
+    setupPatchListEntry(newPath, /* oldPath= */ null, Patch.ChangeType.ADDED);
 
     ChangedFile changedFile = ChangedFile.create(patchListEntry);
     assertThat(changedFile.hasNewPath(Paths.get("/" + newPath))).isTrue();
@@ -89,29 +108,52 @@
   }
 
   @Test
+  public void hasNewPath_fileDiffOutput() throws Exception {
+    String newPath = "foo/bar/baz.txt";
+    setupFileDiffOutput(newPath, /* oldPath= */ null, Patch.ChangeType.ADDED);
+
+    ChangedFile changedFile = ChangedFile.create(fileDiffOutput);
+    assertThat(changedFile.hasNewPath(Paths.get("/" + newPath))).isTrue();
+    assertThat(changedFile.hasNewPath(Paths.get("/otherPath"))).isFalse();
+  }
+
+  @Test
   public void cannotCheckHasNewPathWithNull_diffEntry() throws Exception {
-    setupDiffEntry(null, null, ChangeType.ADD);
+    setupDiffEntry(/* newPath= */ null, /* oldPath= */ null, ChangeType.ADD);
 
     NullPointerException npe =
         assertThrows(
-            NullPointerException.class, () -> ChangedFile.create(diffEntry).hasNewPath(null));
+            NullPointerException.class,
+            () -> ChangedFile.create(diffEntry).hasNewPath(/* absolutePath= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("absolutePath");
   }
 
   @Test
   public void cannotCheckHasNewPathWithNull_patchListEntry() throws Exception {
-    setupPatchListEntry(null, null, Patch.ChangeType.ADDED);
+    setupPatchListEntry(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.ADDED);
 
     NullPointerException npe =
         assertThrows(
-            NullPointerException.class, () -> ChangedFile.create(patchListEntry).hasNewPath(null));
+            NullPointerException.class,
+            () -> ChangedFile.create(patchListEntry).hasNewPath(/* absolutePath= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("absolutePath");
+  }
+
+  @Test
+  public void cannotCheckHasNewPathWithNull_fileDiffOutput() throws Exception {
+    setupFileDiffOutput(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.ADDED);
+
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> ChangedFile.create(fileDiffOutput).hasNewPath(/* absolutePath= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("absolutePath");
   }
 
   @Test
   public void cannotCheckHasNewPathWithRelativePath_diffEntry() throws Exception {
     String relativePath = "foo/bar/baz.txt";
-    setupDiffEntry(null, null, ChangeType.ADD);
+    setupDiffEntry(/* newPath= */ null, /* oldPath= */ null, ChangeType.ADD);
     IllegalStateException exception =
         assertThrows(
             IllegalStateException.class,
@@ -124,7 +166,7 @@
   @Test
   public void cannotCheckHasNewPathWithRelativePath_patchListEntry() throws Exception {
     String relativePath = "foo/bar/baz.txt";
-    setupPatchListEntry(null, null, Patch.ChangeType.ADDED);
+    setupPatchListEntry(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.ADDED);
     IllegalStateException exception =
         assertThrows(
             IllegalStateException.class,
@@ -135,38 +177,66 @@
   }
 
   @Test
+  public void cannotCheckHasNewPathWithRelativePath_fileDiffOutput() throws Exception {
+    String relativePath = "foo/bar/baz.txt";
+    setupFileDiffOutput(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.ADDED);
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () -> ChangedFile.create(fileDiffOutput).hasNewPath(Paths.get(relativePath)));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(String.format("path %s must be absolute", relativePath));
+  }
+
+  @Test
   public void getOldPath_diffEntry() throws Exception {
     String oldPath = "foo/bar/baz.txt";
-    setupDiffEntry(null, oldPath, ChangeType.DELETE);
+    setupDiffEntry(/* newPath= */ null, oldPath, ChangeType.DELETE);
     assertThat(ChangedFile.create(diffEntry).oldPath()).value().isEqualTo(Paths.get("/" + oldPath));
   }
 
   @Test
   public void getOldPath_patchListEntry() throws Exception {
     String oldPath = "foo/bar/baz.txt";
-    setupPatchListEntry(null, oldPath, Patch.ChangeType.DELETED);
+    setupPatchListEntry(/* newPath= */ null, oldPath, Patch.ChangeType.DELETED);
     assertThat(ChangedFile.create(patchListEntry).oldPath())
         .value()
         .isEqualTo(Paths.get("/" + oldPath));
   }
 
   @Test
+  public void getOldPath_fileDiffOutput() throws Exception {
+    String oldPath = "foo/bar/baz.txt";
+    setupFileDiffOutput(/* newPath= */ null, oldPath, Patch.ChangeType.DELETED);
+    assertThat(ChangedFile.create(fileDiffOutput).oldPath())
+        .value()
+        .isEqualTo(Paths.get("/" + oldPath));
+  }
+
+  @Test
   public void getOldPathWhenOldPathIsNotSet_diffEntry() throws Exception {
-    setupDiffEntry(null, null, ChangeType.DELETE);
+    setupDiffEntry(/* newPath= */ null, /* oldPath= */ null, ChangeType.DELETE);
     when(diffEntry.getOldPath()).thenReturn(DiffEntry.DEV_NULL);
     assertThat(ChangedFile.create(diffEntry).oldPath()).isEmpty();
   }
 
   @Test
   public void getOldPathWhenOldPathIsNotSet_patchListEntry() throws Exception {
-    setupPatchListEntry(null, null, Patch.ChangeType.DELETED);
+    setupPatchListEntry(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.DELETED);
     assertThat(ChangedFile.create(patchListEntry).oldPath()).isEmpty();
   }
 
   @Test
+  public void getOldPathWhenOldPathIsNotSet_fileDiffOutput() throws Exception {
+    setupFileDiffOutput(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.DELETED);
+    assertThat(ChangedFile.create(fileDiffOutput).oldPath()).isEmpty();
+  }
+
+  @Test
   public void hasOldPath_diffEntry() throws Exception {
     String oldPath = "foo/bar/baz.txt";
-    setupDiffEntry(null, oldPath, ChangeType.DELETE);
+    setupDiffEntry(/* newPath= */ null, oldPath, ChangeType.DELETE);
 
     ChangedFile changedFile = ChangedFile.create(diffEntry);
     assertThat(changedFile.hasOldPath(Paths.get("/" + oldPath))).isTrue();
@@ -176,7 +246,7 @@
   @Test
   public void hasOldPath_patchListEntry() throws Exception {
     String oldPath = "foo/bar/baz.txt";
-    setupPatchListEntry(null, oldPath, Patch.ChangeType.DELETED);
+    setupPatchListEntry(/* newPath= */ null, oldPath, Patch.ChangeType.DELETED);
 
     ChangedFile changedFile = ChangedFile.create(patchListEntry);
     assertThat(changedFile.hasOldPath(Paths.get("/" + oldPath))).isTrue();
@@ -184,26 +254,48 @@
   }
 
   @Test
+  public void hasOldPath_fileDiffOutput() throws Exception {
+    String oldPath = "foo/bar/baz.txt";
+    setupFileDiffOutput(/* newPath= */ null, oldPath, Patch.ChangeType.DELETED);
+
+    ChangedFile changedFile = ChangedFile.create(fileDiffOutput);
+    assertThat(changedFile.hasOldPath(Paths.get("/" + oldPath))).isTrue();
+    assertThat(changedFile.hasOldPath(Paths.get("/otherPath"))).isFalse();
+  }
+
+  @Test
   public void cannotCheckHasOldPathWithNull_diffEntry() throws Exception {
-    setupDiffEntry(null, null, ChangeType.DELETE);
+    setupDiffEntry(/* newPath= */ null, /* oldPath= */ null, ChangeType.DELETE);
     NullPointerException npe =
         assertThrows(
-            NullPointerException.class, () -> ChangedFile.create(diffEntry).hasOldPath(null));
+            NullPointerException.class,
+            () -> ChangedFile.create(diffEntry).hasOldPath(/* absolutePath= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("absolutePath");
   }
 
   @Test
   public void cannotCheckHasOldPathWithNull_patchListEntry() throws Exception {
-    setupPatchListEntry(null, null, Patch.ChangeType.DELETED);
+    setupPatchListEntry(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.DELETED);
     NullPointerException npe =
         assertThrows(
-            NullPointerException.class, () -> ChangedFile.create(patchListEntry).hasOldPath(null));
+            NullPointerException.class,
+            () -> ChangedFile.create(patchListEntry).hasOldPath(/* absolutePath= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("absolutePath");
+  }
+
+  @Test
+  public void cannotCheckHasOldPathWithNull_fileDiffOutput() throws Exception {
+    setupFileDiffOutput(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.DELETED);
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> ChangedFile.create(fileDiffOutput).hasOldPath(/* absolutePath= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("absolutePath");
   }
 
   @Test
   public void cannotCheckHasOldPathWithRelativePath_diffEntry() throws Exception {
-    setupDiffEntry(null, null, ChangeType.DELETE);
+    setupDiffEntry(/* newPath= */ null, /* oldPath= */ null, ChangeType.DELETE);
     String relativePath = "foo/bar/baz.txt";
     IllegalStateException exception =
         assertThrows(
@@ -216,7 +308,7 @@
 
   @Test
   public void cannotCheckHasOldPathWithRelativePath_patchListEntry() throws Exception {
-    setupPatchListEntry(null, null, Patch.ChangeType.DELETED);
+    setupPatchListEntry(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.DELETED);
     String relativePath = "foo/bar/baz.txt";
     IllegalStateException exception =
         assertThrows(
@@ -228,8 +320,21 @@
   }
 
   @Test
+  public void cannotCheckHasOldPathWithRelativePath_fileDiffOutput() throws Exception {
+    setupFileDiffOutput(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.DELETED);
+    String relativePath = "foo/bar/baz.txt";
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () -> ChangedFile.create(fileDiffOutput).hasOldPath(Paths.get(relativePath)));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(String.format("path %s must be absolute", relativePath));
+  }
+
+  @Test
   public void isRename_diffEntry() throws Exception {
-    setupDiffEntry(null, null, ChangeType.RENAME);
+    setupDiffEntry(/* newPath= */ null, /* oldPath= */ null, ChangeType.RENAME);
     ChangedFile changedFile = ChangedFile.create(diffEntry);
     assertThat(changedFile.isRename()).isTrue();
     assertThat(changedFile.isDeletion()).isFalse();
@@ -237,15 +342,23 @@
 
   @Test
   public void isRename_patchListEntry() throws Exception {
-    setupPatchListEntry(null, null, Patch.ChangeType.RENAMED);
+    setupPatchListEntry(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.RENAMED);
     ChangedFile changedFile = ChangedFile.create(patchListEntry);
     assertThat(changedFile.isRename()).isTrue();
     assertThat(changedFile.isDeletion()).isFalse();
   }
 
   @Test
+  public void isRename_fileDiffOutput() throws Exception {
+    setupFileDiffOutput(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.RENAMED);
+    ChangedFile changedFile = ChangedFile.create(fileDiffOutput);
+    assertThat(changedFile.isRename()).isTrue();
+    assertThat(changedFile.isDeletion()).isFalse();
+  }
+
+  @Test
   public void isDeletion_diffEntry() throws Exception {
-    setupDiffEntry(null, null, ChangeType.DELETE);
+    setupDiffEntry(/* newPath= */ null, /* oldPath= */ null, ChangeType.DELETE);
     ChangedFile changedFile = ChangedFile.create(diffEntry);
     assertThat(changedFile.isRename()).isFalse();
     assertThat(changedFile.isDeletion()).isTrue();
@@ -253,15 +366,23 @@
 
   @Test
   public void isDeletion_patchListEntry() throws Exception {
-    setupPatchListEntry(null, null, Patch.ChangeType.DELETED);
+    setupPatchListEntry(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.DELETED);
     ChangedFile changedFile = ChangedFile.create(patchListEntry);
     assertThat(changedFile.isRename()).isFalse();
     assertThat(changedFile.isDeletion()).isTrue();
   }
 
   @Test
+  public void isDeletion_fileDiffOutput() throws Exception {
+    setupFileDiffOutput(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.DELETED);
+    ChangedFile changedFile = ChangedFile.create(fileDiffOutput);
+    assertThat(changedFile.isRename()).isFalse();
+    assertThat(changedFile.isDeletion()).isTrue();
+  }
+
+  @Test
   public void isAddition_diffEntry() throws Exception {
-    setupDiffEntry(null, null, ChangeType.ADD);
+    setupDiffEntry(/* newPath= */ null, /* oldPath= */ null, ChangeType.ADD);
     ChangedFile changedFile = ChangedFile.create(diffEntry);
     assertThat(changedFile.isRename()).isFalse();
     assertThat(changedFile.isDeletion()).isFalse();
@@ -269,15 +390,23 @@
 
   @Test
   public void isAddition_patchListEntry() throws Exception {
-    setupPatchListEntry(null, null, Patch.ChangeType.ADDED);
+    setupPatchListEntry(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.ADDED);
     ChangedFile changedFile = ChangedFile.create(patchListEntry);
     assertThat(changedFile.isRename()).isFalse();
     assertThat(changedFile.isDeletion()).isFalse();
   }
 
   @Test
+  public void isAddition_fileDiffOutput() throws Exception {
+    setupFileDiffOutput(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.ADDED);
+    ChangedFile changedFile = ChangedFile.create(fileDiffOutput);
+    assertThat(changedFile.isRename()).isFalse();
+    assertThat(changedFile.isDeletion()).isFalse();
+  }
+
+  @Test
   public void isModify_diffEntry() throws Exception {
-    setupDiffEntry(null, null, ChangeType.MODIFY);
+    setupDiffEntry(/* newPath= */ null, /* oldPath= */ null, ChangeType.MODIFY);
     ChangedFile changedFile = ChangedFile.create(diffEntry);
     assertThat(changedFile.isRename()).isFalse();
     assertThat(changedFile.isDeletion()).isFalse();
@@ -285,15 +414,23 @@
 
   @Test
   public void isModify_patchListEntry() throws Exception {
-    setupPatchListEntry(null, null, Patch.ChangeType.MODIFIED);
+    setupPatchListEntry(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.MODIFIED);
     ChangedFile changedFile = ChangedFile.create(patchListEntry);
     assertThat(changedFile.isRename()).isFalse();
     assertThat(changedFile.isDeletion()).isFalse();
   }
 
   @Test
+  public void isModify_fileDiffOutput() throws Exception {
+    setupFileDiffOutput(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.MODIFIED);
+    ChangedFile changedFile = ChangedFile.create(fileDiffOutput);
+    assertThat(changedFile.isRename()).isFalse();
+    assertThat(changedFile.isDeletion()).isFalse();
+  }
+
+  @Test
   public void isCopy_diffEntry() throws Exception {
-    setupDiffEntry(null, null, ChangeType.COPY);
+    setupDiffEntry(/* newPath= */ null, /* oldPath= */ null, ChangeType.COPY);
     ChangedFile changedFile = ChangedFile.create(diffEntry);
     assertThat(changedFile.isRename()).isFalse();
     assertThat(changedFile.isDeletion()).isFalse();
@@ -301,12 +438,20 @@
 
   @Test
   public void isCopy_patchListEntry() throws Exception {
-    setupPatchListEntry(null, null, Patch.ChangeType.COPIED);
+    setupPatchListEntry(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.COPIED);
     ChangedFile changedFile = ChangedFile.create(patchListEntry);
     assertThat(changedFile.isRename()).isFalse();
     assertThat(changedFile.isDeletion()).isFalse();
   }
 
+  @Test
+  public void isCopy_fileDiffOutput() throws Exception {
+    setupFileDiffOutput(/* newPath= */ null, /* oldPath= */ null, Patch.ChangeType.COPIED);
+    ChangedFile changedFile = ChangedFile.create(fileDiffOutput);
+    assertThat(changedFile.isRename()).isFalse();
+    assertThat(changedFile.isDeletion()).isFalse();
+  }
+
   private void setupDiffEntry(
       @Nullable String newPath, @Nullable String oldPath, ChangeType changeType) {
     when(diffEntry.getNewPath()).thenReturn(newPath != null ? newPath : DiffEntry.DEV_NULL);
@@ -326,4 +471,11 @@
       when(patchListEntry.getChangeType()).thenReturn(changeType);
     }
   }
+
+  private void setupFileDiffOutput(
+      @Nullable String newPath, @Nullable String oldPath, Patch.ChangeType changeType) {
+    when(fileDiffOutput.newPath()).thenReturn(Optional.ofNullable(newPath));
+    when(fileDiffOutput.oldPath()).thenReturn(Optional.ofNullable(oldPath));
+    when(fileDiffOutput.changeType()).thenReturn(changeType);
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/ChangedFilesTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/ChangedFilesTest.java
index 19d5fdd..4534df2 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/ChangedFilesTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/ChangedFilesTest.java
@@ -17,9 +17,13 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.plugins.codeowners.testing.ChangedFileSubject.assertThat;
+import static com.google.gerrit.plugins.codeowners.testing.ChangedFileSubject.assertThatCollection;
+import static com.google.gerrit.plugins.codeowners.testing.ChangedFileSubject.hasPath;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.fail;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -32,15 +36,18 @@
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.plugins.codeowners.JgitPath;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
-import com.google.gerrit.plugins.codeowners.api.MergeCommitStrategy;
+import com.google.gerrit.plugins.codeowners.common.ChangedFile;
+import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
+import com.google.gerrit.plugins.codeowners.testing.ChangedFileSubject;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
 import com.google.inject.Inject;
 import java.nio.file.Paths;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -60,7 +67,8 @@
   @Test
   public void cannotComputeForNullRevisionResource() throws Exception {
     NullPointerException npe =
-        assertThrows(NullPointerException.class, () -> changedFiles.compute(null));
+        assertThrows(
+            NullPointerException.class, () -> changedFiles.compute(/* revisionResource= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("revisionResource");
   }
 
@@ -68,14 +76,16 @@
   public void cannotComputeForNullProject() throws Exception {
     NullPointerException npe =
         assertThrows(
-            NullPointerException.class, () -> changedFiles.compute(null, ObjectId.zeroId()));
+            NullPointerException.class,
+            () -> changedFiles.compute(/* project= */ null, ObjectId.zeroId()));
     assertThat(npe).hasMessageThat().isEqualTo("project");
   }
 
   @Test
   public void cannotComputeForNullRevision() throws Exception {
     NullPointerException npe =
-        assertThrows(NullPointerException.class, () -> changedFiles.compute(project, null));
+        assertThrows(
+            NullPointerException.class, () -> changedFiles.compute(project, /* revision= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("revision");
   }
 
@@ -85,7 +95,8 @@
     String changeId =
         createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
 
-    ImmutableSet<ChangedFile> changedFilesSet = changedFiles.compute(getRevisionResource(changeId));
+    ImmutableList<ChangedFile> changedFilesSet =
+        changedFiles.compute(getRevisionResource(changeId));
     assertThat(changedFilesSet).hasSize(1);
     ChangedFile changedFile = Iterables.getOnlyElement(changedFilesSet);
     assertThat(changedFile).hasNewPath().value().isEqualTo(Paths.get(path));
@@ -103,7 +114,8 @@
         createChange("Change Modifying A File", JgitPath.of(path).get(), "new file content")
             .getChangeId();
 
-    ImmutableSet<ChangedFile> changedFilesSet = changedFiles.compute(getRevisionResource(changeId));
+    ImmutableList<ChangedFile> changedFilesSet =
+        changedFiles.compute(getRevisionResource(changeId));
     assertThat(changedFilesSet).hasSize(1);
     ChangedFile changedFile = Iterables.getOnlyElement(changedFilesSet);
     assertThat(changedFile).hasNewPath().value().isEqualTo(Paths.get(path));
@@ -117,7 +129,8 @@
     String path = "/foo/bar/baz.txt";
     String changeId = createChangeWithFileDeletion(path);
 
-    ImmutableSet<ChangedFile> changedFilesSet = changedFiles.compute(getRevisionResource(changeId));
+    ImmutableList<ChangedFile> changedFilesSet =
+        changedFiles.compute(getRevisionResource(changeId));
     assertThat(changedFilesSet).hasSize(1);
     ChangedFile changedFile = Iterables.getOnlyElement(changedFilesSet);
     assertThat(changedFile).hasNewPath().isEmpty();
@@ -134,13 +147,22 @@
 
     gApi.changes().id(changeId).current().files();
 
-    ImmutableSet<ChangedFile> changedFilesSet = changedFiles.compute(getRevisionResource(changeId));
-    assertThat(changedFilesSet).hasSize(1);
-    ChangedFile changedFile = Iterables.getOnlyElement(changedFilesSet);
-    assertThat(changedFile).hasNewPath().value().isEqualTo(Paths.get(newPath));
-    assertThat(changedFile).hasOldPath().value().isEqualTo(Paths.get(oldPath));
-    assertThat(changedFile).isRename();
-    assertThat(changedFile).isNoDeletion();
+    // A renamed file is reported as addition of new path + deletion of old path. This is because
+    // ChangedFiles uses a DiffFormatter without rename detection (because rename detection requires
+    // loading the file contents which is too expensive).
+    ImmutableList<ChangedFile> changedFilesSet =
+        changedFiles.compute(getRevisionResource(changeId));
+    assertThat(changedFilesSet).hasSize(2);
+    ChangedFileSubject changedFile1 = assertThatCollection(changedFilesSet).element(0);
+    changedFile1.hasNewPath().value().isEqualTo(Paths.get(newPath));
+    changedFile1.hasOldPath().isEmpty();
+    changedFile1.isNoRename();
+    changedFile1.isNoDeletion();
+    ChangedFileSubject changedFile2 = assertThatCollection(changedFilesSet).element(1);
+    changedFile2.hasNewPath().isEmpty();
+    changedFile2.hasOldPath().value().isEqualTo(Paths.get(oldPath));
+    changedFile2.isNoRename();
+    changedFile2.isDeletion();
   }
 
   @Test
@@ -151,7 +173,8 @@
     assertThat(r.getCommit().getParents()).isEmpty();
     String changeId = r.getChangeId();
 
-    ImmutableSet<ChangedFile> changedFilesSet = changedFiles.compute(getRevisionResource(changeId));
+    ImmutableList<ChangedFile> changedFilesSet =
+        changedFiles.compute(getRevisionResource(changeId));
     assertThat(changedFilesSet).hasSize(1);
     ChangedFile changedFile = Iterables.getOnlyElement(changedFilesSet);
     assertThat(changedFile).hasNewPath().value().isEqualTo(Paths.get(path));
@@ -175,6 +198,8 @@
   }
 
   private void testComputeForMergeChange(MergeCommitStrategy mergeCommitStrategy) throws Exception {
+    setAsRootCodeOwners(admin);
+
     String file1 = "foo/a.txt";
     String file2 = "bar/b.txt";
 
@@ -228,17 +253,13 @@
             .content("content")
             .create();
 
-    ImmutableSet<ChangedFile> changedFilesSet =
+    ImmutableList<ChangedFile> changedFilesSet =
         changedFiles.compute(getRevisionResource(Integer.toString(mergeChange.get())));
-    ImmutableSet<String> paths =
-        changedFilesSet.stream()
-            .map(changedFile -> JgitPath.of(changedFile.newPath().get()).get())
-            .collect(toImmutableSet());
 
     if (MergeCommitStrategy.ALL_CHANGED_FILES.equals(mergeCommitStrategy)) {
-      assertThat(paths).containsExactly(file1, file2);
+      assertThat(changedFilesSet).comparingElementsUsing(hasPath()).containsExactly(file1, file2);
     } else if (MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION.equals(mergeCommitStrategy)) {
-      assertThat(paths).containsExactly(file1);
+      assertThat(changedFilesSet).comparingElementsUsing(hasPath()).containsExactly(file1);
     } else {
       fail("expected merge commit strategy: " + mergeCommitStrategy);
     }
@@ -263,6 +284,8 @@
 
   private void testComputeForMergeChangeThatContainsADeletedFileAsConflictResolution()
       throws Exception {
+    setAsRootCodeOwners(admin);
+
     String file = "foo/a.txt";
 
     // Create a base change.
@@ -303,7 +326,7 @@
             .delete()
             .create();
 
-    ImmutableSet<ChangedFile> changedFilesSet =
+    ImmutableList<ChangedFile> changedFilesSet =
         changedFiles.compute(getRevisionResource(Integer.toString(mergeChange.get())));
     ImmutableSet<String> oldPaths =
         changedFilesSet.stream()
@@ -312,6 +335,567 @@
     assertThat(oldPaths).containsExactly(file);
   }
 
+  @Test
+  public void computeReturnsChangedFilesSortedByPath() throws Exception {
+    String file1 = "foo/bar.baz";
+    String file2 = "foo/baz.bar";
+    String file3 = "bar/foo.baz";
+    String file4 = "bar/baz.foo";
+    String file5 = "baz/foo.bar";
+    String changeId =
+        createChange(
+                "Test Change",
+                ImmutableMap.of(
+                    file1,
+                    "file content",
+                    file2,
+                    "file content",
+                    file3,
+                    "file content",
+                    file4,
+                    "file content",
+                    file5,
+                    "file content"))
+            .getChangeId();
+
+    ImmutableList<ChangedFile> changedFilesSet =
+        changedFiles.compute(getRevisionResource(changeId));
+    assertThat(changedFilesSet)
+        .comparingElementsUsing(hasPath())
+        .containsExactly(file4, file3, file5, file1, file2)
+        .inOrder();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.mergeCommitStrategy", value = "ALL_CHANGED_FILES")
+  public void computeReturnsChangedFilesSortedByPath_mergeCommitAgainstFirstParent()
+      throws Exception {
+    testComputeReturnsChangedFilesSortedByPathForMerge(MergeCommitStrategy.ALL_CHANGED_FILES);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.mergeCommitStrategy",
+      value = "FILES_WITH_CONFLICT_RESOLUTION")
+  public void computeReturnsChangedFilesSortedByPath_mergeCommitAgainstAutoMerge()
+      throws Exception {
+    testComputeReturnsChangedFilesSortedByPathForMerge(
+        MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
+  }
+
+  private void testComputeReturnsChangedFilesSortedByPathForMerge(
+      MergeCommitStrategy mergeCommitStrategy) throws Exception {
+    setAsRootCodeOwners(admin);
+
+    String file1 = "foo/bar.baz";
+    String file2 = "foo/baz.bar";
+    String file3 = "bar/foo.baz";
+    String file4 = "bar/baz.foo";
+    String file5 = "baz/foo.bar";
+
+    // Create a base change.
+    Change.Id baseChange =
+        changeOperations
+            .newChange()
+            .branch("master")
+            .file(file1)
+            .content("base content")
+            .file(file3)
+            .content("base content")
+            .file(file5)
+            .content("base content")
+            .create();
+    approveAndSubmit(baseChange);
+
+    // Create another branch
+    String branchName = "foo";
+    BranchInput branchInput = new BranchInput();
+    branchInput.ref = branchName;
+    branchInput.revision = projectOperations.project(project).getHead("master").name();
+    gApi.projects().name(project.get()).branch(branchInput.ref).create(branchInput);
+
+    // Create a change in master that touches file1, file3 and file5
+    Change.Id changeInMaster =
+        changeOperations
+            .newChange()
+            .branch("master")
+            .file(file1)
+            .content("master content")
+            .file(file3)
+            .content("master content")
+            .file(file5)
+            .content("master content")
+            .create();
+    approveAndSubmit(changeInMaster);
+
+    // Create a change in the other branch and that touches file1, file3, file5 and creates file2,
+    // file4.
+    Change.Id changeInOtherBranch =
+        changeOperations
+            .newChange()
+            .branch(branchName)
+            .file(file1)
+            .content("other content")
+            .file(file2)
+            .content("content")
+            .file(file3)
+            .content("other content")
+            .file(file4)
+            .content("content")
+            .file(file5)
+            .content("other content")
+            .create();
+    approveAndSubmit(changeInOtherBranch);
+
+    // Create a merge change with a conflict resolution for file1 and file2 with the same content as
+    // in the other branch (no conflict on file2).
+    Change.Id mergeChange =
+        changeOperations
+            .newChange()
+            .branch("master")
+            .mergeOfButBaseOnFirst()
+            .tipOfBranch("master")
+            .and()
+            .tipOfBranch(branchName)
+            .file(file1)
+            .content("merged content")
+            .file(file2)
+            .content("content")
+            .file(file3)
+            .content("merged content")
+            .file(file4)
+            .content("content")
+            .file(file5)
+            .content("merged content")
+            .create();
+
+    ImmutableList<ChangedFile> changedFilesSet =
+        changedFiles.compute(getRevisionResource(Integer.toString(mergeChange.get())));
+
+    if (MergeCommitStrategy.ALL_CHANGED_FILES.equals(mergeCommitStrategy)) {
+      assertThat(changedFilesSet)
+          .comparingElementsUsing(hasPath())
+          .containsExactly(file4, file3, file5, file1, file2)
+          .inOrder();
+    } else if (MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION.equals(mergeCommitStrategy)) {
+      assertThat(changedFilesSet)
+          .comparingElementsUsing(hasPath())
+          .containsExactly(file3, file5, file1);
+    } else {
+      fail("expected merge commit strategy: " + mergeCommitStrategy);
+    }
+  }
+
+  @Test
+  public void cannotGetFromDiffCacheForNullProject() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> changedFiles.getFromDiffCache(/* project= */ null, ObjectId.zeroId()));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void cannotGetFromDiffCacheForNullRevision() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> changedFiles.getFromDiffCache(project, /* revision= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("revision");
+  }
+
+  @Test
+  public void getFromDiffCacheForChangeThatAddedAFile() throws Exception {
+    String path = "/foo/bar/baz.txt";
+    RevCommit commit =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getCommit();
+
+    ImmutableList<ChangedFile> changedFilesSet = changedFiles.getFromDiffCache(project, commit);
+    assertThat(changedFilesSet).hasSize(1);
+    ChangedFile changedFile = Iterables.getOnlyElement(changedFilesSet);
+    assertThat(changedFile).hasNewPath().value().isEqualTo(Paths.get(path));
+    assertThat(changedFile).hasOldPath().isEmpty();
+    assertThat(changedFile).isNoRename();
+    assertThat(changedFile).isNoDeletion();
+  }
+
+  @Test
+  public void getFromDiffCacheForChangeThatModifiedAFile() throws Exception {
+    String path = "/foo/bar/baz.txt";
+    createChange("Test Change", JgitPath.of(path).get(), "file content").getChangeId();
+
+    RevCommit commit =
+        createChange("Change Modifying A File", JgitPath.of(path).get(), "new file content")
+            .getCommit();
+
+    ImmutableList<ChangedFile> changedFilesSet = changedFiles.getFromDiffCache(project, commit);
+    assertThat(changedFilesSet).hasSize(1);
+    ChangedFile changedFile = Iterables.getOnlyElement(changedFilesSet);
+    assertThat(changedFile).hasNewPath().value().isEqualTo(Paths.get(path));
+    assertThat(changedFile).hasOldPath().value().isEqualTo(Paths.get(path));
+    assertThat(changedFile).isNoRename();
+    assertThat(changedFile).isNoDeletion();
+  }
+
+  @Test
+  public void getFromDiffCacheForChangeThatDeletedAFile() throws Exception {
+    String path = "/foo/bar/baz.txt";
+    String changeId = createChangeWithFileDeletion(path);
+
+    ImmutableList<ChangedFile> changedFilesSet =
+        changedFiles.getFromDiffCache(
+            project, getRevisionResource(changeId).getPatchSet().commitId());
+    assertThat(changedFilesSet).hasSize(1);
+    ChangedFile changedFile = Iterables.getOnlyElement(changedFilesSet);
+    assertThat(changedFile).hasNewPath().isEmpty();
+    assertThat(changedFile).hasOldPath().value().isEqualTo(Paths.get(path));
+    assertThat(changedFile).isNoRename();
+    assertThat(changedFile).isDeletion();
+  }
+
+  @Test
+  public void getFromDiffCacheForChangeThatRenamedAFile() throws Exception {
+    String oldPath = "/foo/bar/old.txt";
+    String newPath = "/foo/bar/new.txt";
+    String changeId = createChangeWithFileRename(oldPath, newPath);
+
+    gApi.changes().id(changeId).current().files();
+
+    ImmutableList<ChangedFile> changedFilesSet =
+        changedFiles.getFromDiffCache(
+            project, getRevisionResource(changeId).getPatchSet().commitId());
+    ChangedFileSubject changedFile = assertThatCollection(changedFilesSet).onlyElement();
+    changedFile.hasNewPath().value().isEqualTo(Paths.get(newPath));
+    changedFile.hasOldPath().value().isEqualTo(Paths.get(oldPath));
+    changedFile.isRename();
+    changedFile.isNoDeletion();
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void getFromDiffCacheForInitialChangeThatAddedAFile() throws Exception {
+    String path = "/foo/bar/baz.txt";
+    RevCommit commit =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getCommit();
+    assertThat(commit.getParents()).isEmpty();
+
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class, () -> changedFiles.getFromDiffCache(project, commit));
+    assertThat(exception).hasMessageThat().isEqualTo("diff cache doesn't support initial commits");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.mergeCommitStrategy", value = "ALL_CHANGED_FILES")
+  public void getFromFileDiffCacheForMergeChange_allChangedFiles() throws Exception {
+    testGetFromFileDiffCacheForMergeChange(MergeCommitStrategy.ALL_CHANGED_FILES);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.mergeCommitStrategy",
+      value = "FILES_WITH_CONFLICT_RESOLUTION")
+  public void getFromFileDiffCacheForMergeChange_filesWithConflictResolution() throws Exception {
+    testGetFromFileDiffCacheForMergeChange(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
+  }
+
+  private void testGetFromFileDiffCacheForMergeChange(MergeCommitStrategy mergeCommitStrategy)
+      throws Exception {
+    setAsRootCodeOwners(admin);
+
+    String file1 = "foo/a.txt";
+    String file2 = "bar/b.txt";
+
+    // Create a base change.
+    Change.Id baseChange =
+        changeOperations.newChange().branch("master").file(file1).content("base content").create();
+    approveAndSubmit(baseChange);
+
+    // Create another branch
+    String branchName = "foo";
+    BranchInput branchInput = new BranchInput();
+    branchInput.ref = branchName;
+    branchInput.revision = projectOperations.project(project).getHead("master").name();
+    gApi.projects().name(project.get()).branch(branchInput.ref).create(branchInput);
+
+    // Create a change in master that touches file1.
+    Change.Id changeInMaster =
+        changeOperations
+            .newChange()
+            .branch("master")
+            .file(file1)
+            .content("master content")
+            .create();
+    approveAndSubmit(changeInMaster);
+
+    // Create a change in the other branch and that touches file1 and creates file2.
+    Change.Id changeInOtherBranch =
+        changeOperations
+            .newChange()
+            .branch(branchName)
+            .file(file1)
+            .content("other content")
+            .file(file2)
+            .content("content")
+            .create();
+    approveAndSubmit(changeInOtherBranch);
+
+    // Create a merge change with a conflict resolution for file1 and file2 with the same content as
+    // in the other branch (no conflict on file2).
+    Change.Id mergeChange =
+        changeOperations
+            .newChange()
+            .branch("master")
+            .mergeOfButBaseOnFirst()
+            .tipOfBranch("master")
+            .and()
+            .tipOfBranch(branchName)
+            .file(file1)
+            .content("merged content")
+            .file(file2)
+            .content("content")
+            .create();
+
+    ImmutableList<ChangedFile> changedFilesSet =
+        changedFiles.getFromDiffCache(
+            project,
+            getRevisionResource(Integer.toString(mergeChange.get())).getPatchSet().commitId());
+
+    if (MergeCommitStrategy.ALL_CHANGED_FILES.equals(mergeCommitStrategy)) {
+      assertThat(changedFilesSet).comparingElementsUsing(hasPath()).containsExactly(file1, file2);
+    } else if (MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION.equals(mergeCommitStrategy)) {
+      assertThat(changedFilesSet).comparingElementsUsing(hasPath()).containsExactly(file1);
+    } else {
+      fail("expected merge commit strategy: " + mergeCommitStrategy);
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.mergeCommitStrategy", value = "ALL_CHANGED_FILES")
+  public void
+      getFromFileDiffCacheForMergeChangeThatContainsADeletedFileAsConflictResolution_allChangedFiles()
+          throws Exception {
+    testGetFromFileDiffCacheForMergeChangeThatContainsADeletedFileAsConflictResolution();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.mergeCommitStrategy",
+      value = "FILES_WITH_CONFLICT_RESOLUTION")
+  public void
+      getFromFileDiffCacheForMergeChangeThatContainsADeletedFileAsConflictResolution_filesWithConflictResolution()
+          throws Exception {
+    testGetFromFileDiffCacheForMergeChangeThatContainsADeletedFileAsConflictResolution();
+  }
+
+  private void testGetFromFileDiffCacheForMergeChangeThatContainsADeletedFileAsConflictResolution()
+      throws Exception {
+    setAsRootCodeOwners(admin);
+
+    String file = "foo/a.txt";
+
+    // Create a base change.
+    Change.Id baseChange =
+        changeOperations.newChange().branch("master").file(file).content("base content").create();
+    approveAndSubmit(baseChange);
+
+    // Create another branch
+    String branchName = "foo";
+    BranchInput branchInput = new BranchInput();
+    branchInput.ref = branchName;
+    branchInput.revision = projectOperations.project(project).getHead("master").name();
+    gApi.projects().name(project.get()).branch(branchInput.ref).create(branchInput);
+
+    // Create a change in master that touches file1.
+    Change.Id changeInMaster =
+        changeOperations.newChange().branch("master").file(file).content("master content").create();
+    approveAndSubmit(changeInMaster);
+
+    // Create a change in the other branch and that deleted file1.
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "Change Deleting A File", file, "");
+    Result r = push.rm("refs/for/master");
+    r.assertOkStatus();
+    approveAndSubmit(r.getChange().getId());
+
+    // Create a merge change with resolving the conflict on file between the edit in master and the
+    // deletion in the other branch by deleting the file.
+    Change.Id mergeChange =
+        changeOperations
+            .newChange()
+            .branch("master")
+            .mergeOf()
+            .tipOfBranch("master")
+            .and()
+            .tipOfBranch(branchName)
+            .file(file)
+            .delete()
+            .create();
+
+    ImmutableList<ChangedFile> changedFilesSet =
+        changedFiles.getFromDiffCache(
+            project,
+            getRevisionResource(Integer.toString(mergeChange.get())).getPatchSet().commitId());
+    ImmutableSet<String> oldPaths =
+        changedFilesSet.stream()
+            .map(changedFile -> JgitPath.of(changedFile.oldPath().get()).get())
+            .collect(toImmutableSet());
+    assertThat(oldPaths).containsExactly(file);
+  }
+
+  @Test
+  public void getFromDiffCacheReturnsChangedFilesSortedByPath() throws Exception {
+    String file1 = "foo/bar.baz";
+    String file2 = "foo/baz.bar";
+    String file3 = "bar/foo.baz";
+    String file4 = "bar/baz.foo";
+    String file5 = "baz/foo.bar";
+    RevCommit commit =
+        createChange(
+                "Test Change",
+                ImmutableMap.of(
+                    file1,
+                    "file content",
+                    file2,
+                    "file content",
+                    file3,
+                    "file content",
+                    file4,
+                    "file content",
+                    file5,
+                    "file content"))
+            .getCommit();
+
+    ImmutableList<ChangedFile> changedFilesSet = changedFiles.getFromDiffCache(project, commit);
+    assertThat(changedFilesSet)
+        .comparingElementsUsing(hasPath())
+        .containsExactly(file4, file3, file5, file1, file2)
+        .inOrder();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.mergeCommitStrategy", value = "ALL_CHANGED_FILES")
+  public void getFromDiffCacheReturnsChangedFilesSortedByPath_mergeCommitAgainstFirstParent()
+      throws Exception {
+    testGetFromDiffCacheReturnsChangedFilesSortedByPathForMerge(
+        MergeCommitStrategy.ALL_CHANGED_FILES);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.mergeCommitStrategy",
+      value = "FILES_WITH_CONFLICT_RESOLUTION")
+  public void getFromDiffCacheReturnsChangedFilesSortedByPath_mergeCommitAgainstAutoMerge()
+      throws Exception {
+    testGetFromDiffCacheReturnsChangedFilesSortedByPathForMerge(
+        MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
+  }
+
+  private void testGetFromDiffCacheReturnsChangedFilesSortedByPathForMerge(
+      MergeCommitStrategy mergeCommitStrategy) throws Exception {
+    setAsRootCodeOwners(admin);
+
+    String file1 = "foo/bar.baz";
+    String file2 = "foo/baz.bar";
+    String file3 = "bar/foo.baz";
+    String file4 = "bar/baz.foo";
+    String file5 = "baz/foo.bar";
+
+    // Create a base change.
+    Change.Id baseChange =
+        changeOperations
+            .newChange()
+            .branch("master")
+            .file(file1)
+            .content("base content")
+            .file(file3)
+            .content("base content")
+            .file(file5)
+            .content("base content")
+            .create();
+    approveAndSubmit(baseChange);
+
+    // Create another branch
+    String branchName = "foo";
+    BranchInput branchInput = new BranchInput();
+    branchInput.ref = branchName;
+    branchInput.revision = projectOperations.project(project).getHead("master").name();
+    gApi.projects().name(project.get()).branch(branchInput.ref).create(branchInput);
+
+    // Create a change in master that touches file1, file3 and file5
+    Change.Id changeInMaster =
+        changeOperations
+            .newChange()
+            .branch("master")
+            .file(file1)
+            .content("master content")
+            .file(file3)
+            .content("master content")
+            .file(file5)
+            .content("master content")
+            .create();
+    approveAndSubmit(changeInMaster);
+
+    // Create a change in the other branch and that touches file1, file3, file5 and creates file2,
+    // file4.
+    Change.Id changeInOtherBranch =
+        changeOperations
+            .newChange()
+            .branch(branchName)
+            .file(file1)
+            .content("other content")
+            .file(file2)
+            .content("content")
+            .file(file3)
+            .content("other content")
+            .file(file4)
+            .content("content")
+            .file(file5)
+            .content("other content")
+            .create();
+    approveAndSubmit(changeInOtherBranch);
+
+    // Create a merge change with a conflict resolution for file1 and file2 with the same content as
+    // in the other branch (no conflict on file2).
+    Change.Id mergeChange =
+        changeOperations
+            .newChange()
+            .branch("master")
+            .mergeOfButBaseOnFirst()
+            .tipOfBranch("master")
+            .and()
+            .tipOfBranch(branchName)
+            .file(file1)
+            .content("merged content")
+            .file(file2)
+            .content("content")
+            .file(file3)
+            .content("merged content")
+            .file(file4)
+            .content("content")
+            .file(file5)
+            .content("merged content")
+            .create();
+
+    ImmutableList<ChangedFile> changedFilesSet =
+        changedFiles.getFromDiffCache(
+            project,
+            getRevisionResource(Integer.toString(mergeChange.get())).getPatchSet().commitId());
+
+    if (MergeCommitStrategy.ALL_CHANGED_FILES.equals(mergeCommitStrategy)) {
+      assertThat(changedFilesSet)
+          .comparingElementsUsing(hasPath())
+          .containsExactly(file4, file3, file5, file1, file2)
+          .inOrder();
+    } else if (MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION.equals(mergeCommitStrategy)) {
+      assertThat(changedFilesSet)
+          .comparingElementsUsing(hasPath())
+          .containsExactly(file3, file5, file1);
+    } else {
+      fail("expected merge commit strategy: " + mergeCommitStrategy);
+    }
+  }
+
   private void approveAndSubmit(Change.Id changeId) throws Exception {
     approve(Integer.toString(changeId.get()));
     gApi.changes().id(changeId.get()).current().submit();
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckForAccountTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckForAccountTest.java
index e392e47..5693c57 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckForAccountTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckForAccountTest.java
@@ -16,17 +16,21 @@
 
 import static com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject.assertThatStream;
 
+import com.google.common.collect.ImmutableMap;
+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.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.entities.PatchSet;
 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.common.CodeOwnerStatus;
 import com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.truth.ListSubject;
 import com.google.inject.Inject;
 import java.nio.file.Path;
 import java.nio.file.Paths;
@@ -34,7 +38,10 @@
 import org.junit.Before;
 import org.junit.Test;
 
-/** Tests for {@link CodeOwnerApprovalCheck#getFileStatusesForAccount(ChangeNotes, Account.Id)}. */
+/**
+ * Tests for {@link CodeOwnerApprovalCheck#getFileStatusesForAccount(ChangeNotes, PatchSet,
+ * Account.Id)}.
+ */
 public class CodeOwnerApprovalCheckForAccountTest extends AbstractCodeOwnersTest {
   @Inject private ChangeNotes.Factory changeNotesFactory;
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -51,17 +58,15 @@
 
   @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();
+    ChangeNotes changeNotes = getChangeNotes(changeId);
 
     // Verify that the file would not be approved by the user.
     Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatusesForAccount(getChangeNotes(changeId), user.id());
+        codeOwnerApprovalCheck.getFileStatusesForAccount(
+            changeNotes, changeNotes.getCurrentPatchSet(), user.id());
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
         assertThatStream(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
@@ -89,6 +94,7 @@
     Path path = Paths.get("/foo/bar.baz");
     String changeId =
         createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+    ChangeNotes changeNotes = getChangeNotes(changeId);
 
     // Add a Code-Review+1 (= code owner approval) from the code owner.
     requestScopeOperations.setApiUser(codeOwner.id());
@@ -96,7 +102,8 @@
 
     // Verify that the file would not be approved by the user.
     Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatusesForAccount(getChangeNotes(changeId), user.id());
+        codeOwnerApprovalCheck.getFileStatusesForAccount(
+            changeNotes, changeNotes.getCurrentPatchSet(), user.id());
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
         assertThatStream(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
@@ -120,10 +127,12 @@
     Path path = Paths.get("/foo/bar.baz");
     String changeId =
         createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+    ChangeNotes changeNotes = getChangeNotes(changeId);
 
     // Verify that the file would be approved by the user.
     Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatusesForAccount(getChangeNotes(changeId), user.id());
+        codeOwnerApprovalCheck.getFileStatusesForAccount(
+            changeNotes, changeNotes.getCurrentPatchSet(), user.id());
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
         assertThatStream(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
@@ -135,17 +144,86 @@
   }
 
   @Test
-  public void notApprovedByUser_bootstrapping() throws Exception {
-    // since no code owner config exists we are entering the bootstrapping code path in
-    // CodeOwnerApprovalCheck
+  public void approvedByUser_forPatchSets() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .create();
 
+    Path path1 = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path1).get(), "file content")
+            .getChangeId();
+
+    // amend change and add another file
+    Path path2 = Paths.get("/foo/baz.bar");
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "subject",
+            ImmutableMap.of(
+                JgitPath.of(path1).get(), "file content", JgitPath.of(path2).get(), "file content"),
+            changeId);
+    push.to("refs/for/master").assertOkStatus();
+
+    ChangeNotes changeNotes = getChangeNotes(changeId);
+
+    // Verify that the file in patch set 1 would be approved by the user.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatusesForAccount(
+            changeNotes,
+            changeNotes.getPatchSets().get(PatchSet.id(changeNotes.getChangeId(), 1)),
+            user.id());
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path1);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+
+    // Verify that both files in patch set 2 would be approved by the user.
+    fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatusesForAccount(
+            changeNotes,
+            changeNotes.getPatchSets().get(PatchSet.id(changeNotes.getChangeId(), 2)),
+            user.id());
+    ListSubject<FileCodeOwnerStatusSubject, FileCodeOwnerStatus> fileCodeOwnerStatusListSubject =
+        assertThatStream(fileCodeOwnerStatuses);
+    fileCodeOwnerStatusListSubject.hasSize(2);
+    fileCodeOwnerStatusSubject = fileCodeOwnerStatusListSubject.element(0);
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path1);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+    fileCodeOwnerStatusSubject = fileCodeOwnerStatusListSubject.element(1);
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path2);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "PROJECT_OWNERS")
+  public void notApprovedByUser_projectOwnersAreFallbackCodeOwner() throws Exception {
     Path path = Paths.get("/foo/bar.baz");
     String changeId =
         createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+    ChangeNotes changeNotes = getChangeNotes(changeId);
 
     // 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());
+        codeOwnerApprovalCheck.getFileStatusesForAccount(
+            changeNotes, changeNotes.getCurrentPatchSet(), user.id());
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
         assertThatStream(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
@@ -157,18 +235,18 @@
   }
 
   @Test
-  public void approvedByProjectOwner_bootstrapping() throws Exception {
-    // since no code owner config exists we are entering the bootstrapping code path in
-    // CodeOwnerApprovalCheck
-
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "PROJECT_OWNERS")
+  public void approvedByProjectOwner_projectOwnersAreFallbackCodeOwner() throws Exception {
     Path path = Paths.get("/foo/bar.baz");
     String changeId =
         createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+    ChangeNotes changeNotes = getChangeNotes(changeId);
 
     // 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());
+        codeOwnerApprovalCheck.getFileStatusesForAccount(
+            changeNotes, changeNotes.getCurrentPatchSet(), admin.id());
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
         assertThatStream(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
@@ -182,17 +260,15 @@
   @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();
+    ChangeNotes changeNotes = getChangeNotes(changeId);
 
     // 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());
+        codeOwnerApprovalCheck.getFileStatusesForAccount(
+            changeNotes, changeNotes.getCurrentPatchSet(), user.id());
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
         assertThatStream(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
@@ -221,11 +297,13 @@
     Path path = Paths.get("/foo/bar.baz");
     String changeId =
         createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+    ChangeNotes changeNotes = getChangeNotes(changeId);
 
     // 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());
+        codeOwnerApprovalCheck.getFileStatusesForAccount(
+            changeNotes, changeNotes.getCurrentPatchSet(), user.id());
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
         assertThatStream(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
@@ -236,29 +314,6 @@
         .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 d5cafb9..98268cb 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java
@@ -14,16 +14,16 @@
 
 package com.google.gerrit.plugins.codeowners.backend;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
-import static com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject.assertThat;
-import static com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject.assertThatStream;
+import static com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject.assertThatCollection;
 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.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -31,22 +31,22 @@
 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.PatchSet;
+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.ChangeInfo;
 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;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperations;
-import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
 import com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 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.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -56,7 +56,7 @@
  * <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
+ * com.google.gerrit.entities.PatchSet, com.google.gerrit.entities.Account.Id)} is covered by {@link
  * CodeOwnerApprovalCheckForAccountTest}.
  */
 public class CodeOwnerApprovalCheckTest extends AbstractCodeOwnersTest {
@@ -79,16 +79,14 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () -> codeOwnerApprovalCheck.getFileStatuses(/* changeNotes= */ null));
+            () ->
+                codeOwnerApprovalCheck.getFileStatusesAsSet(
+                    /* changeNotes= */ null, /* start= */ 0, /* limit= */ 0));
     assertThat(npe).hasMessageThat().isEqualTo("changeNotes");
   }
 
   @Test
   public void getStatusForFileAddition_insufficientReviewers() throws Exception {
-    // create arbitrary code owner config to avoid entering the bootstrapping code path in
-    // CodeOwnerApprovalCheck
-    createArbitraryCodeOwnerConfigFile();
-
     TestAccount user2 = accountCreator.user2();
 
     Path path = Paths.get("/foo/bar.baz");
@@ -102,27 +100,14 @@
     requestScopeOperations.setApiUser(user2.id());
     recommend(changeId);
 
-    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);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
   public void getStatusForFileModification_insufficientReviewers() throws Exception {
-    // create arbitrary code owner config to avoid entering the bootstrapping code path in
-    // CodeOwnerApprovalCheck
-    createArbitraryCodeOwnerConfigFile();
-
     TestAccount user2 = accountCreator.user2();
 
     Path path = Paths.get("/foo/bar.baz");
@@ -138,27 +123,14 @@
     requestScopeOperations.setApiUser(user2.id());
     recommend(changeId);
 
-    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);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.modification(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
   public void getStatusForFileDeletion_insufficientReviewers() throws Exception {
-    // create arbitrary code owner config to avoid entering the bootstrapping code path in
-    // CodeOwnerApprovalCheck
-    createArbitraryCodeOwnerConfigFile();
-
     TestAccount user2 = accountCreator.user2();
 
     Path path = Paths.get("/foo/bar.baz");
@@ -171,27 +143,27 @@
     requestScopeOperations.setApiUser(user2.id());
     recommend(changeId);
 
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasOldPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasOldPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isDeletion();
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.deletion(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
   public void getStatusForFileRename_insufficientReviewers() throws Exception {
-    // create arbitrary code owner config to avoid entering the bootstrapping code path in
-    // CodeOwnerApprovalCheck
-    createArbitraryCodeOwnerConfigFile();
+    testGetStatusForFileRename_insufficientReviewers(/* useDiffCache= */ false);
+  }
 
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
+  public void getStatusForFileRename_insufficientReviewers_useDiffCache() throws Exception {
+    testGetStatusForFileRename_insufficientReviewers(/* useDiffCache= */ true);
+  }
+
+  private void testGetStatusForFileRename_insufficientReviewers(boolean useDiffCache)
+      throws Exception {
     TestAccount user2 = accountCreator.user2();
 
     Path oldPath = Paths.get("/foo/old.bar");
@@ -205,24 +177,21 @@
     requestScopeOperations.setApiUser(user2.id());
     recommend(changeId);
 
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(newPath);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().value().hasPathThat().isEqualTo(oldPath);
-    fileCodeOwnerStatusSubject
-        .hasOldPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-    fileCodeOwnerStatusSubject.hasChangedFile().isRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    if (useDiffCache) {
+      assertThatCollection(fileCodeOwnerStatuses)
+          .containsExactly(
+              FileCodeOwnerStatus.rename(
+                  oldPath,
+                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
+                  newPath,
+                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    } else {
+      assertThatCollection(fileCodeOwnerStatuses)
+          .containsExactly(
+              FileCodeOwnerStatus.deletion(oldPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    }
   }
 
   @Test
@@ -242,19 +211,9 @@
     requestScopeOperations.setApiUser(user2.id());
     recommend(changeId);
 
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.PENDING);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.PENDING));
   }
 
   @Test
@@ -276,19 +235,9 @@
     requestScopeOperations.setApiUser(user2.id());
     recommend(changeId);
 
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.PENDING);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.modification(path, CodeOwnerStatus.PENDING));
   }
 
   @Test
@@ -307,23 +256,25 @@
     requestScopeOperations.setApiUser(user2.id());
     recommend(changeId);
 
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasOldPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasOldPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.PENDING);
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isDeletion();
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.deletion(path, CodeOwnerStatus.PENDING));
   }
 
   @Test
   public void getStatusForFileRename_pendingOldPath() throws Exception {
+    testGetStatusForFileRename_pendingOldPath(/* useDiffCache= */ false);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
+  public void getStatusForFileRename_pendingOldPath_useDiffCache() throws Exception {
+    testGetStatusForFileRename_pendingOldPath(/* useDiffCache= */ true);
+  }
+
+  private void testGetStatusForFileRename_pendingOldPath(boolean useDiffCache) throws Exception {
     TestAccount user2 = accountCreator.user2();
 
     setAsCodeOwners("/foo/bar/", user);
@@ -339,28 +290,37 @@
     requestScopeOperations.setApiUser(user2.id());
     recommend(changeId);
 
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(newPath);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().value().hasPathThat().isEqualTo(oldPath);
-    fileCodeOwnerStatusSubject
-        .hasOldPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.PENDING);
-    fileCodeOwnerStatusSubject.hasChangedFile().isRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    if (useDiffCache) {
+      assertThatCollection(fileCodeOwnerStatuses)
+          .containsExactly(
+              FileCodeOwnerStatus.rename(
+                  oldPath,
+                  CodeOwnerStatus.PENDING,
+                  newPath,
+                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    } else {
+      assertThatCollection(fileCodeOwnerStatuses)
+          .containsExactly(
+              FileCodeOwnerStatus.deletion(oldPath, CodeOwnerStatus.PENDING),
+              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    }
   }
 
   @Test
   public void getStatusForFileRename_pendingNewPath() throws Exception {
+    testGetStatusForFileRename_pendingNewPath(/* useDiffCache= */ false);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
+  public void getStatusForFileRename_pendingNewPath_useDiffCache() throws Exception {
+    testGetStatusForFileRename_pendingNewPath(/* useDiffCache= */ true);
+  }
+
+  private void testGetStatusForFileRename_pendingNewPath(boolean useDiffCache) throws Exception {
     TestAccount user2 = accountCreator.user2();
 
     setAsCodeOwners("/foo/baz/", user);
@@ -376,24 +336,21 @@
     requestScopeOperations.setApiUser(user2.id());
     recommend(changeId);
 
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(newPath);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.PENDING);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().value().hasPathThat().isEqualTo(oldPath);
-    fileCodeOwnerStatusSubject
-        .hasOldPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-    fileCodeOwnerStatusSubject.hasChangedFile().isRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    if (useDiffCache) {
+      assertThatCollection(fileCodeOwnerStatuses)
+          .containsExactly(
+              FileCodeOwnerStatus.rename(
+                  oldPath,
+                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
+                  newPath,
+                  CodeOwnerStatus.PENDING));
+    } else {
+      assertThatCollection(fileCodeOwnerStatuses)
+          .containsExactly(
+              FileCodeOwnerStatus.deletion(oldPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.PENDING));
+    }
   }
 
   @Test
@@ -408,19 +365,9 @@
     requestScopeOperations.setApiUser(user.id());
     recommend(changeId);
 
-    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);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
   }
 
   @Test
@@ -437,19 +384,9 @@
     requestScopeOperations.setApiUser(user.id());
     recommend(changeId);
 
-    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);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.modification(path, CodeOwnerStatus.APPROVED));
   }
 
   @Test
@@ -463,23 +400,25 @@
     requestScopeOperations.setApiUser(user.id());
     recommend(changeId);
 
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasOldPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasOldPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.APPROVED);
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isDeletion();
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.deletion(path, CodeOwnerStatus.APPROVED));
   }
 
   @Test
   public void getStatusForFileRename_approvedOldPath() throws Exception {
+    testGetStatusForFileRename_approvedOldPath(/* useDiffCache= */ false);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
+  public void getStatusForFileRename_approvedOldPath_useDiffCache() throws Exception {
+    testGetStatusForFileRename_approvedOldPath(/* useDiffCache= */ true);
+  }
+
+  private void testGetStatusForFileRename_approvedOldPath(boolean useDiffCache) throws Exception {
     setAsCodeOwners("/foo/bar/", user);
 
     Path oldPath = Paths.get("/foo/bar/abc.txt");
@@ -491,28 +430,37 @@
     requestScopeOperations.setApiUser(user.id());
     recommend(changeId);
 
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(newPath);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().value().hasPathThat().isEqualTo(oldPath);
-    fileCodeOwnerStatusSubject
-        .hasOldPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.APPROVED);
-    fileCodeOwnerStatusSubject.hasChangedFile().isRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    if (useDiffCache) {
+      assertThatCollection(fileCodeOwnerStatuses)
+          .containsExactly(
+              FileCodeOwnerStatus.rename(
+                  oldPath,
+                  CodeOwnerStatus.APPROVED,
+                  newPath,
+                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    } else {
+      assertThatCollection(fileCodeOwnerStatuses)
+          .containsExactly(
+              FileCodeOwnerStatus.deletion(oldPath, CodeOwnerStatus.APPROVED),
+              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    }
   }
 
   @Test
   public void getStatusForFileRename_approvedNewPath() throws Exception {
+    testGetStatusForFileRename_approvedNewPath(/* useDiffCache= */ false);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
+  public void getStatusForFileRename_approvedNewPath_useDiffCache() throws Exception {
+    testGetStatusForFileRename_approvedNewPath(/* useDiffCache= */ true);
+  }
+
+  private void testGetStatusForFileRename_approvedNewPath(boolean useDiffCache) throws Exception {
     setAsCodeOwners("/foo/baz/", user);
 
     Path oldPath = Paths.get("/foo/bar/abc.txt");
@@ -524,245 +472,394 @@
     requestScopeOperations.setApiUser(user.id());
     recommend(changeId);
 
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(newPath);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.APPROVED);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().value().hasPathThat().isEqualTo(oldPath);
-    fileCodeOwnerStatusSubject
-        .hasOldPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-    fileCodeOwnerStatusSubject.hasChangedFile().isRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    if (useDiffCache) {
+      assertThatCollection(fileCodeOwnerStatuses)
+          .containsExactly(
+              FileCodeOwnerStatus.rename(
+                  oldPath,
+                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
+                  newPath,
+                  CodeOwnerStatus.APPROVED));
+    } else {
+      assertThatCollection(fileCodeOwnerStatuses)
+          .containsExactly(
+              FileCodeOwnerStatus.deletion(oldPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.APPROVED));
+    }
   }
 
   @Test
-  public void getStatusForFileAddition_noImplicitApprovalByPatchSetUploader() throws Exception {
-    testImplicitApprovalByPatchSetUploaderOnGetStatusForFileAddition(
-        /* implicitApprovalsEnabled= */ false);
+  public void getStatusForFileAddition_noImplicitApproval() throws Exception {
+    testImplicitApprovalOnGetStatusForFileAddition(
+        /* implicitApprovalsEnabled= */ false, /* uploaderMatchesChangeOwner= */ true);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
-  public void getStatusForFileAddition_withImplicitApprovalByPatchSetUploader() throws Exception {
-    testImplicitApprovalByPatchSetUploaderOnGetStatusForFileAddition(
-        /* implicitApprovalsEnabled= */ true);
+  public void getStatusForFileAddition_noImplicitApproval_uploaderDoesntMatchChangeOwner()
+      throws Exception {
+    testImplicitApprovalOnGetStatusForFileAddition(
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ false);
   }
 
-  private void testImplicitApprovalByPatchSetUploaderOnGetStatusForFileAddition(
-      boolean implicitApprovalsEnabled) throws Exception {
-    setAsRootCodeOwners(user);
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void getStatusForFileAddition_withImplicitApproval() throws Exception {
+    testImplicitApprovalOnGetStatusForFileAddition(
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ true);
+  }
+
+  private void testImplicitApprovalOnGetStatusForFileAddition(
+      boolean implicitApprovalsEnabled, boolean uploaderMatchesChangeOwner) throws Exception {
+    TestAccount changeOwner = admin;
+    TestAccount otherCodeOwner =
+        accountCreator.create(
+            "code_owner", "code.owner@example.com", "Code Owner", /* displayName= */ null);
+
+    setAsRootCodeOwners(changeOwner, otherCodeOwner);
 
     Path path = Paths.get("/foo/bar.baz");
     String changeId =
         createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
-    amendChange(user, changeId);
 
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(
-            implicitApprovalsEnabled
-                ? CodeOwnerStatus.APPROVED
-                : CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+    if (uploaderMatchesChangeOwner) {
+      amendChange(changeOwner, changeId);
+    } else {
+      amendChange(otherCodeOwner, changeId);
+    }
+
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                implicitApprovalsEnabled && uploaderMatchesChangeOwner
+                    ? CodeOwnerStatus.APPROVED
+                    : CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
-  public void getStatusForFileModification_noImplicitApprovalByPatchSetUploader() throws Exception {
-    testImplicitApprovalByPatchSetUploaderOnGetStatusForFileModification(
-        /* implicitApprovalsEnabled= */ false);
+  public void getStatusForFileModification_noImplicitApproval() throws Exception {
+    testImplicitApprovalOnGetStatusForFileModification(
+        /* implicitApprovalsEnabled= */ false, /* uploaderMatchesChangeOwner= */ true);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
-  public void getStatusForFileModification_withImplicitApprovalByPatchSetUploader()
+  public void getStatusForFileModification_noImplicitApproval_uploaderDoesntMatchChangeOwner()
       throws Exception {
-    testImplicitApprovalByPatchSetUploaderOnGetStatusForFileModification(
-        /* implicitApprovalsEnabled= */ true);
+    testImplicitApprovalOnGetStatusForFileModification(
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ false);
   }
 
-  private void testImplicitApprovalByPatchSetUploaderOnGetStatusForFileModification(
-      boolean implicitApprovalsEnabled) throws Exception {
-    setAsRootCodeOwners(user);
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void getStatusForFileModification_withImplicitApproval() throws Exception {
+    testImplicitApprovalOnGetStatusForFileModification(
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ true);
+  }
+
+  private void testImplicitApprovalOnGetStatusForFileModification(
+      boolean implicitApprovalsEnabled, boolean uploaderMatchesChangeOwner) throws Exception {
+    TestAccount changeOwner = admin;
+    TestAccount otherCodeOwner =
+        accountCreator.create(
+            "code_owner", "code.owner@example.com", "Code Owner", /* displayName= */ null);
+
+    setAsRootCodeOwners(changeOwner, otherCodeOwner);
 
     Path path = Paths.get("/foo/bar.baz");
-    createChange("Test Change", JgitPath.of(path).get(), "file content").getChangeId();
+    createChange("Test Change", JgitPath.of(path).get(), "file content");
     String changeId =
         createChange("Change Modifying A File", JgitPath.of(path).get(), "new file content")
             .getChangeId();
-    amendChange(user, changeId);
 
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(
-            implicitApprovalsEnabled
-                ? CodeOwnerStatus.APPROVED
-                : CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+    if (uploaderMatchesChangeOwner) {
+      amendChange(changeOwner, changeId);
+    } else {
+      amendChange(otherCodeOwner, changeId);
+    }
+
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.modification(
+                path,
+                implicitApprovalsEnabled && uploaderMatchesChangeOwner
+                    ? CodeOwnerStatus.APPROVED
+                    : CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
-  public void getStatusForFileDeletion_noImplicitApprovalByPatchSetUploader() throws Exception {
-    testImplicitApprovalByPatchSetUploaderOnGetStatusForFileDeletion(
-        /* implicitApprovalsEnabled= */ false);
+  public void getStatusForFileDeletion_noImplicitApproval() throws Exception {
+    testImplicitApprovalOnGetStatusForFileDeletion(
+        /* implicitApprovalsEnabled= */ false, /* uploaderMatchesChangeOwner= */ true);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
-  public void getStatusForFileDeletion_withImplicitApprovalByPatchSetUploader() throws Exception {
-    testImplicitApprovalByPatchSetUploaderOnGetStatusForFileDeletion(
-        /* implicitApprovalsEnabled= */ true);
+  public void getStatusForFileDeletion_noImplicitApproval_uploaderDoesntMatchChangeOwner()
+      throws Exception {
+    testImplicitApprovalOnGetStatusForFileDeletion(
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ false);
   }
 
-  private void testImplicitApprovalByPatchSetUploaderOnGetStatusForFileDeletion(
-      boolean implicitApprovalsEnabled) throws Exception {
-    setAsRootCodeOwners(user);
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void getStatusForFileDeletion_withImplicitApproval() throws Exception {
+    testImplicitApprovalOnGetStatusForFileDeletion(
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ true);
+  }
+
+  private void testImplicitApprovalOnGetStatusForFileDeletion(
+      boolean implicitApprovalsEnabled, boolean uploaderMatchesChangeOwner) throws Exception {
+    TestAccount changeOwner = admin;
+    TestAccount otherCodeOwner =
+        accountCreator.create(
+            "code_owner", "code.owner@example.com", "Code Owner", /* displayName= */ null);
+
+    setAsRootCodeOwners(changeOwner, otherCodeOwner);
 
     Path path = Paths.get("/foo/bar.baz");
     String changeId = createChangeWithFileDeletion(path);
-    amendChange(user, changeId);
 
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasOldPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasOldPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(
-            implicitApprovalsEnabled
-                ? CodeOwnerStatus.APPROVED
-                : CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isDeletion();
+    if (uploaderMatchesChangeOwner) {
+      amendChange(changeOwner, changeId);
+    } else {
+      amendChange(otherCodeOwner, changeId);
+    }
+
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.deletion(
+                path,
+                implicitApprovalsEnabled && uploaderMatchesChangeOwner
+                    ? CodeOwnerStatus.APPROVED
+                    : CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
-  public void getStatusForFileRename_noImplicitApprovalByPatchSetUploaderOnOldPath()
-      throws Exception {
-    testImplicitApprovalByPatchSetUploaderOnStatusForFileRenameOnOldPath(
-        /* implicitApprovalsEnabled= */ false);
+  public void getStatusForFileRename_noImplicitApprovalOnOldPath() throws Exception {
+    testImplicitApprovalOnGetStatusForFileRenameOnOldPath(
+        /* implicitApprovalsEnabled= */ false,
+        /* uploaderMatchesChangeOwner= */ true,
+        /* useDiffCache= */ false);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
+  public void getStatusForFileRename_noImplicitApprovalOnOldPath_useDiffCache() throws Exception {
+    testImplicitApprovalOnGetStatusForFileRenameOnOldPath(
+        /* implicitApprovalsEnabled= */ false,
+        /* uploaderMatchesChangeOwner= */ true,
+        /* useDiffCache= */ true);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
-  public void getStatusForFileRename_withImplicitApprovalByPatchSetUploaderOnOldPath()
+  public void getStatusForFileRename_noImplicitApprovalOnOldPath_uploaderDoesntMatchChangeOwner()
       throws Exception {
-    testImplicitApprovalByPatchSetUploaderOnStatusForFileRenameOnOldPath(
-        /* implicitApprovalsEnabled= */ true);
+    testImplicitApprovalOnGetStatusForFileRenameOnOldPath(
+        /* implicitApprovalsEnabled= */ true,
+        /* uploaderMatchesChangeOwner= */ false,
+        /* useDiffCache= */ false);
   }
 
-  private void testImplicitApprovalByPatchSetUploaderOnStatusForFileRenameOnOldPath(
-      boolean implicitApprovalsEnabled) throws Exception {
-    setAsCodeOwners("/foo/bar/", user);
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
+  public void
+      getStatusForFileRename_noImplicitApprovalOnOldPath_uploaderDoesntMatchChangeOwner_useDiffCache()
+          throws Exception {
+    testImplicitApprovalOnGetStatusForFileRenameOnOldPath(
+        /* implicitApprovalsEnabled= */ true,
+        /* uploaderMatchesChangeOwner= */ false,
+        /* useDiffCache= */ true);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void getStatusForFileRename_withImplicitApprovalOnOldPath() throws Exception {
+    testImplicitApprovalOnGetStatusForFileRenameOnOldPath(
+        /* implicitApprovalsEnabled= */ true,
+        /* uploaderMatchesChangeOwner= */ true,
+        /* useDiffCache= */ false);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
+  public void getStatusForFileRename_withImplicitApprovalOnOldPath_useDiffCache() throws Exception {
+    testImplicitApprovalOnGetStatusForFileRenameOnOldPath(
+        /* implicitApprovalsEnabled= */ true,
+        /* uploaderMatchesChangeOwner= */ true,
+        /* useDiffCache= */ true);
+  }
+
+  private void testImplicitApprovalOnGetStatusForFileRenameOnOldPath(
+      boolean implicitApprovalsEnabled, boolean uploaderMatchesChangeOwner, boolean useDiffCache)
+      throws Exception {
+    TestAccount changeOwner = admin;
+    TestAccount otherCodeOwner =
+        accountCreator.create(
+            "code_owner", "code.owner@example.com", "Code Owner", /* displayName= */ null);
+
+    setAsCodeOwners("/foo/bar/", changeOwner, otherCodeOwner);
 
     Path oldPath = Paths.get("/foo/bar/abc.txt");
     Path newPath = Paths.get("/foo/baz/abc.txt");
     String changeId = createChangeWithFileRename(oldPath, newPath);
-    amendChange(user, changeId);
 
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(newPath);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().value().hasPathThat().isEqualTo(oldPath);
-    fileCodeOwnerStatusSubject
-        .hasOldPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(
-            implicitApprovalsEnabled
-                ? CodeOwnerStatus.APPROVED
-                : CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-    fileCodeOwnerStatusSubject.hasChangedFile().isRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+    if (uploaderMatchesChangeOwner) {
+      amendChange(changeOwner, changeId);
+    } else {
+      amendChange(otherCodeOwner, changeId);
+    }
+
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    if (useDiffCache) {
+      assertThatCollection(fileCodeOwnerStatuses)
+          .containsExactly(
+              FileCodeOwnerStatus.rename(
+                  oldPath,
+                  implicitApprovalsEnabled && uploaderMatchesChangeOwner
+                      ? CodeOwnerStatus.APPROVED
+                      : CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
+                  newPath,
+                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    } else {
+      assertThatCollection(fileCodeOwnerStatuses)
+          .containsExactly(
+              FileCodeOwnerStatus.deletion(
+                  oldPath,
+                  implicitApprovalsEnabled && uploaderMatchesChangeOwner
+                      ? CodeOwnerStatus.APPROVED
+                      : CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+              FileCodeOwnerStatus.addition(newPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    }
   }
 
   @Test
-  public void getStatusForFileRename_noImplicitApprovalByPatchSetUploaderOnNewPath()
+  public void testImplicitApprovalOnGetStatusForFileRenameOnNewPath() throws Exception {
+    testImplicitApprovalOnGetStatusForFileRenameOnNewPath(
+        /* implicitApprovalsEnabled= */ false,
+        /* uploaderMatchesChangeOwner= */ true,
+        /* useDiffCache= */ false);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
+  public void testImplicitApprovalOnGetStatusForFileRenameOnNewPath_useDiffCache()
       throws Exception {
-    testImplicitApprovalByPatchSetUploaderOnStatusForFileRenameOnNewPath(
-        /* implicitApprovalsEnabled= */ false);
+    testImplicitApprovalOnGetStatusForFileRenameOnNewPath(
+        /* implicitApprovalsEnabled= */ false,
+        /* uploaderMatchesChangeOwner= */ true,
+        /* useDiffCache= */ true);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
-  public void getStatusForFileRename_withImplicitApprovalByPatchSetUploaderOnNewPath()
+  public void getStatusForFileRename_noImplicitApprovalOnNewPath_uploaderDoesntMatchChangeOwner()
       throws Exception {
-    testImplicitApprovalByPatchSetUploaderOnStatusForFileRenameOnNewPath(
-        /* implicitApprovalsEnabled= */ true);
+    testImplicitApprovalOnGetStatusForFileRenameOnNewPath(
+        /* implicitApprovalsEnabled= */ true,
+        /* uploaderMatchesChangeOwner= */ false,
+        /* useDiffCache= */ false);
   }
 
-  private void testImplicitApprovalByPatchSetUploaderOnStatusForFileRenameOnNewPath(
-      boolean implicitApprovalsEnabled) throws Exception {
-    setAsCodeOwners("/foo/baz/", user);
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
+  public void
+      getStatusForFileRename_noImplicitApprovalOnNewPath_uploaderDoesntMatchChangeOwner_useDiffCache()
+          throws Exception {
+    testImplicitApprovalOnGetStatusForFileRenameOnNewPath(
+        /* implicitApprovalsEnabled= */ true,
+        /* uploaderMatchesChangeOwner= */ false,
+        /* useDiffCache= */ true);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void getStatusForFileRename_withImplicitApprovalOnNewPath() throws Exception {
+    testImplicitApprovalOnGetStatusForFileRenameOnNewPath(
+        /* implicitApprovalsEnabled= */ true,
+        /* uploaderMatchesChangeOwner= */ true,
+        /* useDiffCache= */ false);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = CodeOwnersExperimentFeaturesConstants.USE_DIFF_CACHE)
+  public void getStatusForFileRename_withImplicitApprovalOnNewPath_useDiffCache() throws Exception {
+    testImplicitApprovalOnGetStatusForFileRenameOnNewPath(
+        /* implicitApprovalsEnabled= */ true,
+        /* uploaderMatchesChangeOwner= */ true,
+        /* useDiffCache= */ true);
+  }
+
+  private void testImplicitApprovalOnGetStatusForFileRenameOnNewPath(
+      boolean implicitApprovalsEnabled, boolean uploaderMatchesChangeOwner, boolean useDiffCache)
+      throws Exception {
+    TestAccount changeOwner = admin;
+    TestAccount otherCodeOwner =
+        accountCreator.create(
+            "code_owner", "code.owner@example.com", "Code Owner", /* displayName= */ null);
+
+    setAsCodeOwners("/foo/baz/", changeOwner, otherCodeOwner);
 
     Path oldPath = Paths.get("/foo/bar/abc.txt");
     Path newPath = Paths.get("/foo/baz/abc.txt");
     String changeId = createChangeWithFileRename(oldPath, newPath);
-    amendChange(user, changeId);
 
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(newPath);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(
-            implicitApprovalsEnabled
-                ? CodeOwnerStatus.APPROVED
-                : CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().value().hasPathThat().isEqualTo(oldPath);
-    fileCodeOwnerStatusSubject
-        .hasOldPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-    fileCodeOwnerStatusSubject.hasChangedFile().isRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+    if (uploaderMatchesChangeOwner) {
+      amendChange(changeOwner, changeId);
+    } else {
+      amendChange(otherCodeOwner, changeId);
+    }
+
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    if (useDiffCache) {
+      assertThatCollection(fileCodeOwnerStatuses)
+          .containsExactly(
+              FileCodeOwnerStatus.rename(
+                  oldPath,
+                  CodeOwnerStatus.INSUFFICIENT_REVIEWERS,
+                  newPath,
+                  implicitApprovalsEnabled && uploaderMatchesChangeOwner
+                      ? CodeOwnerStatus.APPROVED
+                      : CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    } else {
+      assertThatCollection(fileCodeOwnerStatuses)
+          .containsExactly(
+              FileCodeOwnerStatus.deletion(oldPath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+              FileCodeOwnerStatus.addition(
+                  newPath,
+                  implicitApprovalsEnabled && uploaderMatchesChangeOwner
+                      ? CodeOwnerStatus.APPROVED
+                      : CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+    }
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
-  public void getStatusForFileAddition_noImplicitlyApprovalByChangeOwner() throws Exception {
+  public void
+      getStatusForFileAddition_noImplicitlyApprovalByPatchSetUploaderThatDoesntOwnTheChange()
+          throws Exception {
     setAsRootCodeOwners(admin);
 
     Path path = Paths.get("/foo/bar.baz");
@@ -770,48 +867,10 @@
         createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
     amendChange(user, changeId);
 
-    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);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
-  public void getStatusForFileAddition_noImplicitlyApprovalByPreviousPatchSetUploader()
-      throws Exception {
-    TestAccount user2 = accountCreator.user2();
-
-    setAsRootCodeOwners(user);
-
-    Path path = Paths.get("/foo/bar.baz");
-    String changeId =
-        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
-    amendChange(user, changeId);
-    amendChange(user2, changeId);
-
-    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);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
@@ -833,45 +892,47 @@
 
     // 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);
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add an approval by a user that is a code owner only through the global code ownership.
     approve(changeId);
 
     // 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);
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
   }
 
   @Test
   public void everyoneIsCodeOwner_noImplicitApproval() throws Exception {
-    testImplicitlyApprovedWhenEveryoneIsCodeOwner(/* implicitApprovalsEnabled= */ false);
+    testImplicitlyApprovedWhenEveryoneIsCodeOwner(
+        /* implicitApprovalsEnabled= */ false, /* uploaderMatchesChangeOwner= */ true);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void everyoneIsCodeOwner_noImplicitApproval_uploaderDoesntMatchChangeOwner()
+      throws Exception {
+    testImplicitlyApprovedWhenEveryoneIsCodeOwner(
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ false);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
   public void everyoneIsCodeOwner_withImplicitApproval() throws Exception {
-    testImplicitlyApprovedWhenEveryoneIsCodeOwner(/* implicitApprovalsEnabled= */ true);
+    testImplicitlyApprovedWhenEveryoneIsCodeOwner(
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ true);
   }
 
-  private void testImplicitlyApprovedWhenEveryoneIsCodeOwner(boolean implicitApprovalsEnabled)
-      throws Exception {
+  private void testImplicitlyApprovedWhenEveryoneIsCodeOwner(
+      boolean implicitApprovalsEnabled, boolean uploaderMatchesChangeOwner) throws Exception {
+    TestAccount changeOwner = admin;
+    TestAccount otherCodeOwner = user;
+
     // Create a code owner config file that makes everyone a code owner.
     codeOwnerConfigOperations
         .newCodeOwnerConfig()
@@ -886,19 +947,20 @@
     String changeId =
         createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
 
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(
-            implicitApprovalsEnabled
-                ? CodeOwnerStatus.APPROVED
-                : CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    if (uploaderMatchesChangeOwner) {
+      amendChange(changeOwner, changeId);
+    } else {
+      amendChange(otherCodeOwner, changeId);
+    }
+
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                implicitApprovalsEnabled && uploaderMatchesChangeOwner
+                    ? CodeOwnerStatus.APPROVED
+                    : CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
@@ -919,53 +981,27 @@
 
     // Verify that the status of the file is INSUFFICIENT_REVIEWERS (since there is no implicit
     // approval by default).
-    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);
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add a user as reviewer that is a code owner.
     gApi.changes().id(changeId).addReviewer(user.email());
 
     // Check that the status of the file 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);
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.PENDING));
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "bot@example.com")
   public void approvedByGlobalCodeOwner() throws Exception {
-    testApprovedByGlobalCodeOwner(/* bootstrappingMode= */ false);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "bot@example.com")
-  public void approvedByGlobalCodeOwner_bootstrappingMode() throws Exception {
-    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);
 
-    if (!bootstrappingMode) {
-      // Create a code owner config file so that we are not in the bootstrapping mode.
-      createArbitraryCodeOwnerConfigFile();
-    }
-
     // Create a change as a user that is not a code owner.
     Path path = Paths.get("/foo/bar.baz");
     String changeId =
@@ -973,16 +1009,10 @@
             .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);
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Let the bot approve the change.
     projectOperations
@@ -995,98 +1025,76 @@
 
     // 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);
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
   }
 
   @Test
-  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "bot@example.com")
+  @GerritConfig(
+      name = "plugin.code-owners.globalCodeOwner",
+      values = {"bot@example.com", "otherBot@example.com"})
   public void globalCodeOwner_noImplicitApproval() throws Exception {
     testImplicitlyApprovedByGlobalCodeOwner(
-        /* implicitApprovalsEnabled= */ false, /* bootstrappingMode= */ false);
+        /* implicitApprovalsEnabled= */ false, /* uploaderMatchesChangeOwner= */ true);
   }
 
   @Test
-  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "bot@example.com")
+  @GerritConfig(
+      name = "plugin.code-owners.globalCodeOwner",
+      values = {"bot@example.com", "otherBot@example.com"})
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void globalCodeOwner_noImplicitApproval_uploaderDoesntMatchChangeOwner() throws Exception {
+    testImplicitlyApprovedByGlobalCodeOwner(
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ false);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.globalCodeOwner",
+      values = {"bot@example.com", "otherBot@example.com"})
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
   public void globalCodeOwner_withImplicitApproval() throws Exception {
     testImplicitlyApprovedByGlobalCodeOwner(
-        /* 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);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "bot@example.com")
-  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
-  public void globalCodeOwner_withImplicitApproval_bootstrappingMode() throws Exception {
-    testImplicitlyApprovedByGlobalCodeOwner(
-        /* implicitApprovalsEnabled= */ true, /* bootstrappingMode= */ true);
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ true);
   }
 
   private void testImplicitlyApprovedByGlobalCodeOwner(
-      boolean implicitApprovalsEnabled, boolean bootstrappingMode) throws Exception {
+      boolean implicitApprovalsEnabled, boolean uploaderMatchesChangeOwner) throws Exception {
     TestAccount bot =
         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.
-      createArbitraryCodeOwnerConfigFile();
-    }
+    TestAccount otherBot =
+        accountCreator.create(
+            "other_bot", "otherBot@example.com", "Other Bot", /* displayName= */ null);
 
     Path path = Paths.get("/foo/bar.baz");
     String changeId =
         createChange(bot, "Change Adding A File", JgitPath.of(path).get(), "file content")
             .getChangeId();
 
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(
-            implicitApprovalsEnabled
-                ? CodeOwnerStatus.APPROVED
-                : CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    if (uploaderMatchesChangeOwner) {
+      amendChange(bot, changeId);
+    } else {
+      amendChange(otherBot, changeId);
+    }
+
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                implicitApprovalsEnabled && uploaderMatchesChangeOwner
+                    ? CodeOwnerStatus.APPROVED
+                    : CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "bot@example.com")
   public void globalCodeOwnerAsReviewer() throws Exception {
-    testGlobalCodeOwnerAsReviewer(/* bootstrappingMode= */ false);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "bot@example.com")
-  public void globalCodeOwnerAsReviewer_bootstrappingMode() throws Exception {
-    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);
 
-    if (!bootstrappingMode) {
-      // Create a code owner config file so that we are not in the bootstrapping mode.
-      createArbitraryCodeOwnerConfigFile();
-    }
-
     // Create a change as a user that is not a code owner.
     Path path = Paths.get("/foo/bar.baz");
     String changeId =
@@ -1094,30 +1102,19 @@
             .getChangeId();
 
     // Verify that the status of the file is INSUFFICIENT_REVIEWERS.
-    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);
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add the bot approve as reviewer.
     gApi.changes().id(changeId).addReviewer(bot.email());
 
     // Check that the status of the file is PENDING 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.PENDING);
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.PENDING));
 
     // Let the bot approve the change.
     projectOperations
@@ -1130,35 +1127,14 @@
 
     // 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);
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
   public void approvedByAnyoneWhenEveryoneIsGlobalCodeOwner() throws Exception {
-    testApprovedByAnyoneWhenEveryoneIsGlobalCodeOwner(/* bootstrappingMode= */ false);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
-  public void approvedByAnyoneWhenEveryoneIsGlobalCodeOwner_bootstrappingMode() throws Exception {
-    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.
-      createArbitraryCodeOwnerConfigFile();
-    }
-
     // Create a change.
     Path path = Paths.get("/foo/bar.baz");
     String changeId =
@@ -1167,37 +1143,35 @@
 
     // Verify that the file is not approved yet (the change owner is a global 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);
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add an approval by a user that is a code owner only through the global code ownership.
     approve(changeId);
 
     // 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);
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
   public void everyoneIsGlobalCodeOwner_noImplicitApproval() throws Exception {
     testImplicitlyApprovedByGlobalCodeOwnerWhenEveryoneIsGlobalCodeOwner(
-        /* implicitApprovalsEnabled= */ false, /* bootstrappingMode= */ false);
+        /* implicitApprovalsEnabled= */ false, /* uploaderMatchesChangeOwner= */ true);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void everyoneIsGlobalCodeOwner_noImplicitApproval_uploaderDoesntMatchChangeOwner()
+      throws Exception {
+    testImplicitlyApprovedByGlobalCodeOwnerWhenEveryoneIsGlobalCodeOwner(
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ false);
   }
 
   @Test
@@ -1205,70 +1179,37 @@
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
   public void everyoneIsGlobalCodeOwner_withImplicitApproval() throws Exception {
     testImplicitlyApprovedByGlobalCodeOwnerWhenEveryoneIsGlobalCodeOwner(
-        /* implicitApprovalsEnabled= */ true, /* bootstrappingMode= */ false);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
-  public void everyoneIsGlobalCodeOwner_noImplicitApproval_bootstrappingMode() throws Exception {
-    testImplicitlyApprovedByGlobalCodeOwnerWhenEveryoneIsGlobalCodeOwner(
-        /* implicitApprovalsEnabled= */ false, /* bootstrappingMode= */ true);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
-  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
-  public void everyoneIsGlobalCodeOwner_withImplicitApproval_bootstrappingMode() throws Exception {
-    testImplicitlyApprovedByGlobalCodeOwnerWhenEveryoneIsGlobalCodeOwner(
-        /* implicitApprovalsEnabled= */ true, /* bootstrappingMode= */ true);
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ true);
   }
 
   private void testImplicitlyApprovedByGlobalCodeOwnerWhenEveryoneIsGlobalCodeOwner(
-      boolean implicitApprovalsEnabled, boolean bootstrappingMode) throws Exception {
-    if (!bootstrappingMode) {
-      // Create a code owner config file so that we are not in the bootstrapping mode.
-      createArbitraryCodeOwnerConfigFile();
-    }
-
+      boolean implicitApprovalsEnabled, boolean uploaderMatchesChangeOwner) throws Exception {
+    TestAccount changeOwner = admin;
+    TestAccount otherCodeOwner = user;
     // Create a change as a user that is a code owner only through the global code ownership.
     Path path = Paths.get("/foo/bar.baz");
     String changeId =
         createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
 
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(
-            implicitApprovalsEnabled
-                ? CodeOwnerStatus.APPROVED
-                : CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    if (uploaderMatchesChangeOwner) {
+      amendChange(changeOwner, changeId);
+    } else {
+      amendChange(otherCodeOwner, changeId);
+    }
+
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                implicitApprovalsEnabled && uploaderMatchesChangeOwner
+                    ? CodeOwnerStatus.APPROVED
+                    : CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
   public void anyReviewerWhenEveryoneIsGlobalCodeOwner() throws Exception {
-    testAnyReviewerWhenEveryoneIsGlobalCodeOwner(/* bootstrappingMode= */ false);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
-  public void anyReviewerWhenEveryoneIsGlobalCodeOwner_bootstrappingMode() throws Exception {
-    testAnyReviewerWhenEveryoneIsGlobalCodeOwner(/* bootstrappingMode= */ true);
-  }
-
-  private void testAnyReviewerWhenEveryoneIsGlobalCodeOwner(boolean bootstrappingMode)
-      throws Exception {
-    if (!bootstrappingMode) {
-      // 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.
     Path path = Paths.get("/foo/bar.baz");
     String changeId =
@@ -1276,29 +1217,18 @@
 
     // Verify that the status of the file is INSUFFICIENT_REVIEWERS (since there is no implicit
     // approval by default).
-    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);
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add a user as reviewer that is a code owner only through the global code ownership.
     gApi.changes().id(changeId).addReviewer(user.email());
 
     // Check that the status of the file 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);
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.PENDING));
   }
 
   @Test
@@ -1323,29 +1253,21 @@
     // Add code owner from a lower level as reviewer.
     gApi.changes().id(changeId).addReviewer(user2.email());
 
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     // The expected status is APPROVED since 'user' which is configured as code owner on the root
     // level approved the change.
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.APPROVED);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
   public void getStatus_overrideApprovesAllFiles() throws Exception {
-    // create arbitrary code owner config to avoid entering the bootstrapping code path in
-    // CodeOwnerApprovalCheck
-    createArbitraryCodeOwnerConfigFile();
-
     createOwnersOverrideLabel();
 
+    String path1 = "foo/baz.config";
+    String path2 = "bar/baz.config";
+
     // Create a change.
     String changeId =
         pushFactory
@@ -1354,37 +1276,27 @@
                 testRepo,
                 "Test Change",
                 ImmutableMap.of(
-                    "foo/baz.config", "content",
-                    "bar/baz.config", "other content"))
+                    path1, "content",
+                    path2, "other content"))
             .to("refs/for/master")
             .getChangeId();
 
     // Without Owners-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);
-    }
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+            FileCodeOwnerStatus.addition(path2, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add an override approval.
     gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
 
     // With Owners-Override approval the expected status is APPROVED.
-    for (FileCodeOwnerStatus fileCodeOwnerStatus :
-        codeOwnerApprovalCheck
-            .getFileStatuses(getChangeNotes(changeId))
-            .collect(toImmutableList())) {
-      assertThat(fileCodeOwnerStatus)
-          .hasNewPathStatus()
-          .value()
-          .hasStatusThat()
-          .isEqualTo(CodeOwnerStatus.APPROVED);
-    }
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.APPROVED),
+            FileCodeOwnerStatus.addition(path2, CodeOwnerStatus.APPROVED));
   }
 
   @Test
@@ -1392,13 +1304,12 @@
       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");
 
+    String path1 = "foo/baz.config";
+    String path2 = "bar/baz.config";
+
     // Create a change.
     String changeId =
         pushFactory
@@ -1407,67 +1318,47 @@
                 testRepo,
                 "Test Change",
                 ImmutableMap.of(
-                    "foo/baz.config", "content",
-                    "bar/baz.config", "other content"))
+                    path1, "content",
+                    path2, "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);
-    }
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+            FileCodeOwnerStatus.addition(path2, 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);
-    }
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.APPROVED),
+            FileCodeOwnerStatus.addition(path2, 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);
-    }
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.INSUFFICIENT_REVIEWERS),
+            FileCodeOwnerStatus.addition(path2, 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);
-    }
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path1, CodeOwnerStatus.APPROVED),
+            FileCodeOwnerStatus.addition(path2, CodeOwnerStatus.APPROVED));
   }
 
   @Test
@@ -1516,10 +1407,6 @@
   @Test
   @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
   public void isSubmittableIfOverrideIsPresent() throws Exception {
-    // create arbitrary code owner config to avoid entering the bootstrapping code path in
-    // CodeOwnerApprovalCheck
-    createArbitraryCodeOwnerConfigFile();
-
     createOwnersOverrideLabel();
 
     // Create a change.
@@ -1550,10 +1437,6 @@
       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");
 
@@ -1593,316 +1476,6 @@
   }
 
   @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);
-
-    // Create change with a user that is not a project owner.
-    Path path = Paths.get("/foo/bar.baz");
-    String changeId =
-        createChange(user, "Change Adding A File", JgitPath.of(path).get(), "file content")
-            .getChangeId();
-
-    // Add a reviewer that is not a project owner.
-    gApi.changes().id(changeId).addReviewer(user2.email());
-
-    // Add a Code-Review+1 (= code owner approval) from a user that is not a project owner.
-    requestScopeOperations.setApiUser(user3.id());
-    recommend(changeId);
-
-    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);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
-  }
-
-  @Test
-  public void bootstrappingGetStatus_pending() throws Exception {
-    // since no code owner config exists we are entering the bootstrapping code path in
-    // CodeOwnerApprovalCheck
-
-    TestAccount user2 = accountCreator.user2();
-
-    // Create change with a user that is not a project owner.
-    Path path = Paths.get("/foo/bar.baz");
-    String changeId =
-        createChange(user, "Change Adding A File", JgitPath.of(path).get(), "file content")
-            .getChangeId();
-
-    // Add a reviewer that is a project owner.
-    gApi.changes().id(changeId).addReviewer(admin.email());
-
-    // Add a Code-Review+1 (= code owner approval) from a user that is not a project owner.
-    requestScopeOperations.setApiUser(user2.id());
-    recommend(changeId);
-
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.PENDING);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
-  }
-
-  @Test
-  public void bootstrappingGetStatus_approved() throws Exception {
-    // since no code owner config exists we are entering the bootstrapping code path in
-    // CodeOwnerApprovalCheck
-
-    // Create change with a user that is not a project owner.
-    Path path = Paths.get("/foo/bar.baz");
-    String changeId =
-        createChange(user, "Change Adding A File", JgitPath.of(path).get(), "file content")
-            .getChangeId();
-
-    // Add a Code-Review+1 from a project owner (by default this counts as code owner approval).
-    requestScopeOperations.setApiUser(admin.id());
-    recommend(changeId);
-
-    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);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
-  }
-
-  @Test
-  public void bootstrappingGetStatus_noImplicitApprovalByPatchSetUploader() throws Exception {
-    testImplicitApprovalByPatchSetUploaderOnBootstrappingGetStatus(
-        /* implicitApprovalsEnabled= */ false);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
-  public void bootstrappingGetStatus_withImplicitApprovalByPatchSetUploader() throws Exception {
-    testImplicitApprovalByPatchSetUploaderOnBootstrappingGetStatus(
-        /* implicitApprovalsEnabled= */ true);
-  }
-
-  private void testImplicitApprovalByPatchSetUploaderOnBootstrappingGetStatus(
-      boolean implicitApprovalsEnabled) throws Exception {
-    // since no code owner config exists we are entering the bootstrapping code path in
-    // CodeOwnerApprovalCheck
-
-    // Create change with a user that is not a project owner.
-    Path path = Paths.get("/foo/bar.baz");
-    String changeId =
-        createChange(user, "Change Adding A File", JgitPath.of(path).get(), "file content")
-            .getChangeId();
-
-    // Amend change with a user that is a project owner.
-    amendChange(admin, changeId);
-
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(
-            implicitApprovalsEnabled
-                ? CodeOwnerStatus.APPROVED
-                : CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
-  public void bootstrappingGetStatus_noImplicitlyApprovalByChangeOwner() throws Exception {
-    // since no code owner config exists we are entering the bootstrapping code path in
-    // CodeOwnerApprovalCheck
-
-    // Create change with a user that is a project owner.
-    Path path = Paths.get("/foo/bar.baz");
-    String changeId =
-        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
-
-    // Amend change with a user that is not a project owner.
-    amendChange(user, changeId);
-
-    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);
-    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
-    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
-  public void bootstrappingGetStatus_overrideApprovesAllFiles() throws Exception {
-    // since no code owner config exists we are entering the bootstrapping code path in
-    // CodeOwnerApprovalCheck
-
-    createOwnersOverrideLabel();
-
-    // 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 Owners-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 Owners-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
-  @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));
@@ -1914,9 +1487,7 @@
     gApi.projects().name(project.get()).deleteBranches(input);
 
     ResourceConflictException exception =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId)));
+        assertThrows(ResourceConflictException.class, () -> getFileCodeOwnerStatuses(changeId));
     assertThat(exception).hasMessageThat().isEqualTo("destination branch not found");
   }
 
@@ -1934,16 +1505,10 @@
             .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);
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Let the 'user' approve the change.
     projectOperations
@@ -1956,14 +1521,9 @@
 
     // 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);
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
 
     // Change some other file ('user' who uploads the change is a code owner and hence owner
     // approvals are implicit for this change)
@@ -1981,14 +1541,60 @@
         .isEqualTo(user.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);
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
+  }
+
+  @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 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.
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, 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 = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.addition(path, 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 = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
   }
 
   @Test
@@ -2005,16 +1611,10 @@
             .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);
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Let 'user' approve the change (vote Code-Review+2)
     projectOperations
@@ -2027,14 +1627,9 @@
 
     // 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);
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
   }
 
   @Test
@@ -2067,16 +1662,10 @@
             .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);
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Let 'user2' override with Owners-Override+2
     requestScopeOperations.setApiUser(user2.id());
@@ -2084,74 +1673,9 @@
 
     // 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();
-
-    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");
-    String changeId =
-        createChange(user2, "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 the project owner approve the change.
-    requestScopeOperations.setApiUser(admin.id());
-    approve(changeId);
-
-    // Verify that the file is still approved yet (since we are not in bootstrapping mode, the
-    // project owner doesn't count as code owner).
-    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
-    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
-
-    // Let the code owner approve the change.
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
-        .update();
-    requestScopeOperations.setApiUser(user.id());
-    approve(changeId);
-
-    // 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);
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
   }
 
   @Test
@@ -2167,16 +1691,10 @@
             .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);
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Let the code owner approve the change.
     projectOperations
@@ -2189,49 +1707,62 @@
 
     // 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);
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
   }
 
   @Test
   public void defaultCodeOwner_noImplicitApproval() throws Exception {
-    testImplicitlyApprovedByDefaultCodeOwner(/* implicitApprovalsEnabled= */ false);
+    testImplicitlyApprovedByDefaultCodeOwner(
+        /* implicitApprovalsEnabled= */ false, /* uploaderMatchesChangeOwner= */ true);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void defaultCodeOwner_noImplicitApproval_uploaderDoesntMatchChangeOwner()
+      throws Exception {
+    testImplicitlyApprovedByDefaultCodeOwner(
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ false);
   }
 
   @Test
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
   public void defaultCodeOwner_withImplicitApproval() throws Exception {
-    testImplicitlyApprovedByDefaultCodeOwner(/* implicitApprovalsEnabled= */ true);
+    testImplicitlyApprovedByDefaultCodeOwner(
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ true);
   }
 
-  private void testImplicitlyApprovedByDefaultCodeOwner(boolean implicitApprovalsEnabled)
-      throws Exception {
-    setAsDefaultCodeOwners(user);
+  private void testImplicitlyApprovedByDefaultCodeOwner(
+      boolean implicitApprovalsEnabled, boolean uploaderMatchesChangeOwner) throws Exception {
+    TestAccount changeOwner = admin;
+    TestAccount otherCodeOwner =
+        accountCreator.create(
+            "code_owner", "code.owner@example.com", "Code Owner", /* displayName= */ null);
+
+    setAsDefaultCodeOwners(changeOwner, otherCodeOwner);
 
     Path path = Paths.get("/foo/bar.baz");
     String changeId =
-        createChange(user, "Change Adding A File", JgitPath.of(path).get(), "file content")
-            .getChangeId();
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
 
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    if (uploaderMatchesChangeOwner) {
+      amendChange(changeOwner, changeId);
+    } else {
+      amendChange(otherCodeOwner, changeId);
+    }
+
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
-    fileCodeOwnerStatusSubject
-        .hasNewPathStatus()
-        .value()
-        .hasStatusThat()
-        .isEqualTo(
-            implicitApprovalsEnabled
-                ? CodeOwnerStatus.APPROVED
-                : CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(
+                path,
+                implicitApprovalsEnabled && uploaderMatchesChangeOwner
+                    ? CodeOwnerStatus.APPROVED
+                    : CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
   }
 
   @Test
@@ -2247,30 +1778,19 @@
             .getChangeId();
 
     // Verify that the status of the file is INSUFFICIENT_REVIEWERS.
-    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);
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
 
     // Add the default code owner as reviewer.
     gApi.changes().id(changeId).addReviewer(user.email());
 
     // Check that the status of the file is PENDING 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.PENDING);
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.PENDING));
 
     // Let the default code owner approve the change.
     projectOperations
@@ -2283,14 +1803,124 @@
 
     // 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);
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
+  }
+
+  @Test
+  public void pureRevertsAreNotExemptedByDefault() throws Exception {
+    setAsRootCodeOwners(admin);
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+    approve(changeId);
+    gApi.changes().id(changeId).current().submit();
+
+    // Revert the change
+    String changeIdOfRevert = gApi.changes().id(changeId).revert().get().changeId;
+
+    // Check that the file is not approved.
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        getFileCodeOwnerStatuses(changeIdOfRevert);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.deletion(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.exemptPureReverts", value = "true")
+  public void pureRevertsAreExemptedIfConfigured() throws Exception {
+    setAsRootCodeOwners(admin);
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+    approve(changeId);
+    gApi.changes().id(changeId).current().submit();
+
+    // Revert the change
+    String changeIdOfRevert = gApi.changes().id(changeId).revert().get().changeId;
+
+    // Check that the file is approved since it's a pure revert.
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        getFileCodeOwnerStatuses(changeIdOfRevert);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.deletion(path, CodeOwnerStatus.APPROVED));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.exemptPureReverts", value = "true")
+  public void nonPureRevertsAreNotExempted() throws Exception {
+    setAsRootCodeOwners(admin);
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+    approve(changeId);
+    gApi.changes().id(changeId).current().submit();
+
+    // Revert the change
+    ChangeInfo revertChange = gApi.changes().id(changeId).revert().get();
+
+    // Amend change to make it a non-pure revert change.
+    GitUtil.fetch(
+        testRepo,
+        RefNames.patchSetRef(PatchSet.id(Change.id(revertChange._number), 1)) + ":revert");
+    testRepo.reset("revert");
+
+    amendChange(
+        revertChange.changeId,
+        "refs/for/master",
+        admin,
+        testRepo,
+        "Revert change",
+        JgitPath.of(path).get(),
+        "other content");
+
+    // Check that the file is not approved.
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        getFileCodeOwnerStatuses(revertChange.changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.modification(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.exemptedUser", value = "exempted-user@example.com")
+  public void changeUploadedByExemptedUserIsApproved() throws Exception {
+    TestAccount exemptedUser =
+        accountCreator.create(
+            "exemptedUser", "exempted-user@example.com", "Exempted User", /* displayName= */ null);
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange(exemptedUser, "Change Adding A File", JgitPath.of(path).get(), "file content")
+            .getChangeId();
+
+    // Check that the file is approved since the uploader is exempted from requiring code owner
+    // approvals.
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
+
+    // Amend the change by another user, so that the other non-exempted user becomes the last
+    // uploader.
+    amendChange(user, changeId);
+
+    // Check that the file is no longer approved since the uploader is not exempted from requiring
+    // code owner approvals.
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+  }
+
+  private ImmutableSet<FileCodeOwnerStatus> getFileCodeOwnerStatuses(String changeId)
+      throws Exception {
+    return codeOwnerApprovalCheck.getFileStatusesAsSet(
+        getChangeNotes(changeId), /* start= */ 0, /* limit= */ 0);
   }
 
   private ChangeNotes getChangeNotes(String changeId) throws Exception {
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest.java
index d2d2c5b..08052d8 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest.java
@@ -14,29 +14,29 @@
 
 package com.google.gerrit.plugins.codeowners.backend;
 
-import static com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject.assertThatStream;
+import static com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject.assertThatCollection;
 
+import com.google.common.collect.ImmutableSet;
 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.backend.config.GeneralConfig;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
 import com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 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. */
+/** Tests for {@link CodeOwnerApprovalCheck} with ALL_USERS as fallback code owners. */
 public class CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest
     extends AbstractCodeOwnersTest {
   @Inject private ChangeNotes.Factory changeNotesFactory;
@@ -76,10 +76,9 @@
         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));
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -92,8 +91,8 @@
 
     // 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();
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -107,8 +106,8 @@
 
     // 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();
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -130,10 +129,9 @@
         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));
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -157,10 +155,9 @@
         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));
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -173,8 +170,8 @@
 
     // 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();
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -188,8 +185,8 @@
 
     // 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();
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -215,10 +212,9 @@
         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));
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -229,20 +225,15 @@
 
   @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));
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -254,8 +245,8 @@
     gApi.changes().id(changeId).addReviewer(user.email());
 
     // Verify that the status is pending now .
-    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -268,8 +259,8 @@
     recommend(changeId);
 
     // Verify that the status is approved now
-    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -281,98 +272,15 @@
   @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));
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(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();
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -396,10 +304,9 @@
         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));
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -412,8 +319,8 @@
 
     // 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();
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -427,8 +334,8 @@
 
     // 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();
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -453,10 +360,9 @@
         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));
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -481,10 +387,9 @@
         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));
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -497,8 +402,8 @@
 
     // 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();
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -512,8 +417,8 @@
 
     // 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();
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -539,10 +444,9 @@
         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));
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -572,10 +476,9 @@
         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));
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -588,8 +491,8 @@
 
     // 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();
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -603,8 +506,8 @@
 
     // 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();
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -636,10 +539,9 @@
         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));
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -648,6 +550,12 @@
         .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
   }
 
+  private ImmutableSet<FileCodeOwnerStatus> getFileCodeOwnerStatuses(String changeId)
+      throws Exception {
+    return codeOwnerApprovalCheck.getFileStatusesAsSet(
+        getChangeNotes(changeId), /* start= */ 0, /* limit= */ 0);
+  }
+
   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/CodeOwnerApprovalCheckWithProjectOwnersAsFallbackCodeOwnersTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithProjectOwnersAsFallbackCodeOwnersTest.java
new file mode 100644
index 0000000..4ab563c
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithProjectOwnersAsFallbackCodeOwnersTest.java
@@ -0,0 +1,687 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject.assertThat;
+import static com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject.assertThatCollection;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+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.request.RequestScopeOperations;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
+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 org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link CodeOwnerApprovalCheck} with PROJECT_OWNERS as fallback code owners. */
+public class CodeOwnerApprovalCheckWithProjectOwnersAsFallbackCodeOwnersTest
+    extends AbstractCodeOwnersTest {
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  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.setEnum(
+        "plugin",
+        "code-owners",
+        GeneralConfig.KEY_FALLBACK_CODE_OWNERS,
+        FallbackCodeOwners.PROJECT_OWNERS);
+    return cfg;
+  }
+
+  @Before
+  public void setUpCodeOwnersPlugin() throws Exception {
+    codeOwnerApprovalCheck = plugin.getSysInjector().getInstance(CodeOwnerApprovalCheck.class);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "bot@example.com")
+  public void approvedByGlobalCodeOwner() throws Exception {
+    // Create a bot user that is a global code owner.
+    TestAccount bot =
+        accountCreator.create("bot", "bot@example.com", "Bot", /* displayName= */ null);
+
+    // 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.
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Let the bot approve the change.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
+    requestScopeOperations.setApiUser(bot.id());
+    approve(changeId);
+
+    // Check that the file is approved now.
+    requestScopeOperations.setApiUser(admin.id());
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", values = "bot@example.com")
+  public void globalCodeOwner_noImplicitApproval() throws Exception {
+    testImplicitlyApprovedByGlobalCodeOwner(
+        /* implicitApprovalsEnabled= */ false, /* uploaderMatchesChangeOwner= */ true);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", values = "bot@example.com")
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void globalCodeOwner_noImplicitApproval_uploaderDoesntMatchChangeOwner() throws Exception {
+    testImplicitlyApprovedByGlobalCodeOwner(
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ false);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", values = "bot@example.com")
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void globalCodeOwner_withImplicitApproval() throws Exception {
+    testImplicitlyApprovedByGlobalCodeOwner(
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ true);
+  }
+
+  private void testImplicitlyApprovedByGlobalCodeOwner(
+      boolean implicitApprovalsEnabled, boolean uploaderMatchesChangeOwner) throws Exception {
+    TestAccount bot =
+        accountCreator.create("bot", "bot@example.com", "Bot", /* displayName= */ null);
+    TestAccount projectOwner = admin;
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange(bot, "Change Adding A File", JgitPath.of(path).get(), "file content")
+            .getChangeId();
+
+    if (uploaderMatchesChangeOwner) {
+      amendChange(bot, changeId);
+    } else {
+      amendChange(projectOwner, changeId);
+    }
+
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(
+            implicitApprovalsEnabled && uploaderMatchesChangeOwner
+                ? CodeOwnerStatus.APPROVED
+                : CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "bot@example.com")
+  public void globalCodeOwnerAsReviewer() throws Exception {
+    // Create a bot user that is a global code owner.
+    TestAccount bot =
+        accountCreator.create("bot", "bot@example.com", "Bot", /* displayName= */ null);
+
+    // 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 status of the file is INSUFFICIENT_REVIEWERS.
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add the bot approve as reviewer.
+    gApi.changes().id(changeId).addReviewer(bot.email());
+
+    // Check that the status of the file is PENDING now.
+    requestScopeOperations.setApiUser(admin.id());
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.PENDING);
+
+    // Let the bot approve the change.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
+    requestScopeOperations.setApiUser(bot.id());
+    approve(changeId);
+
+    // Check that the file is approved now.
+    requestScopeOperations.setApiUser(admin.id());
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
+  public void approvedByAnyoneWhenEveryoneIsGlobalCodeOwner() throws Exception {
+    // Create a change.
+    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 global code owner, but
+    // implicit approvals are disabled).
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add an approval by a user that is a code owner only through the global code ownership.
+    approve(changeId);
+
+    // Check that the file is approved now.
+    requestScopeOperations.setApiUser(admin.id());
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
+  public void everyoneIsGlobalCodeOwner_noImplicitApproval() throws Exception {
+    testImplicitlyApprovedByGlobalCodeOwnerWhenEveryoneIsGlobalCodeOwner(
+        /* implicitApprovalsEnabled= */ false, /* uploaderMatchesChangeOwner= */ true);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void everyoneIsGlobalCodeOwner_noImplicitApproval_uploaderDoesntMatchChangeOwner()
+      throws Exception {
+    testImplicitlyApprovedByGlobalCodeOwnerWhenEveryoneIsGlobalCodeOwner(
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ false);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void everyoneIsGlobalCodeOwner_withImplicitApproval() throws Exception {
+    testImplicitlyApprovedByGlobalCodeOwnerWhenEveryoneIsGlobalCodeOwner(
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ true);
+  }
+
+  private void testImplicitlyApprovedByGlobalCodeOwnerWhenEveryoneIsGlobalCodeOwner(
+      boolean implicitApprovalsEnabled, boolean uploaderMatchesChangeOwner) throws Exception {
+    TestAccount projectOwner = admin;
+    TestAccount otherProjectOwner = accountCreator.admin2();
+
+    // Create a change as a user that is a project code owner.
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    if (uploaderMatchesChangeOwner) {
+      amendChange(projectOwner, changeId);
+    } else {
+      amendChange(otherProjectOwner, changeId);
+    }
+
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(
+            implicitApprovalsEnabled && uploaderMatchesChangeOwner
+                ? CodeOwnerStatus.APPROVED
+                : CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
+  public void anyReviewerWhenEveryoneIsGlobalCodeOwner() throws Exception {
+    // Create a change as a user that is a code owner only through the global code ownership.
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the status of the file is INSUFFICIENT_REVIEWERS (since there is no implicit
+    // approval by default).
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a user as reviewer that is a code owner only through the global code ownership.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Check that the status of the file is PENDING now.
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.PENDING);
+  }
+
+  @Test
+  public void getStatus_insufficientReviewers() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+    TestAccount user3 =
+        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");
+    String changeId =
+        createChange(user, "Change Adding A File", JgitPath.of(path).get(), "file content")
+            .getChangeId();
+
+    // Add a reviewer that is not a project owner.
+    gApi.changes().id(changeId).addReviewer(user2.email());
+
+    // Add a Code-Review+1 (= code owner approval) from a user that is not a project owner.
+    requestScopeOperations.setApiUser(user3.id());
+    recommend(changeId);
+
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
+    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
+    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+  }
+
+  @Test
+  public void getStatus_pending() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    // Create change with a user that is not a project owner.
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange(user, "Change Adding A File", JgitPath.of(path).get(), "file content")
+            .getChangeId();
+
+    // Add a reviewer that is a project owner.
+    gApi.changes().id(changeId).addReviewer(admin.email());
+
+    // Add a Code-Review+1 (= code owner approval) from a user that is not a project owner.
+    requestScopeOperations.setApiUser(user2.id());
+    recommend(changeId);
+
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.PENDING);
+    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
+    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
+    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+  }
+
+  @Test
+  public void getStatus_approved() throws Exception {
+    // Create change with a user that is not a project owner.
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange(user, "Change Adding A File", JgitPath.of(path).get(), "file content")
+            .getChangeId();
+
+    // Add a Code-Review+1 from a project owner (by default this counts as code owner approval).
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(changeId);
+
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
+    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
+    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+  }
+
+  @Test
+  public void getStatus_noImplicitApproval() throws Exception {
+    testImplicitApprovalOnGetStatus(
+        /* implicitApprovalsEnabled= */ false, /* uploaderMatchesChangeOwner= */ true);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void getStatus_noImplicitApproval_uploaderDoesntMatchChangeOwner() throws Exception {
+    testImplicitApprovalOnGetStatus(
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ false);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void getStatus_withImplicitApproval() throws Exception {
+    testImplicitApprovalOnGetStatus(
+        /* implicitApprovalsEnabled= */ true, /* uploaderMatchesChangeOwner= */ true);
+  }
+
+  private void testImplicitApprovalOnGetStatus(
+      boolean implicitApprovalsEnabled, boolean uploaderMatchesChangeOwner) throws Exception {
+    TestAccount projectOwner = admin;
+
+    // Create change with a user that is not a project owner.
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    if (uploaderMatchesChangeOwner) {
+      amendChange(projectOwner, changeId);
+    } else {
+      amendChange(user, changeId);
+    }
+
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(
+            implicitApprovalsEnabled && uploaderMatchesChangeOwner
+                ? CodeOwnerStatus.APPROVED
+                : CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
+    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
+    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void getStatus_noImplicitlyApprovalByPatchSetUploaderThatDoesntOwnTheChange()
+      throws Exception {
+    TestAccount admin2 = accountCreator.admin2();
+
+    // Create change with a user that is a project owner.
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Amend change with a user that is another project owner.
+    amendChange(admin2, changeId);
+
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+    fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
+    fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
+    fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void getStatus_overrideApprovesAllFiles() throws Exception {
+    createOwnersOverrideLabel();
+
+    // 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 Owners-Override approval the expected status is INSUFFICIENT_REVIEWERS.
+    for (FileCodeOwnerStatus fileCodeOwnerStatus : getFileCodeOwnerStatuses(changeId)) {
+      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 Owners-Override approval the expected status is APPROVED.
+    for (FileCodeOwnerStatus fileCodeOwnerStatus : getFileCodeOwnerStatuses(changeId)) {
+      assertThat(fileCodeOwnerStatus)
+          .hasNewPathStatus()
+          .value()
+          .hasStatusThat()
+          .isEqualTo(CodeOwnerStatus.APPROVED);
+    }
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.overrideApproval",
+      values = {"Owners-Override+1", "Another-Override+1"})
+  public void getStatus_anyOverrideApprovesAllFiles() throws Exception {
+    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 : getFileCodeOwnerStatuses(changeId)) {
+      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 : getFileCodeOwnerStatuses(changeId)) {
+      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 : getFileCodeOwnerStatuses(changeId)) {
+      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 : getFileCodeOwnerStatuses(changeId)) {
+      assertThat(fileCodeOwnerStatus)
+          .hasNewPathStatus()
+          .value()
+          .hasStatusThat()
+          .isEqualTo(CodeOwnerStatus.APPROVED);
+    }
+  }
+
+  @Test
+  public void projectOwnersAreNotCodeOwnersIfDefaultCodeOwnerConfigExists() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    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");
+    String changeId =
+        createChange(user2, "Change Adding A File", JgitPath.of(path).get(), "file content")
+            .getChangeId();
+
+    // Verify that the file is not approved yet.
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+
+    // Let the project owner approve the change.
+    requestScopeOperations.setApiUser(admin.id());
+    approve(changeId);
+
+    // Verify that the file is not approved yet
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(
+            FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+
+    // Let the code owner approve the change.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    approve(changeId);
+
+    // Check that the file is approved now.
+    requestScopeOperations.setApiUser(admin.id());
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    assertThatCollection(fileCodeOwnerStatuses)
+        .containsExactly(FileCodeOwnerStatus.addition(path, CodeOwnerStatus.APPROVED));
+  }
+
+  private ImmutableSet<FileCodeOwnerStatus> getFileCodeOwnerStatuses(String changeId)
+      throws Exception {
+    return codeOwnerApprovalCheck.getFileStatusesAsSet(
+        getChangeNotes(changeId), /* start= */ 0, /* limit= */ 0);
+  }
+
+  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
index 9ba465a..f09a9e8 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithSelfApprovalsIgnoredTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithSelfApprovalsIgnoredTest.java
@@ -14,25 +14,25 @@
 
 package com.google.gerrit.plugins.codeowners.backend;
 
-import static com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject.assertThatStream;
+import static com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject.assertThatCollection;
 
+import com.google.common.collect.ImmutableSet;
 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.backend.config.OverrideApprovalConfig;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
 import com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 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;
@@ -83,10 +83,9 @@
             .getChangeId();
 
     // Verify that the file is not approved.
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -99,8 +98,8 @@
     recommend(changeId);
 
     // Verify that the file is not approved (since self approvals are ignored).
-    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
-    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -125,10 +124,9 @@
     amendChange(admin, changeId);
 
     // Verify that the file is not approved.
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -142,8 +140,8 @@
 
     // 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();
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -172,10 +170,9 @@
     amendChange(codeOwner, changeId);
 
     // Verify that the file is not approved.
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -188,8 +185,8 @@
 
     // 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();
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -203,8 +200,8 @@
 
     // 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();
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -215,7 +212,7 @@
 
   @Test
   @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
-  public void notImplicitlyApprovedByUploaderWhoIsChangeOwner() throws Exception {
+  public void notImplicitlyApproved() throws Exception {
     TestAccount codeOwner =
         accountCreator.create(
             "codeOwner", "codeOwner@example.com", "CodeOwner", /* displayName= */ null);
@@ -227,10 +224,9 @@
             .getChangeId();
 
     // Verify that the file is not approved.
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -260,10 +256,66 @@
     amendChange(codeOwner, changeId);
 
     // Verify that the file is not approved.
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "forced")
+  public void implicitlyApproved() 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 approved.
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "forced")
+  public void notImplicitlyApprovedByUploader_forcedImplicitApprovals() 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 approved.
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -274,10 +326,6 @@
 
   @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);
@@ -288,10 +336,9 @@
             .getChangeId();
 
     // Verify that the file is not approved.
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -305,8 +352,8 @@
 
     // 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();
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -317,10 +364,6 @@
 
   @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);
@@ -334,10 +377,9 @@
     amendChange(admin, changeId);
 
     // Verify that the file is not approved.
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -351,8 +393,8 @@
 
     // 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();
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -363,10 +405,6 @@
 
   @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);
@@ -380,10 +418,9 @@
     amendChange(admin, changeId);
 
     // Verify that the file is not approved.
-    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
-        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
     FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
-        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+        assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -395,8 +432,8 @@
     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();
+    fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+    fileCodeOwnerStatusSubject = assertThatCollection(fileCodeOwnerStatuses).onlyElement();
     fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
     fileCodeOwnerStatusSubject
         .hasNewPathStatus()
@@ -405,6 +442,12 @@
         .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
   }
 
+  private ImmutableSet<FileCodeOwnerStatus> getFileCodeOwnerStatuses(String changeId)
+      throws Exception {
+    return codeOwnerApprovalCheck.getFileStatusesAsSet(
+        getChangeNotes(changeId), /* start= */ 0, /* limit= */ 0);
+  }
+
   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/CodeOwnerBackendIdTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackendIdTest.java
new file mode 100644
index 0000000..3f5ee09
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerBackendIdTest.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
+import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend;
+import com.google.gerrit.server.IdentifiedUser;
+import java.nio.file.Path;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Test;
+
+/** Tests for {@link com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId}. */
+public class CodeOwnerBackendIdTest extends AbstractCodeOwnersTest {
+  @Test
+  public void getBackendIdForCodeOwnersBackendClass() throws Exception {
+    assertThat(CodeOwnerBackendId.getBackendId(FindOwnersBackend.class))
+        .isEqualTo(FindOwnersBackend.ID);
+    assertThat(CodeOwnerBackendId.getBackendId(ProtoBackend.class)).isEqualTo(ProtoBackend.ID);
+
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () -> CodeOwnerBackendId.getBackendId(TestCodeOwnerBackend.class));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format("unknown code owner backend: %s", TestCodeOwnerBackend.class.getName()));
+  }
+
+  private static class TestCodeOwnerBackend implements CodeOwnerBackend {
+    @Override
+    public Optional<CodeOwnerConfig> getCodeOwnerConfig(
+        CodeOwnerConfig.Key codeOwnerConfigKey,
+        @Nullable RevWalk revWalk,
+        @Nullable ObjectId revision) {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public Optional<CodeOwnerConfig> upsertCodeOwnerConfig(
+        CodeOwnerConfig.Key codeOwnerConfigKey,
+        CodeOwnerConfigUpdate codeOwnerConfigUpdate,
+        @Nullable IdentifiedUser currentUser) {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public boolean isCodeOwnerConfigFile(NameKey project, String fileName) {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public Path getFilePath(CodeOwnerConfig.Key codeOwnerConfigKey) {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public Optional<PathExpressionMatcher> getPathExpressionMatcher() {
+      return Optional.empty();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileTest.java
index 61bbdb7..e6b776d 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileTest.java
@@ -47,10 +47,13 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private MetaDataUpdate.Server metaDataUpdateServer;
 
+  private CodeOwnerConfigFile.Factory codeOwnerConfigFileFactory;
   private TestCodeOwnerConfigStorage testCodeOwnerConfigStorage;
 
   @Before
   public void setUpCodeOwnersPlugin() throws Exception {
+    codeOwnerConfigFileFactory =
+        plugin.getSysInjector().getInstance(CodeOwnerConfigFile.Factory.class);
     testCodeOwnerConfigStorage =
         plugin
             .getSysInjector()
@@ -534,7 +537,7 @@
   private CodeOwnerConfigFile loadCodeOwnerConfig(CodeOwnerConfig.Key codeOwnerConfigKey)
       throws IOException, ConfigInvalidException {
     try (Repository repository = repoManager.openRepository(codeOwnerConfigKey.project())) {
-      return CodeOwnerConfigFile.loadCurrent(
+      return codeOwnerConfigFileFactory.loadCurrent(
           CODE_OWNER_CONFIG_FILE_NAME, CODE_OWNER_CONFIG_PARSER, repository, codeOwnerConfigKey);
     }
   }
@@ -544,7 +547,7 @@
       throws IOException, ConfigInvalidException {
     try (Repository repository = repoManager.openRepository(codeOwnerConfigKey.project());
         RevWalk revWalk = new RevWalk(repository)) {
-      return CodeOwnerConfigFile.load(
+      return codeOwnerConfigFileFactory.load(
           CODE_OWNER_CONFIG_FILE_NAME,
           CODE_OWNER_CONFIG_PARSER,
           revWalk,
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScannerTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScannerTest.java
index 839c77b..ea8af79 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScannerTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScannerTest.java
@@ -18,16 +18,16 @@
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
-import static org.mockito.Mockito.verifyZeroInteractions;
 import static org.mockito.Mockito.when;
 
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.BranchNameKey;
-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.util.JgitPath;
 import com.google.inject.Inject;
 import java.nio.file.Path;
 import java.nio.file.Paths;
@@ -129,7 +129,7 @@
         codeOwnerConfigFileUpdateScanner.update(
             BranchNameKey.create(project, "master"), "Update code owner configs", updater);
     assertThat(commit).isEmpty();
-    verifyZeroInteractions(updater);
+    verifyNoInteractions(updater);
   }
 
   @Test
@@ -155,7 +155,7 @@
         codeOwnerConfigFileUpdateScanner.update(
             BranchNameKey.create(project, "master"), "Update code owner configs", updater);
     assertThat(commit).isEmpty();
-    verifyZeroInteractions(updater);
+    verifyNoInteractions(updater);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchyTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchyTest.java
index af08aa3..df5ad05 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchyTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchyTest.java
@@ -16,11 +16,12 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
-import static org.mockito.Mockito.verifyZeroInteractions;
 import static org.mockito.Mockito.when;
 
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -29,7 +30,9 @@
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperations;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.TestPathExpressions;
-import com.google.gerrit.plugins.codeowners.config.InvalidPluginConfigurationException;
+import com.google.gerrit.plugins.codeowners.backend.config.InvalidPluginConfigurationException;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.project.DeleteRef;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.nio.file.Paths;
@@ -57,6 +60,7 @@
   @Mock private Consumer<CodeOwnerConfig.Key> parentCodeOwnersIgnoredCallback;
 
   @Inject private ProjectOperations projectOperations;
+  @Inject private DeleteRef deleteRef;
 
   private CodeOwnerConfigOperations codeOwnerConfigOperations;
   private CodeOwnerConfigHierarchy codeOwnerConfigHierarchy;
@@ -165,7 +169,7 @@
   @Test
   public void visitorNotInvokedIfNoCodeOwnerConfigExists() throws Exception {
     visit("master", "/foo/bar/baz.md");
-    verifyZeroInteractions(visitor);
+    verifyNoInteractions(visitor);
   }
 
   @Test
@@ -181,7 +185,7 @@
         .create();
 
     visit(branch, "/foo/bar/baz.md");
-    verifyZeroInteractions(visitor);
+    verifyNoInteractions(visitor);
   }
 
   @Test
@@ -536,7 +540,7 @@
             .addCodeOwnerSet(
                 CodeOwnerSet.builder()
                     .setIgnoreGlobalAndParentCodeOwners()
-                    .addPathExpression(testPathExpressions.matchFileTypeInCurrentFolder("txt"))
+                    .addPathExpression(testPathExpressions.matchFileType("txt"))
                     .addCodeOwnerEmail(admin.email())
                     .build())
             .create();
@@ -558,7 +562,7 @@
         .visit(codeOwnerConfigOperations.codeOwnerConfig(rootCodeOwnerConfigKey).get());
     verifyNoMoreInteractions(visitor);
 
-    verifyZeroInteractions(parentCodeOwnersIgnoredCallback);
+    verifyNoInteractions(parentCodeOwnersIgnoredCallback);
   }
 
   @Test
@@ -579,23 +583,24 @@
   }
 
   @Test
-  public void visitorNotInvokedForCodeOwnerConfigInRefsMetaConfigIfItDoesntApply()
-      throws Exception {
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch(RefNames.REFS_CONFIG)
-        .folderPath("/")
-        .addCodeOwnerSet(
-            CodeOwnerSet.builder()
-                .addPathExpression(testPathExpressions.matchAllFilesInSubfolder("other"))
-                .addCodeOwnerEmail(admin.email())
-                .build())
-        .create();
+  public void visitorInvokedForCodeOwnerConfigInRefsMetaConfigIfItDoesntApply() throws Exception {
+    CodeOwnerConfig.Key metaCodeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch(RefNames.REFS_CONFIG)
+            .folderPath("/")
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .addPathExpression(testPathExpressions.matchAllFilesInSubfolder("other"))
+                    .addCodeOwnerEmail(admin.email())
+                    .build())
+            .create();
 
     when(visitor.visit(any(CodeOwnerConfig.class))).thenReturn(true);
     visit("master", "/foo/bar/baz.md");
-    verifyZeroInteractions(visitor);
+    verify(visitor).visit(codeOwnerConfigOperations.codeOwnerConfig(metaCodeOwnerConfigKey).get());
+    verifyNoMoreInteractions(visitor);
   }
 
   @Test
@@ -736,6 +741,23 @@
     verifyNoMoreInteractions(visitor);
   }
 
+  @Test
+  public void refsMetaConfigBranchIsMissing() throws Exception {
+    ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
+    deleteRef.deleteSingleRef(projectState, RefNames.REFS_CONFIG);
+
+    // Visit '/foo/bar/baz.md' in master. This would callback for these OWNERS files if they
+    // existed:
+    // 1. '/foo/bar/OWNERS' in master
+    // 2. '/foo/OWNERS' in master
+    // 3. '/OWNERS' in master
+    // 4. '/OWNERS' in refs/meta/config (this is where the default code owners are loaded from)
+    // This test is making sure that trying to load 4. doesn't fail if refs/meta/config doesn't
+    // exist.
+    visit("master", "/foo/bar/baz.md");
+    verifyNoInteractions(visitor);
+  }
+
   private void visit(String branchName, String path)
       throws InvalidPluginConfigurationException, IOException {
     BranchNameKey branchNameKey = BranchNameKey.create(project, branchName);
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScannerTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScannerTest.java
index df2e602..18fff7f 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScannerTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScannerTest.java
@@ -18,8 +18,8 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
-import static org.mockito.Mockito.verifyZeroInteractions;
 import static org.mockito.Mockito.when;
 
 import com.google.gerrit.acceptance.config.GerritConfig;
@@ -28,7 +28,6 @@
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperations;
 import java.nio.file.Paths;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -122,8 +121,8 @@
   @Test
   public void visitorNotInvokedIfNoCodeOwnerConfigFilesExists() throws Exception {
     visit();
-    verifyZeroInteractions(visitor);
-    verifyZeroInteractions(invalidCodeOwnerConfigCallback);
+    verifyNoInteractions(visitor);
+    verifyNoInteractions(invalidCodeOwnerConfigCallback);
   }
 
   @Test
@@ -146,8 +145,8 @@
     }
 
     visit();
-    verifyZeroInteractions(visitor);
-    verifyZeroInteractions(invalidCodeOwnerConfigCallback);
+    verifyNoInteractions(visitor);
+    verifyNoInteractions(invalidCodeOwnerConfigCallback);
   }
 
   @Test
@@ -155,11 +154,12 @@
     createNonParseableCodeOwnerConfig("/OWNERS");
 
     visit();
-    verifyZeroInteractions(visitor);
+    verifyNoInteractions(visitor);
 
     // Verify that we received the expected callbacks for the invalid code onwer config.
     Mockito.verify(invalidCodeOwnerConfigCallback)
-        .onInvalidCodeOwnerConfig(eq(Paths.get("/OWNERS")), any(ConfigInvalidException.class));
+        .onInvalidCodeOwnerConfig(
+            eq(Paths.get("/OWNERS")), any(InvalidCodeOwnerConfigException.class));
     verifyNoMoreInteractions(invalidCodeOwnerConfigCallback);
   }
 
@@ -189,7 +189,8 @@
 
     // Verify that we received the expected callbacks for the invalid code onwer config.
     Mockito.verify(invalidCodeOwnerConfigCallback)
-        .onInvalidCodeOwnerConfig(eq(Paths.get("/OWNERS")), any(ConfigInvalidException.class));
+        .onInvalidCodeOwnerConfig(
+            eq(Paths.get("/OWNERS")), any(InvalidCodeOwnerConfigException.class));
     verifyNoMoreInteractions(invalidCodeOwnerConfigCallback);
   }
 
@@ -280,7 +281,7 @@
         .visit(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).get());
     verifyNoMoreInteractions(visitor);
 
-    verifyZeroInteractions(invalidCodeOwnerConfigCallback);
+    verifyNoInteractions(invalidCodeOwnerConfigCallback);
   }
 
   @Test
@@ -303,7 +304,7 @@
         .visit(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).get());
     verifyNoMoreInteractions(visitor);
 
-    verifyZeroInteractions(invalidCodeOwnerConfigCallback);
+    verifyNoInteractions(invalidCodeOwnerConfigCallback);
   }
 
   @Test
@@ -322,8 +323,8 @@
         false);
 
     // Verify that we did not receive any callback.
-    verifyZeroInteractions(visitor);
-    verifyZeroInteractions(invalidCodeOwnerConfigCallback);
+    verifyNoInteractions(visitor);
+    verifyNoInteractions(invalidCodeOwnerConfigCallback);
   }
 
   @Test
@@ -387,7 +388,7 @@
         .visit(codeOwnerConfigOperations.codeOwnerConfig(fooBarCodeOwnerConfigKey).get());
     verifyNoMoreInteractions(visitor);
 
-    verifyZeroInteractions(invalidCodeOwnerConfigCallback);
+    verifyNoInteractions(invalidCodeOwnerConfigCallback);
   }
 
   @Test
@@ -436,7 +437,7 @@
         .visit(codeOwnerConfigOperations.codeOwnerConfig(fooCodeOwnerConfigKey).get());
     verifyNoMoreInteractions(visitor);
 
-    verifyZeroInteractions(invalidCodeOwnerConfigCallback);
+    verifyNoInteractions(invalidCodeOwnerConfigCallback);
   }
 
   @Test
@@ -486,100 +487,7 @@
         .visit(codeOwnerConfigOperations.codeOwnerConfig(fooCodeOwnerConfigKey).get());
     verifyNoMoreInteractions(visitor);
 
-    verifyZeroInteractions(invalidCodeOwnerConfigCallback);
-  }
-
-  @Test
-  public void containsNoCodeOwnerConfigFile() throws Exception {
-    assertThat(
-            codeOwnerConfigScannerFactory
-                .create()
-                .containsAnyCodeOwnerConfigFile(BranchNameKey.create(project, "master")))
-        .isFalse();
-  }
-
-  @Test
-  public void containsACodeOwnerConfigFile() throws Exception {
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/bar/")
-        .fileName("OWNERS")
-        .addCodeOwnerEmail(admin.email())
-        .create();
-
-    assertThat(
-            codeOwnerConfigScannerFactory
-                .create()
-                .containsAnyCodeOwnerConfigFile(BranchNameKey.create(project, "master")))
-        .isTrue();
-  }
-
-  @Test
-  public void containsACodeOwnerConfigFile_defaultCodeOwnerConfigExists() throws Exception {
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch(RefNames.REFS_CONFIG)
-        .folderPath("/")
-        .addCodeOwnerEmail(admin.email())
-        .create();
-
-    assertThat(
-            codeOwnerConfigScannerFactory
-                .create()
-                .containsAnyCodeOwnerConfigFile(BranchNameKey.create(project, "master")))
-        .isTrue();
-  }
-
-  @Test
-  public void containsACodeOwnerConfigFile_defaultCodeOwnerConfigIsSkipped() throws Exception {
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch(RefNames.REFS_CONFIG)
-        .folderPath("/")
-        .addCodeOwnerEmail(admin.email())
-        .create();
-
-    assertThat(
-            codeOwnerConfigScannerFactory
-                .create()
-                .includeDefaultCodeOwnerConfig(false)
-                .containsAnyCodeOwnerConfigFile(BranchNameKey.create(project, "master")))
-        .isFalse();
-  }
-
-  @Test
-  public void containsACodeOwnerConfigFile_invalidCodeOwnerConfigFileExists() throws Exception {
-    createNonParseableCodeOwnerConfig("/OWNERS");
-
-    codeOwnerConfigOperations
-        .newCodeOwnerConfig()
-        .project(project)
-        .branch("master")
-        .folderPath("/foo/bar/")
-        .fileName("OWNERS")
-        .addCodeOwnerEmail(admin.email())
-        .create();
-
-    assertThat(
-            codeOwnerConfigScannerFactory
-                .create()
-                .containsAnyCodeOwnerConfigFile(BranchNameKey.create(project, "master")))
-        .isTrue();
-  }
-
-  @Test
-  public void containsOnlyInvalidCodeOwnerConfigFiles() throws Exception {
-    createNonParseableCodeOwnerConfig("/OWNERS");
-
-    assertThat(
-            codeOwnerConfigScannerFactory
-                .create()
-                .containsAnyCodeOwnerConfigFile(BranchNameKey.create(project, "master")))
-        .isTrue();
+    verifyNoInteractions(invalidCodeOwnerConfigCallback);
   }
 
   @Test
@@ -609,7 +517,7 @@
         .visit(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).get());
     verifyNoMoreInteractions(visitor);
 
-    verifyZeroInteractions(invalidCodeOwnerConfigCallback);
+    verifyNoInteractions(invalidCodeOwnerConfigCallback);
   }
 
   private void visit() {
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResultTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResultTest.java
new file mode 100644
index 0000000..d64f5bf
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResultTest.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import org.junit.Test;
+
+/** Tests for {@link CodeOwnerResolverResult}. */
+public class CodeOwnerResolverResultTest extends AbstractAutoValueTest {
+  @Test
+  public void toStringIncludesAllData() throws Exception {
+    CodeOwnerResolverResult codeOwnerResolverResult =
+        CodeOwnerResolverResult.create(
+            ImmutableSet.of(CodeOwner.create(admin.id())),
+            /* ownedByAllUsers= */ false,
+            /* hasUnresolvedCodeOwners= */ false,
+            /* hasUnresolvedImports= */ false,
+            ImmutableList.of("test message"));
+    assertThatToStringIncludesAllData(codeOwnerResolverResult, CodeOwnerResolverResult.class);
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java
index 86652b2..0787e9a 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.plugins.codeowners.testing.CodeOwnerSubject.assertThat;
+import static com.google.gerrit.plugins.codeowners.testing.OptionalResultWithMessagesSubject.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableSet;
@@ -82,25 +83,63 @@
 
   @Test
   public void resolveCodeOwnerReferenceForNonExistingEmail() throws Exception {
-    assertThat(
-            codeOwnerResolver.get().resolve(CodeOwnerReference.create("non-existing@example.com")))
-        .isEmpty();
+    String nonExistingEmail = "non-existing@example.com";
+    OptionalResultWithMessages<CodeOwner> result =
+        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(nonExistingEmail));
+    assertThat(result).isEmpty();
+    assertThat(result)
+        .hasMessagesThat()
+        .contains(
+            String.format(
+                "cannot resolve code owner email %s: no account with this email exists",
+                nonExistingEmail));
   }
 
   @Test
   public void resolveCodeOwnerReferenceForEmail() throws Exception {
-    Optional<CodeOwner> codeOwner =
-        codeOwnerResolver.get().resolve(CodeOwnerReference.create(admin.email()));
-    assertThat(codeOwner).value().hasAccountIdThat().isEqualTo(admin.id());
+    OptionalResultWithMessages<CodeOwner> result =
+        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(admin.email()));
+    assertThat(result.get()).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(result)
+        .hasMessagesThat()
+        .contains(String.format("account %s is visible to user %s", admin.id(), admin.username()));
   }
 
   @Test
   public void cannotResolveCodeOwnerReferenceForStarAsEmail() throws Exception {
-    Optional<CodeOwner> codeOwner =
+    OptionalResultWithMessages<CodeOwner> result =
         codeOwnerResolver
             .get()
-            .resolve(CodeOwnerReference.create(CodeOwnerResolver.ALL_USERS_WILDCARD));
-    assertThat(codeOwner).isEmpty();
+            .resolveWithMessages(CodeOwnerReference.create(CodeOwnerResolver.ALL_USERS_WILDCARD));
+    assertThat(result).isEmpty();
+    assertThat(result)
+        .hasMessagesThat()
+        .contains(
+            String.format(
+                "cannot resolve code owner email %s: no account with this email exists",
+                CodeOwnerResolver.ALL_USERS_WILDCARD));
+  }
+
+  @Test
+  public void resolveCodeOwnerReferenceForAmbiguousEmailIfOtherAccountIsInactive()
+      throws Exception {
+    // Create an external ID for 'user' account that has the same email as the 'admin' account.
+    accountsUpdate
+        .get()
+        .update(
+            "Test update",
+            user.id(),
+            (a, u) ->
+                u.addExternalId(
+                    ExternalId.create(
+                        "foo", "bar", user.id(), admin.email(), /* hashedPassword= */ null)));
+
+    // Deactivate the 'user' account.
+    accountOperations.account(user.id()).forUpdate().inactive().update();
+
+    OptionalResultWithMessages<CodeOwner> result =
+        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(admin.email()));
+    assertThat(result.get()).hasAccountIdThat().isEqualTo(admin.id());
   }
 
   @Test
@@ -116,27 +155,50 @@
                     ExternalId.create(
                         "foo", "bar", user.id(), admin.email(), /* hashedPassword= */ null)));
 
-    assertThat(codeOwnerResolver.get().resolve(CodeOwnerReference.create(admin.email()))).isEmpty();
+    OptionalResultWithMessages<CodeOwner> result =
+        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(admin.email()));
+    assertThat(result).isEmpty();
+    assertThat(result)
+        .hasMessagesThat()
+        .contains(
+            String.format("cannot resolve code owner email %s: email is ambiguous", admin.email()));
   }
 
   @Test
   public void resolveCodeOwnerReferenceForOrphanedEmail() throws Exception {
     // Create an external ID with an email for a non-existing account.
     String email = "foo.bar@example.com";
+    Account.Id accountId = Account.id(999999);
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
       ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.upsert(ExternalId.createEmail(Account.id(999999), email));
+      extIdNotes.upsert(ExternalId.createEmail(accountId, email));
       extIdNotes.commit(md);
     }
 
-    assertThat(codeOwnerResolver.get().resolve(CodeOwnerReference.create(email))).isEmpty();
+    OptionalResultWithMessages<CodeOwner> result =
+        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(email));
+    assertThat(result).isEmpty();
+    assertThat(result)
+        .hasMessagesThat()
+        .containsAnyOf(
+            String.format(
+                "cannot resolve account %s for email %s: account does not exists",
+                accountId, email),
+            String.format(
+                "cannost resolve code owner email %s: no active account with this email found",
+                email));
   }
 
   @Test
   public void resolveCodeOwnerReferenceForInactiveUser() throws Exception {
     accountOperations.account(user.id()).forUpdate().inactive().update();
-    assertThat(codeOwnerResolver.get().resolve(CodeOwnerReference.create(user.email()))).isEmpty();
+    OptionalResultWithMessages<CodeOwner> result =
+        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(user.email()));
+    assertThat(result).isEmpty();
+    assertThat(result)
+        .hasMessagesThat()
+        .contains(String.format("account %s for email %s is inactive", user.id(), user.email()));
   }
 
   @Test
@@ -149,25 +211,78 @@
 
     // user2 cannot see the admin account since they do not share any group and
     // "accounts.visibility" is set to "SAME_GROUP".
-    assertThat(codeOwnerResolver.get().resolve(CodeOwnerReference.create(admin.email()))).isEmpty();
+    OptionalResultWithMessages<CodeOwner> result =
+        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(admin.email()));
+    assertThat(result).isEmpty();
+    assertThat(result)
+        .hasMessagesThat()
+        .contains(
+            String.format(
+                "cannot resolve code owner email %s: account %s is not visible to user %s",
+                admin.email(), admin.id(), user2.username()));
   }
 
   @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.
-    Optional<CodeOwner> codeOwner =
-        codeOwnerResolver.get().resolve(CodeOwnerReference.create(secondaryEmail));
-    assertThat(codeOwner).value().hasAccountIdThat().isEqualTo(user.id());
+    OptionalResultWithMessages<CodeOwner> result =
+        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
+    assertThat(result.get()).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(result)
+        .hasMessagesThat()
+        .contains(
+            String.format(
+                "resolved code owner email %s: account %s is referenced by secondary email and the calling user %s can see secondary emails",
+                secondaryEmail, user.id(), admin.username()));
+
+    // 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());
+    result =
+        codeOwnerResolver
+            .get()
+            .forUser(identifiedUserFactory.create(admin.id()))
+            .resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
+    assertThat(result.get()).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(result)
+        .hasMessagesThat()
+        .contains(
+            String.format(
+                "resolved code owner email %s: account %s is referenced by secondary email and user %s can see secondary emails",
+                secondaryEmail, user.id(), admin.username()));
 
     // user can see its own secondary email.
     requestScopeOperations.setApiUser(user.id());
-    codeOwner = codeOwnerResolver.get().resolve(CodeOwnerReference.create(secondaryEmail));
-    assertThat(codeOwner).value().hasAccountIdThat().isEqualTo(user.id());
+    result = codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
+    assertThat(result.get()).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(result)
+        .hasMessagesThat()
+        .contains(
+            String.format(
+                "email %s is visible to the calling user %s: email is a secondary email that is owned by this user",
+                secondaryEmail, user.username()));
+
+    // user can see its own secondary email if another user is the calling user.
+    requestScopeOperations.setApiUser(user2.id());
+    result =
+        codeOwnerResolver
+            .get()
+            .forUser(identifiedUserFactory.create(user.id()))
+            .resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
+    assertThat(result.get()).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(result)
+        .hasMessagesThat()
+        .contains(
+            String.format(
+                "email %s is visible to user %s: email is a secondary email that is owned by this user",
+                secondaryEmail, user.username()));
   }
 
   @Test
@@ -179,8 +294,31 @@
     // user doesn't have the "Modify Account" global capability and hence cannot see the secondary
     // email of the admin account.
     requestScopeOperations.setApiUser(user.id());
-    assertThat(codeOwnerResolver.get().resolve(CodeOwnerReference.create(secondaryEmail)))
-        .isEmpty();
+    OptionalResultWithMessages<CodeOwner> result =
+        codeOwnerResolver.get().resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
+    assertThat(result).isEmpty();
+    assertThat(result)
+        .hasMessagesThat()
+        .contains(
+            String.format(
+                "cannot resolve code owner email %s: account %s is referenced by secondary email but the calling user %s cannot see secondary emails",
+                secondaryEmail, admin.id(), user.username()));
+
+    // 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());
+    result =
+        codeOwnerResolver
+            .get()
+            .forUser(identifiedUserFactory.create(user.id()))
+            .resolveWithMessages(CodeOwnerReference.create(secondaryEmail));
+    assertThat(result).isEmpty();
+    assertThat(result)
+        .hasMessagesThat()
+        .contains(
+            String.format(
+                "cannot resolve code owner email %s: account %s is referenced by secondary email but user %s cannot see secondary emails",
+                secondaryEmail, admin.id(), user.username()));
   }
 
   @Test
@@ -284,6 +422,15 @@
   }
 
   @Test
+  public void cannotResolvePathCodeOwnersOfNullPathCodeOwners() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> codeOwnerResolver.get().resolvePathCodeOwners(/* pathCodeOwners= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pathCodeOwners");
+  }
+
+  @Test
   public void cannotResolvePathCodeOwnersForRelativePath() throws Exception {
     String relativePath = "foo/bar.md";
     CodeOwnerConfig codeOwnerConfig =
@@ -369,30 +516,43 @@
       name = "plugin.code-owners.allowedEmailDomain",
       values = {"example.com", "example.net"})
   public void configuredEmailDomainsAreAllowed() throws Exception {
-    assertThat(codeOwnerResolver.get().isEmailDomainAllowed("foo@example.com")).isTrue();
-    assertThat(codeOwnerResolver.get().isEmailDomainAllowed("foo@example.net")).isTrue();
-    assertThat(codeOwnerResolver.get().isEmailDomainAllowed("foo@example.org@example.com"))
-        .isTrue();
-    assertThat(codeOwnerResolver.get().isEmailDomainAllowed("foo@example.org")).isFalse();
-    assertThat(codeOwnerResolver.get().isEmailDomainAllowed("foo")).isFalse();
-    assertThat(codeOwnerResolver.get().isEmailDomainAllowed("foo@example.com@example.org"))
-        .isFalse();
-    assertThat(codeOwnerResolver.get().isEmailDomainAllowed(CodeOwnerResolver.ALL_USERS_WILDCARD))
-        .isTrue();
+    assertIsEmailDomainAllowed(
+        "foo@example.com", true, "domain example.com of email foo@example.com is allowed");
+    assertIsEmailDomainAllowed(
+        "foo@example.net", true, "domain example.net of email foo@example.net is allowed");
+    assertIsEmailDomainAllowed(
+        "foo@example.org@example.com",
+        true,
+        "domain example.com of email foo@example.org@example.com is allowed");
+    assertIsEmailDomainAllowed(
+        "foo@example.org", false, "domain example.org of email foo@example.org is not allowed");
+    assertIsEmailDomainAllowed("foo", false, "email foo has no domain");
+    assertIsEmailDomainAllowed(
+        "foo@example.com@example.org",
+        false,
+        "domain example.org of email foo@example.com@example.org is not allowed");
+    assertIsEmailDomainAllowed(
+        CodeOwnerResolver.ALL_USERS_WILDCARD, true, "all users wildcard is allowed");
   }
 
   @Test
   public void allEmailDomainsAreAllowed() throws Exception {
-    assertThat(codeOwnerResolver.get().isEmailDomainAllowed("foo@example.com")).isTrue();
-    assertThat(codeOwnerResolver.get().isEmailDomainAllowed("foo@example.net")).isTrue();
-    assertThat(codeOwnerResolver.get().isEmailDomainAllowed("foo@example.org@example.com"))
-        .isTrue();
-    assertThat(codeOwnerResolver.get().isEmailDomainAllowed("foo@example.org")).isTrue();
-    assertThat(codeOwnerResolver.get().isEmailDomainAllowed("foo")).isTrue();
-    assertThat(codeOwnerResolver.get().isEmailDomainAllowed("foo@example.com@example.org"))
-        .isTrue();
-    assertThat(codeOwnerResolver.get().isEmailDomainAllowed(CodeOwnerResolver.ALL_USERS_WILDCARD))
-        .isTrue();
+    String expectedMessage = "all domains are allowed";
+    assertIsEmailDomainAllowed("foo@example.com", true, expectedMessage);
+    assertIsEmailDomainAllowed("foo@example.net", true, expectedMessage);
+    assertIsEmailDomainAllowed("foo@example.org@example.com", true, expectedMessage);
+    assertIsEmailDomainAllowed("foo@example.org", true, expectedMessage);
+    assertIsEmailDomainAllowed("foo", true, expectedMessage);
+    assertIsEmailDomainAllowed("foo@example.com@example.org", true, expectedMessage);
+    assertIsEmailDomainAllowed(CodeOwnerResolver.ALL_USERS_WILDCARD, true, expectedMessage);
+  }
+
+  private void assertIsEmailDomainAllowed(
+      String email, boolean expectedResult, String expectedMessage) {
+    OptionalResultWithMessages<Boolean> isEmailDomainAllowedResult =
+        codeOwnerResolver.get().isEmailDomainAllowed(email);
+    assertThat(isEmailDomainAllowedResult.get()).isEqualTo(expectedResult);
+    assertThat(isEmailDomainAllowedResult.messages()).containsExactly(expectedMessage);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScoreTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScoreTest.java
new file mode 100644
index 0000000..f0072f0
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScoreTest.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import org.junit.Test;
+
+/** Tests for {@link CodeOwnerScore}. */
+public class CodeOwnerScoreTest extends AbstractCodeOwnersTest {
+  @Test
+  public void createScoringForScoreThatDefinesAMaxValue() throws Exception {
+    assertThat(CodeOwnerScore.IS_REVIEWER.createScoring().build().maxValue())
+        .isEqualTo(CodeOwnerScore.IS_REVIEWER.maxValue().get());
+  }
+
+  @Test
+  public void cannotCreateScoringWithMaxValueForScoreThatDefinesAMaxValue() throws Exception {
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class, () -> CodeOwnerScore.IS_REVIEWER.createScoring(5));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("score IS_REVIEWER has defined a maxValue, setting maxValue not allowed");
+  }
+
+  @Test
+  public void createScoringWithMaxValueForScoreThatDosntDefineAMaxValue() throws Exception {
+    assertThat(CodeOwnerScore.DISTANCE.createScoring(5).build().maxValue()).isEqualTo(5);
+  }
+
+  @Test
+  public void cannotCreateScoringWithoutMaxValueForScoreThatDoesntDefinesAMaxValue()
+      throws Exception {
+    IllegalStateException exception =
+        assertThrows(IllegalStateException.class, () -> CodeOwnerScore.DISTANCE.createScoring());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("score DISTANCE doesn't have a maxValue defined, setting maxValue is required");
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScoringTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScoringTest.java
index 0cab290..43498c6 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScoringTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScoringTest.java
@@ -19,8 +19,6 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
-import java.util.ArrayList;
-import java.util.Comparator;
 import java.util.Optional;
 import org.junit.Test;
 
@@ -108,45 +106,4 @@
                 .scoring(CodeOwner.create(admin.id())))
         .isZero();
   }
-
-  @Test
-  public void sortCodeOwnersByScorings() throws Exception {
-    CodeOwner codeOwner1 = CodeOwner.create(admin.id());
-    CodeOwner codeOwner2 = CodeOwner.create(user.id());
-    CodeOwner codeOwner3 = CodeOwner.create(accountCreator.user2().id());
-
-    ArrayList<CodeOwner> codeOwners = new ArrayList<>();
-    codeOwners.add(codeOwner1);
-    codeOwners.add(codeOwner2);
-    codeOwners.add(codeOwner3);
-
-    // lower distance is better
-    Comparator<CodeOwner> comparator =
-        CodeOwnerScoring.builder(CodeOwnerScore.DISTANCE, 100)
-            .putValueForCodeOwner(codeOwner1, 50)
-            .putValueForCodeOwner(codeOwner2, 100)
-            .putValueForCodeOwner(codeOwner3, 0)
-            .build()
-            .comparingByScoring();
-    codeOwners.sort(comparator);
-    assertThat(codeOwners).containsExactly(codeOwner3, codeOwner1, codeOwner2).inOrder();
-  }
-
-  @Test
-  public void sortCodeOwnersByScoringsIfAnyCodeOwnerHasNoScoring() throws Exception {
-    CodeOwner codeOwner1 = CodeOwner.create(admin.id());
-    CodeOwner codeOwner2 = CodeOwner.create(user.id());
-
-    ArrayList<CodeOwner> codeOwners = new ArrayList<>();
-    codeOwners.add(codeOwner1);
-    codeOwners.add(codeOwner2);
-
-    Comparator<CodeOwner> comparator =
-        CodeOwnerScoring.builder(CodeOwnerScore.DISTANCE, 100)
-            .putValueForCodeOwner(codeOwner2, 50)
-            .build()
-            .comparingByScoring();
-    codeOwners.sort(comparator);
-    assertThat(codeOwners).containsExactly(codeOwner2, codeOwner1).inOrder();
-  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScoringsTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScoringsTest.java
new file mode 100644
index 0000000..daa2f41
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerScoringsTest.java
@@ -0,0 +1,153 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.plugins.codeowners.backend.CodeOwnerScore.IS_REVIEWER_SCORING_VALUE;
+import static com.google.gerrit.plugins.codeowners.backend.CodeOwnerScore.NO_REVIEWER_SCORING_VALUE;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import java.util.ArrayList;
+import org.junit.Test;
+
+/** Tests for {@link CodeOwnerScorings}. */
+public class CodeOwnerScoringsTest extends AbstractCodeOwnersTest {
+  @Test
+  public void getScorings_lowerValueIsBetterScore() throws Exception {
+    CodeOwner codeOwner1 = CodeOwner.create(admin.id());
+    CodeOwner codeOwner2 = CodeOwner.create(user.id());
+    CodeOwner codeOwner3 = CodeOwner.create(accountCreator.user2().id());
+
+    ArrayList<CodeOwner> codeOwners = new ArrayList<>();
+    codeOwners.add(codeOwner1);
+    codeOwners.add(codeOwner2);
+    codeOwners.add(codeOwner3);
+
+    // lower distance is better
+    CodeOwnerScoring distanceScoring =
+        CodeOwnerScore.DISTANCE
+            .createScoring(100)
+            .putValueForCodeOwner(codeOwner1, 50)
+            .putValueForCodeOwner(codeOwner2, 100)
+            .putValueForCodeOwner(codeOwner3, 0)
+            .build();
+
+    CodeOwnerScorings codeOwnerScorings = CodeOwnerScorings.create(distanceScoring);
+    assertThat(codeOwnerScorings.getScorings(ImmutableSet.of(codeOwner1, codeOwner2, codeOwner3)))
+        .containsExactly(
+            codeOwner1,
+            CodeOwnerScore.DISTANCE.weight() * 0.5,
+            codeOwner2,
+            0.0,
+            codeOwner3,
+            CodeOwnerScore.DISTANCE.weight() * 1.0);
+  }
+
+  @Test
+  public void getScorings_greaterValueIsBetterScore() throws Exception {
+    CodeOwner codeOwner1 = CodeOwner.create(admin.id());
+    CodeOwner codeOwner2 = CodeOwner.create(user.id());
+
+    ArrayList<CodeOwner> codeOwners = new ArrayList<>();
+    codeOwners.add(codeOwner1);
+    codeOwners.add(codeOwner2);
+
+    // for the IS_REVIEWER score a greater score is better
+    CodeOwnerScoring isReviewerScoring =
+        CodeOwnerScore.IS_REVIEWER
+            .createScoring()
+            .putValueForCodeOwner(codeOwner1, 0)
+            .putValueForCodeOwner(codeOwner2, 1)
+            .build();
+
+    CodeOwnerScorings codeOwnerScorings = CodeOwnerScorings.create(isReviewerScoring);
+    assertThat(codeOwnerScorings.getScorings(ImmutableSet.of(codeOwner1, codeOwner2)))
+        .containsExactly(
+            codeOwner1,
+            CodeOwnerScore.IS_REVIEWER.weight() * CodeOwnerScore.NO_REVIEWER_SCORING_VALUE,
+            codeOwner2,
+            CodeOwnerScore.IS_REVIEWER.weight() * CodeOwnerScore.IS_REVIEWER_SCORING_VALUE);
+  }
+
+  @Test
+  public void getScorings_multipleScore() throws Exception {
+    CodeOwner codeOwner1 = CodeOwner.create(admin.id());
+    CodeOwner codeOwner2 = CodeOwner.create(user.id());
+    CodeOwner codeOwner3 = CodeOwner.create(accountCreator.user2().id());
+
+    ArrayList<CodeOwner> codeOwners = new ArrayList<>();
+    codeOwners.add(codeOwner1);
+    codeOwners.add(codeOwner2);
+    codeOwners.add(codeOwner3);
+
+    // lower distance is better
+    CodeOwnerScoring distanceScoring =
+        CodeOwnerScoring.builder(CodeOwnerScore.DISTANCE, 100)
+            .putValueForCodeOwner(codeOwner1, 50)
+            .putValueForCodeOwner(codeOwner2, 75)
+            .putValueForCodeOwner(codeOwner3, 0)
+            .build();
+
+    // for the IS_REVIEWER score a greater score is better
+    CodeOwnerScoring isReviewerScoring =
+        CodeOwnerScore.IS_REVIEWER
+            .createScoring()
+            .putValueForCodeOwner(codeOwner1, NO_REVIEWER_SCORING_VALUE)
+            .putValueForCodeOwner(codeOwner2, IS_REVIEWER_SCORING_VALUE)
+            .putValueForCodeOwner(codeOwner3, NO_REVIEWER_SCORING_VALUE)
+            .build();
+
+    // Expected scorings:
+    // codeOwner1: DISTANCE(weight=1)=0.5, IS_REVIEWER(weight=2)=0.0
+    //             -> total scoring: 0.5 * 1 + 0.0 * 2 = 0.5
+    // codeOwner2: DISTANCE(weight=1)=0.25, IS_REVIEWER(weight=2)=1.0
+    //            -> total scoring: 0.25 * 1 + 1.0 * 2 = 2.25
+    // codeOwner3: DISTANCE(weight=1)=1.0, IS_REVIEWER(weight=2)=0.0
+    //            -> total scoring: 1.0 * 1 + 0.0 * 2 = 1.0
+    CodeOwnerScorings codeOwnerScorings =
+        CodeOwnerScorings.create(distanceScoring, isReviewerScoring);
+    assertThat(codeOwnerScorings.getScorings(ImmutableSet.of(codeOwner1, codeOwner2, codeOwner3)))
+        .containsExactly(
+            codeOwner1,
+            CodeOwnerScore.DISTANCE.weight() * 0.5
+                + CodeOwnerScore.IS_REVIEWER.weight() * CodeOwnerScore.NO_REVIEWER_SCORING_VALUE,
+            codeOwner2,
+            CodeOwnerScore.DISTANCE.weight() * 0.25
+                + CodeOwnerScore.IS_REVIEWER.weight() * CodeOwnerScore.IS_REVIEWER_SCORING_VALUE,
+            codeOwner3,
+            CodeOwnerScore.DISTANCE.weight() * 1.0
+                + CodeOwnerScore.IS_REVIEWER.weight() * CodeOwnerScore.NO_REVIEWER_SCORING_VALUE);
+  }
+
+  @Test
+  public void getScoringsIfAnyCodeOwnerHasNoScoring() throws Exception {
+    CodeOwner codeOwner1 = CodeOwner.create(admin.id());
+    CodeOwner codeOwner2 = CodeOwner.create(user.id());
+
+    ArrayList<CodeOwner> codeOwners = new ArrayList<>();
+    codeOwners.add(codeOwner1);
+    codeOwners.add(codeOwner2);
+
+    CodeOwnerScoring distanceScoring =
+        CodeOwnerScoring.builder(CodeOwnerScore.DISTANCE, 100)
+            .putValueForCodeOwner(codeOwner2, 50)
+            .build();
+
+    CodeOwnerScorings codeOwnerScorings = CodeOwnerScorings.create(distanceScoring);
+    assertThat(codeOwnerScorings.getScorings(ImmutableSet.of(codeOwner1, codeOwner2)))
+        .containsExactly(codeOwner1, 0.0, codeOwner2, 0.5);
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSubmitRuleTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSubmitRuleTest.java
index 8e03935..a06db05 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSubmitRuleTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSubmitRuleTest.java
@@ -14,16 +14,23 @@
 
 package com.google.gerrit.plugins.codeowners.backend;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.plugins.codeowners.testing.SubmitRecordSubject.assertThatOptional;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperations;
+import com.google.gerrit.plugins.codeowners.testing.LegacySubmitRequirementSubject;
 import com.google.gerrit.plugins.codeowners.testing.SubmitRecordSubject;
-import com.google.gerrit.plugins.codeowners.testing.SubmitRequirementSubject;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import org.junit.Before;
@@ -50,16 +57,40 @@
   }
 
   @Test
-  public void notReady() throws Exception {
-    // create arbitrary code owner config to avoid entering the bootstrapping code path in
-    // CodeOwnerApprovalCheck
-    createArbitraryCodeOwnerConfigFile();
+  public void emptyIfChangeIdClosed() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(user.email())
+        .create();
 
+    String path = "foo/bar.baz";
+    Change change = createChange("Change Adding A File", path, "file content").getChange().change();
+    String changeId = change.getKey().get();
+
+    // Add a Code-Review+1 from a code owner (by default this counts as code owner approval).
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeId);
+
+    // Approve and submit.
+    requestScopeOperations.setApiUser(admin.id());
+    approve(changeId);
+    gApi.changes().id(changeId).current().submit();
+
+    // Run the code owners submit rule on the closed change.
+    ChangeData changeData = changeDataFactory.create(project, change.getId());
+    assertThat(codeOwnerSubmitRule.evaluate(changeData)).isEmpty();
+  }
+
+  @Test
+  public void notReady() throws Exception {
     ChangeData changeData = createChange().getChange();
     SubmitRecordSubject submitRecordSubject =
         assertThatOptional(codeOwnerSubmitRule.evaluate(changeData)).value();
     submitRecordSubject.hasStatusThat().isNotReady();
-    SubmitRequirementSubject submitRequirementSubject =
+    LegacySubmitRequirementSubject submitRequirementSubject =
         submitRecordSubject.hasSubmitRequirementsThat().onlyElement();
     submitRequirementSubject.hasTypeThat().isEqualTo("code-owners");
     submitRequirementSubject.hasFallbackTextThat().isEqualTo("Code Owners");
@@ -85,14 +116,14 @@
     SubmitRecordSubject submitRecordSubject =
         assertThatOptional(codeOwnerSubmitRule.evaluate(changeData)).value();
     submitRecordSubject.hasStatusThat().isOk();
-    SubmitRequirementSubject submitRequirementSubject =
+    LegacySubmitRequirementSubject submitRequirementSubject =
         submitRecordSubject.hasSubmitRequirementsThat().onlyElement();
     submitRequirementSubject.hasTypeThat().isEqualTo("code-owners");
     submitRequirementSubject.hasFallbackTextThat().isEqualTo("Code Owners");
   }
 
   @Test
-  public void ruleError() throws Exception {
+  public void internalServerError() throws Exception {
     ChangeData changeData = createChange().getChange();
 
     // Create a ChangeData without change notes to trigger an error.
@@ -101,11 +132,12 @@
     when(changeDataWithoutChangeNotes.change()).thenReturn(changeData.change());
     when(changeDataWithoutChangeNotes.currentPatchSet()).thenReturn(changeData.currentPatchSet());
 
-    SubmitRecordSubject submitRecordSubject =
-        assertThatOptional(codeOwnerSubmitRule.evaluate(changeDataWithoutChangeNotes)).value();
-    submitRecordSubject.hasStatusThat().isRuleError();
-    submitRecordSubject
-        .hasErrorMessageThat()
+    CodeOwnersInternalServerErrorException exception =
+        assertThrows(
+            CodeOwnersInternalServerErrorException.class,
+            () -> codeOwnerSubmitRule.evaluate(changeDataWithoutChangeNotes));
+    assertThat(exception)
+        .hasMessageThat()
         .isEqualTo(
             String.format(
                 "Failed to evaluate code owner statuses for patch set %d of change %d.",
@@ -113,10 +145,89 @@
   }
 
   @Test
-  public void ruleErrorWhenChangeDataIsNull() throws Exception {
+  public void internalServerError_changeDataIsNull() throws Exception {
+    CodeOwnersInternalServerErrorException exception =
+        assertThrows(
+            CodeOwnersInternalServerErrorException.class,
+            () -> codeOwnerSubmitRule.evaluate(/* changeData= */ null));
+    assertThat(exception).hasMessageThat().isEqualTo("Failed to evaluate code owner statuses.");
+  }
+
+  @Test
+  public void ruleError_nonParsableCodeOwnerConfig() throws Exception {
+    testRuleErrorForNonParsableCodeOwnerConfig(/* invalidCodeOwnerConfigInfoUrl= */ null);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.invalidCodeOwnerConfigInfoUrl", value = "http://foo.bar")
+  public void ruleError_nonParsableCodeOwnerConfig_withInvalidCodeOwnerConfigInfoUrl()
+      throws Exception {
+    testRuleErrorForNonParsableCodeOwnerConfig("http://foo.bar");
+  }
+
+  private void testRuleErrorForNonParsableCodeOwnerConfig(
+      @Nullable String invalidCodeOwnerConfigInfoUrl) throws Exception {
+    String nameOfInvalidCodeOwnerConfigFile = getCodeOwnerConfigFileName();
+    createNonParseableCodeOwnerConfig(nameOfInvalidCodeOwnerConfigFile);
+
+    ChangeData changeData = createChange().getChange();
+
     SubmitRecordSubject submitRecordSubject =
-        assertThatOptional(codeOwnerSubmitRule.evaluate(/* changeData= */ null)).value();
+        assertThatOptional(codeOwnerSubmitRule.evaluate(changeData)).value();
     submitRecordSubject.hasStatusThat().isRuleError();
-    submitRecordSubject.hasErrorMessageThat().isEqualTo("Failed to evaluate code owner statuses.");
+    submitRecordSubject
+        .hasErrorMessageThat()
+        .isEqualTo(
+            String.format(
+                "Failed to evaluate code owner statuses for patch set %d of change %d"
+                    + " (cause: invalid code owner config file '%s' (project = %s, branch = master):\n"
+                    + "  %s).%s",
+                changeData.change().currentPatchSetId().get(),
+                changeData.change().getId().get(),
+                JgitPath.of(nameOfInvalidCodeOwnerConfigFile).getAsAbsolutePath(),
+                project,
+                getParsingErrorMessageForNonParseableCodeOwnerConfig(),
+                invalidCodeOwnerConfigInfoUrl != null
+                    ? String.format("\nFor help check %s.", invalidCodeOwnerConfigInfoUrl)
+                    : ""));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void overrideWhenCodeOwnerConfigIsNonParsable() throws Exception {
+    createOwnersOverrideLabel();
+
+    String nameOfInvalidCodeOwnerConfigFile = getCodeOwnerConfigFileName();
+    createNonParseableCodeOwnerConfig(nameOfInvalidCodeOwnerConfigFile);
+
+    ChangeData changeData = createChange().getChange();
+    String changeId = changeData.change().getKey().get();
+
+    SubmitRecordSubject submitRecordSubject =
+        assertThatOptional(codeOwnerSubmitRule.evaluate(changeData)).value();
+    submitRecordSubject.hasStatusThat().isRuleError();
+    submitRecordSubject
+        .hasErrorMessageThat()
+        .isEqualTo(
+            String.format(
+                "Failed to evaluate code owner statuses for patch set %d of change %d"
+                    + " (cause: invalid code owner config file '%s' (project = %s, branch = master):\n"
+                    + "  %s).",
+                changeData.change().currentPatchSetId().get(),
+                changeData.change().getId().get(),
+                JgitPath.of(nameOfInvalidCodeOwnerConfigFile).getAsAbsolutePath(),
+                project,
+                getParsingErrorMessageForNonParseableCodeOwnerConfig()));
+
+    // Apply an override.
+    gApi.changes().id(changeId).current().review(new ReviewInput().label("Owners-Override", 1));
+    changeData.reloadChange();
+
+    submitRecordSubject = assertThatOptional(codeOwnerSubmitRule.evaluate(changeData)).value();
+    submitRecordSubject.hasStatusThat().isOk();
+    LegacySubmitRequirementSubject submitRequirementSubject =
+        submitRecordSubject.hasSubmitRequirementsThat().onlyElement();
+    submitRequirementSubject.hasTypeThat().isEqualTo("code-owners");
+    submitRequirementSubject.hasFallbackTextThat().isEqualTo("Code Owners");
   }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnersExceptionHookTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnersExceptionHookTest.java
new file mode 100644
index 0000000..1c0f9c1
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnersExceptionHookTest.java
@@ -0,0 +1,165 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.truth.OptionalSubject.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import com.google.gerrit.plugins.codeowners.backend.config.InvalidPluginConfigurationException;
+import com.google.gerrit.server.ExceptionHook.Status;
+import java.nio.file.InvalidPathException;
+import java.util.Optional;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link CodeOwnersExceptionHook}. */
+public class CodeOwnersExceptionHookTest extends AbstractCodeOwnersTest {
+  private CodeOwnersExceptionHook codeOwnersExceptionHook;
+
+  @Before
+  public void setUpCodeOwnersPlugin() throws Exception {
+    codeOwnersExceptionHook = plugin.getSysInjector().getInstance(CodeOwnersExceptionHook.class);
+  }
+
+  @Test
+  public void skipRetryWithTrace() throws Exception {
+    assertThat(skipRetryWithTrace(newInvalidPluginConfigurationException())).isTrue();
+    assertThat(skipRetryWithTrace(newExceptionWithCause(newInvalidPluginConfigurationException())))
+        .isTrue();
+
+    assertThat(skipRetryWithTrace(newInvalidCodeOwnerConfigException())).isTrue();
+    assertThat(skipRetryWithTrace(newExceptionWithCause(newInvalidCodeOwnerConfigException())))
+        .isTrue();
+
+    assertThat(skipRetryWithTrace(newInvalidPathException())).isTrue();
+    assertThat(skipRetryWithTrace(newExceptionWithCause(newInvalidPathException()))).isTrue();
+
+    assertThat(skipRetryWithTrace(new CodeOwnersInternalServerErrorException("msg"))).isFalse();
+    assertThat(
+            skipRetryWithTrace(
+                newExceptionWithCause(new CodeOwnersInternalServerErrorException("msg"))))
+        .isFalse();
+
+    assertThat(skipRetryWithTrace(new Exception())).isFalse();
+    assertThat(skipRetryWithTrace(newExceptionWithCause(new Exception()))).isFalse();
+  }
+
+  @Test
+  public void getUserMessages() throws Exception {
+    InvalidPluginConfigurationException invalidPluginConfigurationException =
+        newInvalidPluginConfigurationException();
+    assertThat(getUserMessages(invalidPluginConfigurationException))
+        .containsExactly(invalidPluginConfigurationException.getMessage());
+    assertThat(getUserMessages(newExceptionWithCause(invalidPluginConfigurationException)))
+        .containsExactly(invalidPluginConfigurationException.getMessage());
+
+    InvalidCodeOwnerConfigException invalidCodeOwnerConfigException =
+        newInvalidCodeOwnerConfigException();
+    assertThat(getUserMessages(invalidCodeOwnerConfigException))
+        .containsExactly(invalidCodeOwnerConfigException.getMessage());
+    assertThat(getUserMessages(newExceptionWithCause(invalidCodeOwnerConfigException)))
+        .containsExactly(invalidCodeOwnerConfigException.getMessage());
+
+    InvalidPathException invalidPathException = newInvalidPathException();
+    assertThat(getUserMessages(invalidPathException))
+        .containsExactly(invalidPathException.getMessage());
+    assertThat(getUserMessages(newExceptionWithCause(invalidPathException)))
+        .containsExactly(invalidPathException.getMessage());
+
+    CodeOwnersInternalServerErrorException codeOwnersInternalServerErrorException =
+        new CodeOwnersInternalServerErrorException("msg");
+    assertThat(getUserMessages(codeOwnersInternalServerErrorException))
+        .containsExactly(codeOwnersInternalServerErrorException.getUserVisibleMessage());
+    assertThat(getUserMessages(newExceptionWithCause(codeOwnersInternalServerErrorException)))
+        .containsExactly(codeOwnersInternalServerErrorException.getUserVisibleMessage());
+
+    assertThat(getUserMessages(new Exception())).isEmpty();
+    assertThat(getUserMessages(newExceptionWithCause(new Exception()))).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.invalidCodeOwnerConfigInfoUrl", value = "http://foo.bar")
+  public void getUserMessages_withInvalidCodeOwnerConfigInfoUrl() throws Exception {
+    InvalidCodeOwnerConfigException invalidCodeOwnerConfigException =
+        newInvalidCodeOwnerConfigException();
+    assertThat(getUserMessages(invalidCodeOwnerConfigException))
+        .containsExactly(
+            invalidCodeOwnerConfigException.getMessage(), "For help check http://foo.bar")
+        .inOrder();
+    assertThat(getUserMessages(newExceptionWithCause(invalidCodeOwnerConfigException)))
+        .containsExactly(
+            invalidCodeOwnerConfigException.getMessage(), "For help check http://foo.bar")
+        .inOrder();
+  }
+
+  @Test
+  public void getStatus() throws Exception {
+    Status conflictStatus = Status.create(409, "Conflict");
+    assertThat(getStatus(newInvalidPluginConfigurationException()))
+        .value()
+        .isEqualTo(conflictStatus);
+    assertThat(getStatus(newExceptionWithCause(newInvalidPluginConfigurationException())))
+        .value()
+        .isEqualTo(conflictStatus);
+
+    assertThat(getStatus(newInvalidCodeOwnerConfigException())).value().isEqualTo(conflictStatus);
+    assertThat(getStatus(newExceptionWithCause(newInvalidCodeOwnerConfigException())))
+        .value()
+        .isEqualTo(conflictStatus);
+
+    assertThat(getStatus(newInvalidPathException())).value().isEqualTo(conflictStatus);
+    assertThat(getStatus(newExceptionWithCause(newInvalidPathException())))
+        .value()
+        .isEqualTo(conflictStatus);
+
+    assertThat(getStatus(new Exception())).isEmpty();
+    assertThat(getStatus(newExceptionWithCause(new Exception()))).isEmpty();
+
+    assertThat(getStatus(new CodeOwnersInternalServerErrorException("msg"))).isEmpty();
+    assertThat(getStatus(newExceptionWithCause(new CodeOwnersInternalServerErrorException("msg"))))
+        .isEmpty();
+  }
+
+  private boolean skipRetryWithTrace(Exception exception) {
+    return codeOwnersExceptionHook.skipRetryWithTrace("actionType", "actionName", exception);
+  }
+
+  private ImmutableList<String> getUserMessages(Exception exception) {
+    return codeOwnersExceptionHook.getUserMessages(exception, /* traceId= */ null);
+  }
+
+  private Optional<Status> getStatus(Exception exception) {
+    return codeOwnersExceptionHook.getStatus(exception);
+  }
+
+  private Exception newExceptionWithCause(Exception cause) {
+    return new Exception("exception1", new Exception("exception2", cause));
+  }
+
+  private InvalidPluginConfigurationException newInvalidPluginConfigurationException() {
+    return new InvalidPluginConfigurationException("code-owners", "message");
+  }
+
+  private InvalidCodeOwnerConfigException newInvalidCodeOwnerConfigException() {
+    return new InvalidCodeOwnerConfigException("message", project, "refs/heads/master", "/OWNERS");
+  }
+
+  private InvalidPathException newInvalidPathException() {
+    return new InvalidPathException("input", "reason");
+  }
+}
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..8696f19
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/FindOwnersGlobMatcherTest.java
@@ -0,0 +1,90 @@
+// 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 org.junit.Test;
+
+/** Tests for {@link FindOwnersGlobMatcher}. */
+public class FindOwnersGlobMatcherTest extends GlobMatcherTest {
+  @Override
+  protected PathExpressionMatcher getPathExpressionMatcher() {
+    return FindOwnersGlobMatcher.INSTANCE;
+  }
+
+  /**
+   * This test differs from the base class ({@link GlobMatcherTest}), since {@link
+   * FindOwnersGlobMatcher} matches globs against any subdirectory and the test in the base class
+   * checks that subdirectories are not matched.
+   */
+  @Test
+  @Override
+  public void matchFileType() throws Exception {
+    String pathExpression = "*.md";
+    assertMatch(pathExpression, "README.md", "config.md", "foo/README.md", "foo/bar/README.md");
+    assertNoMatch(pathExpression, "README", "README.md5");
+  }
+
+  /**
+   * This test differs from the base class ({@link GlobMatcherTest}), since {@link
+   * FindOwnersGlobMatcher} matches globs against any subdirectory and the test in the base class
+   * checks that subdirectories are not matched.
+   */
+  @Test
+  @Override
+  public void matchConcreteFile() throws Exception {
+    String pathExpression = "BUILD";
+    assertMatch(pathExpression, "BUILD", "foo/BUILD", "foo/bar/BUILD");
+    assertNoMatch(pathExpression, "README", "BUILD2");
+  }
+
+  /**
+   * This test differs from the base class ({@link GlobMatcherTest}), since {@link
+   * FindOwnersGlobMatcher} matches globs against any subdirectory and the test in the base class
+   * checks that subdirectories are not matched.
+   */
+  @Test
+  @Override
+  public void matchAllFilesInSubfolder() throws Exception {
+    String pathExpression = "foo/**";
+    assertMatch(
+        pathExpression,
+        "foo/README.md",
+        "foo/config.txt",
+        "foo/bar/README.md",
+        "foo/bar/baz/README.md",
+        "bar/foo/README.md",
+        "bar/foo/config.txt",
+        "bar/foo/bar/README.md",
+        "bar/foo/bar/baz/README.md");
+    assertNoMatch(pathExpression, "README", "foo2/README", "bar/README", "bar/foo2/README");
+  }
+
+  /**
+   * This test differs from the base class ({@link GlobMatcherTest}), since {@link
+   * FindOwnersGlobMatcher} matches globs against any subdirectory and the test in the base class
+   * checks that subdirectories are not matched.
+   */
+  @Test
+  @Override
+  public void matchPattern() throws Exception {
+    String pathExpression = "{**/,}foo-[1-4].txt";
+    assertMatch(pathExpression, "foo-1.txt", "foo-2.txt", "sub/foo-3.txt", "sub/sub/foo-4.txt");
+    assertNoMatch(pathExpression, "foo-5.txt", "foo-11.txt");
+
+    String pathExpression2 = "foo-[1-4].txt";
+    assertMatch(pathExpression2, "foo-1.txt", "foo-2.txt", "sub/foo-3.txt", "sub/sub/foo-4.txt");
+    assertNoMatch(pathExpression2, "foo-5.txt", "foo-11.txt");
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/GlobMatcherTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/GlobMatcherTest.java
index 75affd9..5291bb7 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/GlobMatcherTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/GlobMatcherTest.java
@@ -24,17 +24,17 @@
   }
 
   @Test
-  public void matchConcreteFileInCurrentFolder() throws Exception {
+  public void matchConcreteFile() throws Exception {
     String pathExpression = "BUILD";
     assertMatch(pathExpression, "BUILD");
-    assertNoMatch(pathExpression, "README", "BUILD2", "foo/BUILD");
+    assertNoMatch(pathExpression, "README", "BUILD2", "foo/BUILD", "foo/bar/BUILD");
   }
 
   @Test
-  public void matchFileTypeInCurrentFolder() throws Exception {
+  public void matchFileType() throws Exception {
     String pathExpression = "*.md";
     assertMatch(pathExpression, "README.md", "config.md");
-    assertNoMatch(pathExpression, "README", "README.md5", "foo/README.md");
+    assertNoMatch(pathExpression, "README", "README.md5", "foo/README.md", "foo/bar/README.md");
   }
 
   @Test
@@ -60,7 +60,16 @@
         "foo/config.txt",
         "foo/bar/README.md",
         "foo/bar/baz/README.md");
-    assertNoMatch(pathExpression, "README", "foo2/README");
+    assertNoMatch(
+        pathExpression,
+        "README",
+        "foo2/README",
+        "bar/README",
+        "bar/foo2/README",
+        "bar/foo/README.md",
+        "bar/foo/config.txt",
+        "bar/foo/bar/README.md",
+        "bar/foo/bar/baz/README.md");
   }
 
   @Test
@@ -68,5 +77,9 @@
     String pathExpression = "{**/,}foo-[1-4].txt";
     assertMatch(pathExpression, "foo-1.txt", "foo-2.txt", "sub/foo-3.txt", "sub/sub/foo-4.txt");
     assertNoMatch(pathExpression, "foo-5.txt", "foo-11.txt");
+
+    String pathExpression2 = "foo-[1-4].txt";
+    assertMatch(pathExpression2, "foo-1.txt", "foo-2.txt");
+    assertNoMatch(pathExpression2, "foo-5.txt", "foo-11.txt", "sub/foo-3.txt", "sub/sub/foo-4.txt");
   }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResultTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResultTest.java
new file mode 100644
index 0000000..d270fdc
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResultTest.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import com.google.common.collect.ImmutableList;
+import java.nio.file.Paths;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+/** Tests for {@link PathCodeOwnersResult}. */
+public class PathCodeOwnersResultTest extends AbstractAutoValueTest {
+  private static final ObjectId TEST_REVISION =
+      ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+
+  @Test
+  public void toStringIncludesAllData() throws Exception {
+    CodeOwnerConfig.Key codeOwnerConfigKey = CodeOwnerConfig.Key.create(project, "master", "/");
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS");
+    PathCodeOwnersResult pathCodeOwnersResult =
+        PathCodeOwnersResult.create(
+            Paths.get("/foo/bar/baz.md"),
+            CodeOwnerConfig.builder(codeOwnerConfigKey, TEST_REVISION)
+                .addImport(codeOwnerConfigReference)
+                .build(),
+            ImmutableList.of(
+                UnresolvedImport.create(
+                    codeOwnerConfigKey,
+                    CodeOwnerConfig.Key.create(project, "master", "/bar/"),
+                    codeOwnerConfigReference,
+                    "test message")));
+    assertThatToStringIncludesAllData(pathCodeOwnersResult, PathCodeOwnersResult.class);
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java
index 066fcbb..dce1d52 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java
@@ -38,9 +38,11 @@
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
 import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperations;
+import com.google.gerrit.plugins.codeowners.acceptance.testsuite.TestPathExpressions;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.inject.Inject;
 import com.google.inject.Key;
+import com.google.inject.Provider;
 import com.google.inject.util.Providers;
 import java.nio.file.Path;
 import java.nio.file.Paths;
@@ -58,6 +60,8 @@
   private CodeOwnerConfigOperations codeOwnerConfigOperations;
   private PathCodeOwners.Factory pathCodeOwnersFactory;
   private DynamicMap<CodeOwnerBackend> codeOwnerBackends;
+  private Provider<TransientCodeOwnerConfigCache> transientCodeOwnerConfigCacheProvider;
+  private TestPathExpressions testPathExpressions;
 
   @Before
   public void setUpCodeOwnersPlugin() throws Exception {
@@ -66,12 +70,15 @@
     pathCodeOwnersFactory = plugin.getSysInjector().getInstance(PathCodeOwners.Factory.class);
     codeOwnerBackends =
         plugin.getSysInjector().getInstance(new Key<DynamicMap<CodeOwnerBackend>>() {});
+    transientCodeOwnerConfigCacheProvider =
+        plugin.getSysInjector().getInstance(new Key<Provider<TransientCodeOwnerConfigCache>>() {});
+    testPathExpressions = plugin.getSysInjector().getInstance(TestPathExpressions.class);
   }
 
   @Test
   public void createPathCodeOwnersForCodeOwnerConfig() throws Exception {
     PathCodeOwners pathCodeOwners =
-        pathCodeOwnersFactory.create(
+        pathCodeOwnersFactory.createWithoutCache(
             createCodeOwnerBuilder().build(), Paths.get("/foo/bar/baz.md"));
     assertThat(pathCodeOwners).isNotNull();
   }
@@ -82,7 +89,7 @@
         assertThrows(
             NullPointerException.class,
             () ->
-                pathCodeOwnersFactory.create(
+                pathCodeOwnersFactory.createWithoutCache(
                     /* codeOwnerConfig= */ null, Paths.get("/foo/bar/baz.md")));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerConfig");
   }
@@ -93,7 +100,9 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () -> pathCodeOwnersFactory.create(codeOwnerConfig, /* absolutePath= */ null));
+            () ->
+                pathCodeOwnersFactory.createWithoutCache(
+                    codeOwnerConfig, /* absolutePath= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("path");
   }
 
@@ -104,7 +113,8 @@
     IllegalStateException exception =
         assertThrows(
             IllegalStateException.class,
-            () -> pathCodeOwnersFactory.create(codeOwnerConfig, Paths.get(relativePath)));
+            () ->
+                pathCodeOwnersFactory.createWithoutCache(codeOwnerConfig, Paths.get(relativePath)));
     assertThat(exception)
         .hasMessageThat()
         .isEqualTo(String.format("path %s must be absolute", relativePath));
@@ -123,6 +133,7 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             codeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo/bar/baz.md"));
@@ -130,12 +141,28 @@
   }
 
   @Test
+  public void cannotCreatePathCodeOwnersForNullCache() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                pathCodeOwnersFactory.create(
+                    /* transientCodeOwnerConfigCache= */ null,
+                    CodeOwnerConfig.Key.create(
+                        BranchNameKey.create(project, "master"), Paths.get("/")),
+                    projectOperations.project(project).getHead("master"),
+                    Paths.get("/foo/bar/baz.md")));
+    assertThat(npe).hasMessageThat().isEqualTo("transientCodeOwnerConfigCache");
+  }
+
+  @Test
   public void cannotCreatePathCodeOwnersForNullCodeOwnerConfigKey() throws Exception {
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
             () ->
                 pathCodeOwnersFactory.create(
+                    transientCodeOwnerConfigCacheProvider.get(),
                     /* codeOwnerConfigKey= */ null,
                     projectOperations.project(project).getHead("master"),
                     Paths.get("/foo/bar/baz.md")));
@@ -149,6 +176,7 @@
             NullPointerException.class,
             () ->
                 pathCodeOwnersFactory.create(
+                    transientCodeOwnerConfigCacheProvider.get(),
                     CodeOwnerConfig.Key.create(
                         BranchNameKey.create(project, "master"), Paths.get("/")),
                     /* revision= */ null,
@@ -172,6 +200,7 @@
             NullPointerException.class,
             () ->
                 pathCodeOwnersFactory.create(
+                    transientCodeOwnerConfigCacheProvider.get(),
                     codeOwnerConfigKey,
                     projectOperations.project(project).getHead("master"),
                     /* absolutePath= */ null));
@@ -195,6 +224,7 @@
             IllegalStateException.class,
             () ->
                 pathCodeOwnersFactory.create(
+                    transientCodeOwnerConfigCacheProvider.get(),
                     codeOwnerConfigKey,
                     projectOperations.project(project).getHead("master"),
                     Paths.get(relativePath)));
@@ -207,9 +237,10 @@
   public void getEmptyPathCodeOwners() throws Exception {
     CodeOwnerConfig emptyCodeOwnerConfig = createCodeOwnerBuilder().build();
     PathCodeOwners pathCodeOwners =
-        pathCodeOwnersFactory.create(emptyCodeOwnerConfig, Paths.get("/foo/bar/baz.md"));
-    assertThat(pathCodeOwners.resolveCodeOwnerConfig().getPathCodeOwners()).isEmpty();
-    assertThat(pathCodeOwners.resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
+        pathCodeOwnersFactory.createWithoutCache(
+            emptyCodeOwnerConfig, Paths.get("/foo/bar/baz.md"));
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().get().getPathCodeOwners()).isEmpty();
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().get().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -219,8 +250,8 @@
             .addCodeOwnerSet(CodeOwnerSet.createWithoutPathExpressions(admin.email(), user.email()))
             .build();
     PathCodeOwners pathCodeOwners =
-        pathCodeOwnersFactory.create(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
-    assertThat(pathCodeOwners.resolveCodeOwnerConfig().getPathCodeOwners())
+        pathCodeOwnersFactory.createWithoutCache(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
   }
@@ -257,8 +288,8 @@
               .addCodeOwnerSet(nonMatchingCodeOwnerSet)
               .build();
       PathCodeOwners pathCodeOwners =
-          pathCodeOwnersFactory.create(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
-      assertThat(pathCodeOwners.resolveCodeOwnerConfig().getPathCodeOwners())
+          pathCodeOwnersFactory.createWithoutCache(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
+      assertThat(pathCodeOwners.resolveCodeOwnerConfig().get().getPathCodeOwners())
           .comparingElementsUsing(hasEmail())
           .containsExactly(admin.email(), user.email());
     }
@@ -278,8 +309,8 @@
                       .build())
               .build();
       PathCodeOwners pathCodeOwners =
-          pathCodeOwnersFactory.create(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
-      assertThat(pathCodeOwners.resolveCodeOwnerConfig().getPathCodeOwners()).isEmpty();
+          pathCodeOwnersFactory.createWithoutCache(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
+      assertThat(pathCodeOwners.resolveCodeOwnerConfig().get().getPathCodeOwners()).isEmpty();
     }
   }
 
@@ -310,8 +341,8 @@
               .addCodeOwnerSet(globalCodeOwnerSet)
               .build();
       PathCodeOwners pathCodeOwners =
-          pathCodeOwnersFactory.create(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
-      assertThat(pathCodeOwners.resolveCodeOwnerConfig().getPathCodeOwners())
+          pathCodeOwnersFactory.createWithoutCache(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
+      assertThat(pathCodeOwners.resolveCodeOwnerConfig().get().getPathCodeOwners())
           .comparingElementsUsing(hasEmail())
           .containsExactly(admin.email());
     }
@@ -344,8 +375,8 @@
               .addCodeOwnerSet(globalCodeOwnerSet)
               .build();
       PathCodeOwners pathCodeOwners =
-          pathCodeOwnersFactory.create(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
-      assertThat(pathCodeOwners.resolveCodeOwnerConfig().getPathCodeOwners())
+          pathCodeOwnersFactory.createWithoutCache(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
+      assertThat(pathCodeOwners.resolveCodeOwnerConfig().get().getPathCodeOwners())
           .comparingElementsUsing(hasEmail())
           .containsExactly(admin.email(), user.email());
     }
@@ -379,8 +410,8 @@
               .addCodeOwnerSet(perFileCodeOwnerSet2)
               .build();
       PathCodeOwners pathCodeOwners =
-          pathCodeOwnersFactory.create(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
-      assertThat(pathCodeOwners.resolveCodeOwnerConfig().getPathCodeOwners())
+          pathCodeOwnersFactory.createWithoutCache(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
+      assertThat(pathCodeOwners.resolveCodeOwnerConfig().get().getPathCodeOwners())
           .comparingElementsUsing(hasEmail())
           .containsExactly(admin.email(), user.email());
     }
@@ -390,27 +421,27 @@
   public void checkThatParentCodeOwnersAreIgnoredIfCodeOwnerConfigIgnoresParentCodeOwners()
       throws Exception {
     PathCodeOwners pathCodeOwners =
-        pathCodeOwnersFactory.create(
+        pathCodeOwnersFactory.createWithoutCache(
             createCodeOwnerBuilder().setIgnoreParentCodeOwners().build(),
             Paths.get("/foo/bar/baz.md"));
-    assertThat(pathCodeOwners.resolveCodeOwnerConfig().ignoreParentCodeOwners()).isTrue();
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().get().ignoreParentCodeOwners()).isTrue();
   }
 
   @Test
   public void checkThatParentCodeOwnersAreNotIgnoredIfCodeOwnerConfigDoesNotIgnoreParentCodeOwners()
       throws Exception {
     PathCodeOwners pathCodeOwners =
-        pathCodeOwnersFactory.create(
+        pathCodeOwnersFactory.createWithoutCache(
             createCodeOwnerBuilder().setIgnoreParentCodeOwners(false).build(),
             Paths.get("/foo/bar/baz.md"));
-    assertThat(pathCodeOwners.resolveCodeOwnerConfig().ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().get().ignoreParentCodeOwners()).isFalse();
   }
 
   @Test
   public void checkThatParentCodeOwnersAreIgnoredIfMatchingCodeOwnerSetIgnoresParentCodeOwners()
       throws Exception {
     PathCodeOwners pathCodeOwners =
-        pathCodeOwnersFactory.create(
+        pathCodeOwnersFactory.createWithoutCache(
             createCodeOwnerBuilder()
                 .addCodeOwnerSet(
                     CodeOwnerSet.builder()
@@ -419,7 +450,7 @@
                         .build())
                 .build(),
             Paths.get("/foo.md"));
-    assertThat(pathCodeOwners.resolveCodeOwnerConfig().ignoreParentCodeOwners()).isTrue();
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().get().ignoreParentCodeOwners()).isTrue();
   }
 
   @Test
@@ -427,7 +458,7 @@
       checkThatParentCodeOwnersAreNotIgnoredIfNonMatchingCodeOwnerSetIgnoresParentCodeOwners()
           throws Exception {
     PathCodeOwners pathCodeOwners =
-        pathCodeOwnersFactory.create(
+        pathCodeOwnersFactory.createWithoutCache(
             createCodeOwnerBuilder()
                 .addCodeOwnerSet(
                     CodeOwnerSet.builder()
@@ -436,7 +467,7 @@
                         .build())
                 .build(),
             Paths.get("/foo.md"));
-    assertThat(pathCodeOwners.resolveCodeOwnerConfig().ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().get().ignoreParentCodeOwners()).isFalse();
   }
 
   @Test
@@ -456,6 +487,7 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
@@ -463,10 +495,10 @@
 
     // Expectation: we get the global code owner from the importing code owner config, the
     // non-resolveable import is silently ignored
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isTrue();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports()).isTrue();
   }
 
   @Test
@@ -502,6 +534,7 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
@@ -509,10 +542,11 @@
 
     // Expectation: we get the global code owners from the importing and the imported code owner
     // config
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
+        .isFalse();
   }
 
   @Test
@@ -548,6 +582,7 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             rootCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
@@ -555,10 +590,11 @@
 
     // Expectation: we get the matching per-file code owners from the importing and the imported
     // code owner config
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
+        .isFalse();
   }
 
   @Test
@@ -594,6 +630,7 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             rootCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
@@ -602,10 +639,11 @@
     // 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().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
+        .isFalse();
   }
 
   @Test
@@ -642,6 +680,7 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             rootCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
@@ -650,10 +689,11 @@
     // 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().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
+        .isFalse();
   }
 
   @Test
@@ -689,6 +729,7 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             rootCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
@@ -699,11 +740,13 @@
     // 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().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(user.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().ignoreParentCodeOwners()).isTrue();
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().ignoreParentCodeOwners())
+        .isTrue();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
+        .isFalse();
   }
 
   @Test
@@ -739,6 +782,7 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             rootCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
@@ -748,11 +792,13 @@
     // 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().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().ignoreParentCodeOwners()).isFalse();
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().ignoreParentCodeOwners())
+        .isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
+        .isFalse();
   }
 
   @Test
@@ -788,6 +834,7 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             rootCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
@@ -797,11 +844,13 @@
     // 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().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().ignoreParentCodeOwners()).isFalse();
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().ignoreParentCodeOwners())
+        .isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
+        .isFalse();
   }
 
   @Test
@@ -829,6 +878,7 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             rootCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
@@ -836,7 +886,8 @@
 
     // Expectation: ignoreParentCodeOwners is true because the ignoreParentCodeOwners flag in the
     // imported code owner config is set to true
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().ignoreParentCodeOwners()).isTrue();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().ignoreParentCodeOwners())
+        .isTrue();
   }
 
   @Test
@@ -866,6 +917,7 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             rootCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
@@ -873,7 +925,8 @@
 
     // 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().resolveCodeOwnerConfig().ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().ignoreParentCodeOwners())
+        .isFalse();
   }
 
   @Test
@@ -924,6 +977,7 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             rootCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
@@ -931,10 +985,11 @@
 
     // 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().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email(), user2.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
+        .isFalse();
   }
 
   @Test
@@ -982,6 +1037,7 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             rootCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
@@ -990,10 +1046,11 @@
     // 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().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
+        .isFalse();
   }
 
   @Test
@@ -1034,6 +1091,7 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
@@ -1041,7 +1099,7 @@
 
     // Expectation: we get the global code owners from the importing and the imported code owner
     // config
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
   }
@@ -1072,13 +1130,14 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             rootCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
   }
@@ -1120,12 +1179,16 @@
         .update();
 
     Optional<PathCodeOwners> pathCodeOwners =
-        pathCodeOwnersFactory.create(rootCodeOwnerConfigKey, oldRevision, Paths.get("/foo.md"));
+        pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
+            rootCodeOwnerConfigKey,
+            oldRevision,
+            Paths.get("/foo.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
     // as they were defined at oldRevision
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
   }
@@ -1155,16 +1218,18 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             rootCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo/bar/baz.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
+        .isFalse();
   }
 
   @Test
@@ -1185,16 +1250,17 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             rootCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo/bar/baz.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing code owner config
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isTrue();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports()).isTrue();
   }
 
   @Test
@@ -1228,6 +1294,7 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             rootCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo/bar/baz.md"));
@@ -1236,10 +1303,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().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isTrue();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports()).isTrue();
   }
 
   @Test
@@ -1261,16 +1328,17 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             rootCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo/bar/baz.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing code owner config
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isTrue();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports()).isTrue();
   }
 
   @Test
@@ -1302,16 +1370,18 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             rootCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo/bar/baz.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
+        .isFalse();
   }
 
   @Test
@@ -1350,16 +1420,18 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             rootCodeOwnerConfigKey,
             projectOperations.project(project).getHead(branchName),
             Paths.get("/foo.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
+        .isFalse();
   }
 
   @Test
@@ -1394,16 +1466,18 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             rootCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo/bar/baz.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
+        .isFalse();
   }
 
   @Test
@@ -1440,16 +1514,18 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             rootCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo/bar/baz.md"));
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
+        .isFalse();
   }
 
   @Test
@@ -1474,6 +1550,7 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
@@ -1481,10 +1558,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().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isTrue();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports()).isTrue();
   }
 
   @Test
@@ -1526,6 +1603,7 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             importingCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
@@ -1534,13 +1612,15 @@
     // 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().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
 
     // Expectation: the ignoreParentCodeOwners flag from the imported code owner config is ignored
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().ignoreParentCodeOwners()).isFalse();
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().ignoreParentCodeOwners())
+        .isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
+        .isFalse();
   }
 
   @Test
@@ -1587,6 +1667,7 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             rootCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
@@ -1594,10 +1675,11 @@
 
     // 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().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email(), user2.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
+        .isFalse();
   }
 
   @Test
@@ -1646,6 +1728,7 @@
 
     Optional<PathCodeOwners> pathCodeOwners =
         pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
             rootCodeOwnerConfigKey,
             projectOperations.project(project).getHead("master"),
             Paths.get("/foo.md"));
@@ -1654,10 +1737,104 @@
     // 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().resolveCodeOwnerConfig().getPathCodeOwners())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
-    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
+        .isFalse();
+  }
+
+  @Test
+  public void onlyMatchingTransitivePerFileImportsAreImported() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    // create importing config with global import
+    CodeOwnerConfig.Key rootCodeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addImport(
+                CodeOwnerConfigReference.create(
+                    CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/bar/OWNERS"))
+            .create();
+
+    // create imported config with 2 per file imports, one for *.md files and one for *.txt
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/bar/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression("*.md")
+                .addImport(
+                    CodeOwnerConfigReference.create(
+                        CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/md/OWNERS"))
+                .build())
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression("*.txt")
+                .addImport(
+                    CodeOwnerConfigReference.create(
+                        CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/txt/OWNERS"))
+                .build())
+        .create();
+
+    // create config with global code owner that is imported by the imported config for *.md files
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/md/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    // create config with global code owner that is imported by the imported config for *.txt files
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/txt/")
+        .addCodeOwnerEmail(user2.email())
+        .create();
+
+    // Expectation for foo.xyz file: code owners is empty since foo.xyz neither matches *.md nor
+    // *.txt
+    Optional<PathCodeOwners> pathCodeOwners =
+        pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
+            rootCodeOwnerConfigKey,
+            projectOperations.project(project).getHead("master"),
+            Paths.get("/foo.xyz"));
+    assertThat(pathCodeOwners).isPresent();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners()).isEmpty();
+
+    // Expectation for foo.md file: code owners contains only user since foo.md only matches *.md
+    pathCodeOwners =
+        pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
+            rootCodeOwnerConfigKey,
+            projectOperations.project(project).getHead("master"),
+            Paths.get("/foo.md"));
+    assertThat(pathCodeOwners).isPresent();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+        .comparingElementsUsing(hasEmail())
+        .containsExactly(user.email());
+
+    // Expectation for foo.txt file: code owners contains only user2 since foo.txt only matches
+    // *.txt
+    pathCodeOwners =
+        pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
+            rootCodeOwnerConfigKey,
+            projectOperations.project(project).getHead("master"),
+            Paths.get("/foo.txt"));
+    assertThat(pathCodeOwners).isPresent();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+        .comparingElementsUsing(hasEmail())
+        .containsExactly(user2.email());
   }
 
   @Test
@@ -1778,6 +1955,117 @@
         .isTrue();
   }
 
+  @Test
+  public void perFileRuleThatIgnoresGlobalCodeOwnersCanImportGlobalCodeOwnersFromOtherFile()
+      throws Exception {
+    // create importing config that:
+    // * has a global code owner
+    // * has a per-file import for md files
+    // * ignores global and parent code owners for md files
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(admin.email())
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .addPathExpression(testPathExpressions.matchFileType("md"))
+                    .setIgnoreGlobalAndParentCodeOwners()
+                    .addImport(
+                        CodeOwnerConfigReference.create(
+                            CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/bar/OWNERS"))
+                    .build())
+            .create();
+
+    // create imported config with a global code owner
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/bar/")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    Optional<PathCodeOwners> pathCodeOwners =
+        pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
+            importingCodeOwnerConfigKey,
+            projectOperations.project(project).getHead("master"),
+            Paths.get("/foo.md"));
+    assertThat(pathCodeOwners).isPresent();
+
+    // Expectation: we get the global code owner from the imported code owner config (since it is
+    // imported by a matching per-file rule), the global code owner from the importing code owner
+    // config is ignored (since the matching per-file rule ignores parent and global code owners)
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+        .comparingElementsUsing(hasEmail())
+        .containsExactly(user.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
+        .isFalse();
+  }
+
+  @Test
+  public void
+      perFileRuleThatIsImportedByAGlobalImportIsRespectedIfALocalPerFileRuleIgnoresGlobalCodeOwners()
+          throws Exception {
+    TestAccount user2 = accountCreator.user2();
+    TestAccount user3 =
+        accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
+
+    // create importing config that has a global import with mode ALL and a per-file rule for md
+    // files that ignores global and parent code owners
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addCodeOwnerEmail(admin.email())
+            .addImport(
+                CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS"))
+            .addCodeOwnerSet(
+                CodeOwnerSet.builder()
+                    .addPathExpression(testPathExpressions.matchFileType("md"))
+                    .setIgnoreGlobalAndParentCodeOwners()
+                    .addCodeOwnerEmail(user.email())
+                    .build())
+            .create();
+
+    // create imported config that has a matching per-file rule
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/bar/")
+        .addCodeOwnerEmail(user2.email())
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression(testPathExpressions.matchFileType("md"))
+                .addCodeOwnerEmail(user3.email())
+                .build())
+        .create();
+
+    Optional<PathCodeOwners> pathCodeOwners =
+        pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
+            importingCodeOwnerConfigKey,
+            projectOperations.project(project).getHead("master"),
+            Paths.get("/foo.md"));
+    assertThat(pathCodeOwners).isPresent();
+
+    // Expectation: we get the code owner from the matching per-file rule in the importing code
+    // owner config and the code owner from the matching per-file rule in the imported code owner
+    // config, the global code owners are ignored since there is a matching per-file rule that
+    // ignores parent and global code owners
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().getPathCodeOwners())
+        .comparingElementsUsing(hasEmail())
+        .containsExactly(user.email(), user3.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().get().hasUnresolvedImports())
+        .isFalse();
+  }
+
   private CodeOwnerConfig.Builder createCodeOwnerBuilder() {
     return CodeOwnerConfig.builder(
         CodeOwnerConfig.Key.create(BranchNameKey.create(project, "master"), Paths.get("/")),
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/UnresolvedImportTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/UnresolvedImportTest.java
new file mode 100644
index 0000000..ce0494f
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/UnresolvedImportTest.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import org.junit.Test;
+
+/** Tests for {@link UnresolvedImport}. */
+public class UnresolvedImportTest extends AbstractAutoValueTest {
+  @Test
+  public void toStringIncludesAllData() throws Exception {
+    UnresolvedImport unresolvedImport =
+        UnresolvedImport.create(
+            CodeOwnerConfig.Key.create(project, "master", "/"),
+            CodeOwnerConfig.Key.create(project, "master", "/bar/"),
+            CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/bar/OWNERS"),
+            "test message");
+    assertThatToStringIncludesAllData(unresolvedImport, UnresolvedImport.class);
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/config/AbstractRequiredApprovalConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/AbstractRequiredApprovalConfigTest.java
similarity index 84%
rename from javatests/com/google/gerrit/plugins/codeowners/config/AbstractRequiredApprovalConfigTest.java
rename to javatests/com/google/gerrit/plugins/codeowners/backend/config/AbstractRequiredApprovalConfigTest.java
index 42a32dd..a8b6f0b 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/config/AbstractRequiredApprovalConfigTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/AbstractRequiredApprovalConfigTest.java
@@ -12,10 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.config;
+package com.google.gerrit.plugins.codeowners.backend.config;
 
 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.backend.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;
@@ -24,14 +24,20 @@
 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 org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
-/** Tests for subclasses of {@link AbstractRequiredApprovalConfig}. */
+/**
+ * Tests for subclasses of {@link
+ * com.google.gerrit.plugins.codeowners.backend.config.AbstractRequiredApprovalConfig}.
+ */
 public abstract class AbstractRequiredApprovalConfigTest extends AbstractCodeOwnersTest {
-  /** Must return the {@link AbstractRequiredApprovalConfig} that should be tested. */
+  /**
+   * Must return the {@link
+   * com.google.gerrit.plugins.codeowners.backend.config.AbstractRequiredApprovalConfig} that should
+   * be tested.
+   */
   protected abstract AbstractRequiredApprovalConfig getRequiredApprovalConfig();
 
   protected void testCannotGetIfGlobalConfigIsInvalid(String invalidValue) throws Exception {
@@ -122,9 +128,7 @@
             () ->
                 getRequiredApprovalConfig()
                     .validateProjectLevelConfig(
-                        /* projectState= */ null,
-                        "code-owners.config",
-                        new ProjectLevelConfig.Bare("code-owners.config")));
+                        /* projectState= */ null, "code-owners.config", new Config()));
     assertThat(npe).hasMessageThat().isEqualTo("projectState");
   }
 
@@ -136,10 +140,7 @@
             NullPointerException.class,
             () ->
                 getRequiredApprovalConfig()
-                    .validateProjectLevelConfig(
-                        projectState,
-                        /* fileName= */ null,
-                        new ProjectLevelConfig.Bare("code-owners.config")));
+                    .validateProjectLevelConfig(projectState, /* fileName= */ null, new Config()));
     assertThat(npe).hasMessageThat().isEqualTo("fileName");
   }
 
@@ -161,23 +162,19 @@
     ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
     ImmutableList<CommitValidationMessage> commitValidationMessage =
         getRequiredApprovalConfig()
-            .validateProjectLevelConfig(
-                projectState,
-                "code-owners.config",
-                new ProjectLevelConfig.Bare("code-owners.config"));
+            .validateProjectLevelConfig(projectState, "code-owners.config", new Config());
     assertThat(commitValidationMessage).isEmpty();
   }
 
   @Test
   public void validateValidProjectLevelConfig() throws Exception {
     ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
-    ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config");
-    cfg.getConfig()
-        .setString(
-            SECTION_CODE_OWNERS,
-            /* subsection= */ null,
-            getRequiredApprovalConfig().getConfigKey(),
-            "Code-Review+2");
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        getRequiredApprovalConfig().getConfigKey(),
+        "Code-Review+2");
     ImmutableList<CommitValidationMessage> commitValidationMessage =
         getRequiredApprovalConfig()
             .validateProjectLevelConfig(projectState, "code-owners.config", cfg);
@@ -187,13 +184,12 @@
   @Test
   public void validateInvalidProjectLevelConfig() throws Exception {
     ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
-    ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config");
-    cfg.getConfig()
-        .setString(
-            SECTION_CODE_OWNERS,
-            /* subsection= */ null,
-            getRequiredApprovalConfig().getConfigKey(),
-            "INVALID");
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        getRequiredApprovalConfig().getConfigKey(),
+        "INVALID");
     ImmutableList<CommitValidationMessage> commitValidationMessage =
         getRequiredApprovalConfig()
             .validateProjectLevelConfig(projectState, "code-owners.config", cfg);
diff --git a/javatests/com/google/gerrit/plugins/codeowners/config/BUILD b/javatests/com/google/gerrit/plugins/codeowners/backend/config/BUILD
similarity index 80%
rename from javatests/com/google/gerrit/plugins/codeowners/config/BUILD
rename to javatests/com/google/gerrit/plugins/codeowners/backend/config/BUILD
index 7f1985a..9dc820d 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/config/BUILD
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/BUILD
@@ -13,8 +13,9 @@
     group = "config",
     deps = [
         ":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/backend",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/common",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing",
     ],
 )
@@ -29,8 +30,8 @@
         "//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/backend",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing",
     ],
 )
diff --git a/javatests/com/google/gerrit/plugins/codeowners/config/BackendConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/BackendConfigTest.java
similarity index 88%
rename from javatests/com/google/gerrit/plugins/codeowners/config/BackendConfigTest.java
rename to javatests/com/google/gerrit/plugins/codeowners/backend/config/BackendConfigTest.java
index 3d7f709..10ef51c 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/config/BackendConfigTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/BackendConfigTest.java
@@ -12,11 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.config;
+package com.google.gerrit.plugins.codeowners.backend.config;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.plugins.codeowners.config.BackendConfig.KEY_BACKEND;
-import static com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
+import static com.google.gerrit.plugins.codeowners.backend.config.BackendConfig.KEY_BACKEND;
+import static com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
@@ -30,12 +30,11 @@
 import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.git.validators.ValidationMessage;
-import com.google.gerrit.server.project.ProjectLevelConfig;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
 import org.junit.Test;
 
-/** Tests for {@link BackendConfig}. */
+/** Tests for {@link com.google.gerrit.plugins.codeowners.backend.config.BackendConfig}. */
 public class BackendConfigTest extends AbstractCodeOwnersTest {
   private BackendConfig backendConfig;
 
@@ -191,9 +190,7 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () ->
-                backendConfig.validateProjectLevelConfig(
-                    null, new ProjectLevelConfig.Bare("code-owners.config")));
+            () -> backendConfig.validateProjectLevelConfig(null, new Config()));
     assertThat(npe).hasMessageThat().isEqualTo("fileName");
   }
 
@@ -209,17 +206,15 @@
   @Test
   public void validateEmptyProjectLevelConfig() throws Exception {
     ImmutableList<CommitValidationMessage> commitValidationMessage =
-        backendConfig.validateProjectLevelConfig(
-            "code-owners.config", new ProjectLevelConfig.Bare("code-owners.config"));
+        backendConfig.validateProjectLevelConfig("code-owners.config", new Config());
     assertThat(commitValidationMessage).isEmpty();
   }
 
   @Test
   public void validateValidProjectLevelConfig() throws Exception {
-    ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config");
-    cfg.getConfig()
-        .setString(
-            SECTION_CODE_OWNERS, null, KEY_BACKEND, CodeOwnerBackendId.FIND_OWNERS.getBackendId());
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS, null, KEY_BACKEND, CodeOwnerBackendId.FIND_OWNERS.getBackendId());
     ImmutableList<CommitValidationMessage> commitValidationMessage =
         backendConfig.validateProjectLevelConfig("code-owners.config", cfg);
     assertThat(commitValidationMessage).isEmpty();
@@ -227,8 +222,8 @@
 
   @Test
   public void validateInvalidProjectLevelConfig_invalidProjectConfiguration() throws Exception {
-    ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config");
-    cfg.getConfig().setString(SECTION_CODE_OWNERS, null, KEY_BACKEND, "INVALID");
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, null, KEY_BACKEND, "INVALID");
     ImmutableList<CommitValidationMessage> commitValidationMessages =
         backendConfig.validateProjectLevelConfig("code-owners.config", cfg);
     assertThat(commitValidationMessages).hasSize(1);
@@ -243,8 +238,8 @@
 
   @Test
   public void validateInvalidProjectLevelConfig_invalidBranchConfiguration() throws Exception {
-    ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config");
-    cfg.getConfig().setString(SECTION_CODE_OWNERS, "someBranch", KEY_BACKEND, "INVALID");
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, "someBranch", KEY_BACKEND, "INVALID");
     ImmutableList<CommitValidationMessage> commitValidationMessages =
         backendConfig.validateProjectLevelConfig("code-owners.config", cfg);
     assertThat(commitValidationMessages).hasSize(1);
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigTest.java
new file mode 100644
index 0000000..9ca0339
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigTest.java
@@ -0,0 +1,476 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+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;
+
+/** Tests for {@link CodeOwnersPluginConfig}. */
+public class CodeOwnersPluginConfigTest extends AbstractCodeOwnersTest {
+  private static final String SECTION = CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
+  private static final String SUBSECTION = "subsection";
+  private static final String SUBSECTION_2 = "subsection2";
+  private static final String SUBSECTION_3 = "subsection3";
+  private static final String KEY = "key";
+  private static final String VALUE = "foo";
+  private static final String VALUE_2 = "bar";
+  private static final String VALUE_3 = "baz";
+  private static final String VALUE_4 = "foo_bar";
+  private static final String VALUE_5 = "foo_baz";
+  private static final String VALUE_6 = "bar_foo";
+
+  @Inject private ProjectOperations projectOperations;
+
+  private CodeOwnersPluginConfig.Factory codeOwnersPluginConfigFactory;
+  private Project.NameKey parent;
+
+  @Before
+  public void setUpCodeOwnersPlugin() throws Exception {
+    codeOwnersPluginConfigFactory =
+        plugin.getSysInjector().getInstance(CodeOwnersPluginConfig.Factory.class);
+  }
+
+  @Before
+  public void setUpProject() throws Exception {
+    parent = project;
+    project = projectOperations.newProject().parent(parent).create();
+  }
+
+  @Test
+  public void getSingleValue_noValueSet() throws Exception {
+    assertThat(cfg().getString(SECTION, /* subsection= */ null, KEY)).isNull();
+  }
+
+  @Test
+  public void getSingleValue_singleValueSet() throws Exception {
+    setSingleValue(project, VALUE);
+    assertThat(cfg().getString(SECTION, /* subsection= */ null, KEY)).isEqualTo(VALUE);
+  }
+
+  @Test
+  public void getSingleValue_multiValueSet() throws Exception {
+    setMultiValue(project, VALUE, VALUE_2, VALUE_3);
+
+    // last value takes precedence
+    assertThat(cfg().getString(SECTION, /* subsection= */ null, KEY)).isEqualTo(VALUE_3);
+  }
+
+  @Test
+  public void getSingleValue_singleValueSetForParent() throws Exception {
+    setSingleValue(parent, VALUE);
+    assertThat(cfg().getString(SECTION, /* subsection= */ null, KEY)).isEqualTo(VALUE);
+  }
+
+  @Test
+  public void getSingleValue_multiValueSetForParent() throws Exception {
+    setMultiValue(parent, VALUE, VALUE_2, VALUE_3);
+
+    // last value takes precedence
+    assertThat(cfg().getString(SECTION, /* subsection= */ null, KEY)).isEqualTo(VALUE_3);
+  }
+
+  @Test
+  public void getSingleValue_valueOverridesSingleParentValues() throws Exception {
+    setSingleValue(allProjects, VALUE);
+    setSingleValue(parent, VALUE_2);
+    setSingleValue(project, VALUE_3);
+    assertThat(cfg().getString(SECTION, /* subsection= */ null, KEY)).isEqualTo(VALUE_3);
+  }
+
+  @Test
+  public void getSingleValue_valueOverridesMultiParentValues() throws Exception {
+    setMultiValue(allProjects, VALUE, VALUE_2);
+    setMultiValue(parent, VALUE_3, VALUE_4);
+    setSingleValue(project, VALUE_5);
+    assertThat(cfg().getString(SECTION, /* subsection= */ null, KEY)).isEqualTo(VALUE_5);
+  }
+
+  @Test
+  public void getSingleValue_unsetSingleParentValues() throws Exception {
+    setSingleValue(allProjects, VALUE);
+    setSingleValue(parent, VALUE_2);
+    setSingleValue(project, "");
+    assertThat(cfg().getString(SECTION, /* subsection= */ null, KEY)).isNull();
+  }
+
+  @Test
+  public void getSingleValue_unsetMultiParentValues() throws Exception {
+    setMultiValue(allProjects, VALUE, VALUE_2);
+    setMultiValue(parent, VALUE_3, VALUE_4);
+    setSingleValue(project, "");
+    assertThat(cfg().getString(SECTION, /* subsection= */ null, KEY)).isNull();
+  }
+
+  @Test
+  public void getSingleValueFromSubsection_noValueSet() throws Exception {
+    assertThat(cfg().getString(SECTION, SUBSECTION, KEY)).isNull();
+  }
+
+  @Test
+  public void getSingleValueFromSubsection_singleValueSet() throws Exception {
+    setSingleValueForSubsection(project, SUBSECTION, VALUE);
+    assertThat(cfg().getString(SECTION, SUBSECTION, KEY)).isEqualTo(VALUE);
+  }
+
+  @Test
+  public void getSingleValueFromSubsection_multiValueSet() throws Exception {
+    setMultiValueForSubsection(project, SUBSECTION, VALUE, VALUE_2, VALUE_3);
+
+    // last value takes precedence
+    assertThat(cfg().getString(SECTION, SUBSECTION, KEY)).isEqualTo(VALUE_3);
+  }
+
+  @Test
+  public void getSingleValueFromSubsection_singleValueSetForParent() throws Exception {
+    setSingleValueForSubsection(parent, SUBSECTION, VALUE);
+    assertThat(cfg().getString(SECTION, SUBSECTION, KEY)).isEqualTo(VALUE);
+  }
+
+  @Test
+  public void getSingleValueFromSubsection_multiValueSetForParent() throws Exception {
+    setMultiValueForSubsection(parent, SUBSECTION, VALUE, VALUE_2, VALUE_3);
+
+    // last value takes precedence
+    assertThat(cfg().getString(SECTION, SUBSECTION, KEY)).isEqualTo(VALUE_3);
+  }
+
+  @Test
+  public void getSingleValueFromSubsection_valueOverridesSingleParentValues() throws Exception {
+    setSingleValueForSubsection(allProjects, SUBSECTION, VALUE);
+    setSingleValueForSubsection(parent, SUBSECTION, VALUE_2);
+    setSingleValueForSubsection(project, SUBSECTION, VALUE_3);
+    assertThat(cfg().getString(SECTION, SUBSECTION, KEY)).isEqualTo(VALUE_3);
+  }
+
+  @Test
+  public void getSingleValueFromSubsection_valueOverridesMultiParentValues() throws Exception {
+    setMultiValueForSubsection(allProjects, SUBSECTION, VALUE, VALUE_2);
+    setMultiValueForSubsection(parent, SUBSECTION, VALUE_3, VALUE_4);
+    setSingleValueForSubsection(project, SUBSECTION, VALUE_5);
+    assertThat(cfg().getString(SECTION, SUBSECTION, KEY)).isEqualTo(VALUE_5);
+  }
+
+  @Test
+  public void getSingleValueFromSubsection_unsetSingleParentValues() throws Exception {
+    setSingleValueForSubsection(allProjects, SUBSECTION, VALUE);
+    setSingleValueForSubsection(parent, SUBSECTION, VALUE_2);
+    setSingleValueForSubsection(project, SUBSECTION, "");
+    assertThat(cfg().getString(SECTION, SUBSECTION, KEY)).isNull();
+  }
+
+  @Test
+  public void getSingleValueFromSubsection_unsetMultiParentValues() throws Exception {
+    setMultiValueForSubsection(allProjects, SUBSECTION, VALUE, VALUE_2);
+    setMultiValueForSubsection(parent, SUBSECTION, VALUE_3, VALUE_4);
+    setSingleValueForSubsection(project, SUBSECTION, "");
+    assertThat(cfg().getString(SECTION, SUBSECTION, KEY)).isNull();
+  }
+
+  @Test
+  public void getMultiValue_noValueSet() throws Exception {
+    assertThat(cfg().getStringList(SECTION, /* subsection= */ null, KEY)).isEmpty();
+  }
+
+  @Test
+  public void getMultiValue_singleValueSet() throws Exception {
+    setSingleValue(project, VALUE);
+    assertThat(cfg().getStringList(SECTION, /* subsection= */ null, KEY))
+        .asList()
+        .containsExactly(VALUE);
+  }
+
+  @Test
+  public void getMultiValue_multiValueSet() throws Exception {
+    setMultiValue(project, VALUE, VALUE_2, VALUE_3);
+    assertThat(cfg().getStringList(SECTION, /* subsection= */ null, KEY))
+        .asList()
+        .containsExactly(VALUE, VALUE_2, VALUE_3)
+        .inOrder();
+  }
+
+  @Test
+  public void getMultiValue_singleValueSetForParent() throws Exception {
+    setSingleValue(parent, VALUE);
+    assertThat(cfg().getStringList(SECTION, /* subsection= */ null, KEY))
+        .asList()
+        .containsExactly(VALUE);
+  }
+
+  @Test
+  public void getMultiValue_multiValueSetForParent() throws Exception {
+    setMultiValue(parent, VALUE, VALUE_2, VALUE_3);
+    assertThat(cfg().getStringList(SECTION, /* subsection= */ null, KEY))
+        .asList()
+        .containsExactly(VALUE, VALUE_2, VALUE_3)
+        .inOrder();
+  }
+
+  @Test
+  public void getMultiValue_singleValueExtendsSingleParentValues() throws Exception {
+    setSingleValue(allProjects, VALUE);
+    setSingleValue(parent, VALUE_2);
+    setSingleValue(project, VALUE_3);
+    assertThat(cfg().getStringList(SECTION, /* subsection= */ null, KEY))
+        .asList()
+        .containsExactly(VALUE, VALUE_2, VALUE_3)
+        .inOrder();
+  }
+
+  @Test
+  public void getMultiValue_singleValueOverridesMultiParentValues() throws Exception {
+    setMultiValue(allProjects, VALUE, VALUE_2);
+    setMultiValue(parent, VALUE_3, VALUE_4);
+    setSingleValue(project, VALUE_5);
+    assertThat(cfg().getStringList(SECTION, /* subsection= */ null, KEY))
+        .asList()
+        .containsExactly(VALUE, VALUE_2, VALUE_3, VALUE_4, VALUE_5)
+        .inOrder();
+  }
+
+  @Test
+  public void getMultiValue_multiValueExtendsSingleParentValues() throws Exception {
+    setSingleValue(allProjects, VALUE);
+    setSingleValue(parent, VALUE_2);
+    setMultiValue(project, VALUE_3, VALUE_4);
+    assertThat(cfg().getStringList(SECTION, /* subsection= */ null, KEY))
+        .asList()
+        .containsExactly(VALUE, VALUE_2, VALUE_3, VALUE_4)
+        .inOrder();
+  }
+
+  @Test
+  public void getMultiValue_multiValueExtendsMultiParentValues() throws Exception {
+    setMultiValue(allProjects, VALUE, VALUE_2);
+    setMultiValue(parent, VALUE_3, VALUE_4);
+    setMultiValue(project, VALUE_5, VALUE_6);
+    assertThat(cfg().getStringList(SECTION, /* subsection= */ null, KEY))
+        .asList()
+        .containsExactly(VALUE, VALUE_2, VALUE_3, VALUE_4, VALUE_5, VALUE_6)
+        .inOrder();
+  }
+
+  @Test
+  public void getMultiValue_multiValueExtendsMultiParentValues_withDuplicates() throws Exception {
+    setMultiValue(allProjects, VALUE, VALUE_2);
+    setMultiValue(parent, VALUE_3);
+    setMultiValue(project, VALUE, VALUE_2);
+    assertThat(cfg().getStringList(SECTION, /* subsection= */ null, KEY))
+        .asList()
+        .containsExactly(VALUE, VALUE_2, VALUE_3, VALUE, VALUE_2)
+        .inOrder();
+  }
+
+  @Test
+  public void getMultiValue_cannotUnsetSingleParentValues() throws Exception {
+    setSingleValue(allProjects, VALUE);
+    setSingleValue(parent, VALUE_2);
+    setSingleValue(project, "");
+
+    // the empty string is returned as null value in the list
+    assertThat(cfg().getStringList(SECTION, /* subsection= */ null, KEY))
+        .asList()
+        .containsExactly(VALUE, VALUE_2, null)
+        .inOrder();
+  }
+
+  @Test
+  public void getMultiValue_cannotUnsetMultiParentValues() throws Exception {
+    setMultiValue(allProjects, VALUE, VALUE_2);
+    setMultiValue(parent, VALUE_3, VALUE_4);
+    setSingleValue(project, "");
+
+    // the empty string is returned as null value in the list
+    assertThat(cfg().getStringList(SECTION, /* subsection= */ null, KEY))
+        .asList()
+        .containsExactly(VALUE, VALUE_2, VALUE_3, VALUE_4, null)
+        .inOrder();
+  }
+
+  @Test
+  public void getMultiValueFromSubsection_noValueSet() throws Exception {
+    assertThat(cfg().getStringList(SECTION, SUBSECTION, KEY)).isEmpty();
+  }
+
+  @Test
+  public void getMultiValueFromSubsection_singleValueSet() throws Exception {
+    setSingleValueForSubsection(project, SUBSECTION, VALUE);
+    assertThat(cfg().getStringList(SECTION, SUBSECTION, KEY)).asList().containsExactly(VALUE);
+  }
+
+  @Test
+  public void getMultiValueFromSubsection_multiValueSet() throws Exception {
+    setMultiValueForSubsection(project, SUBSECTION, VALUE, VALUE_2, VALUE_3);
+    assertThat(cfg().getStringList(SECTION, SUBSECTION, KEY))
+        .asList()
+        .containsExactly(VALUE, VALUE_2, VALUE_3)
+        .inOrder();
+  }
+
+  @Test
+  public void getMultiValueFromSubsection_singleValueSetForParent() throws Exception {
+    setSingleValueForSubsection(parent, SUBSECTION, VALUE);
+    assertThat(cfg().getStringList(SECTION, SUBSECTION, KEY)).asList().containsExactly(VALUE);
+  }
+
+  @Test
+  public void getMultiValueFromSubsection_multiValueSetForParent() throws Exception {
+    setMultiValueForSubsection(parent, SUBSECTION, VALUE, VALUE_2, VALUE_3);
+    assertThat(cfg().getStringList(SECTION, SUBSECTION, KEY))
+        .asList()
+        .containsExactly(VALUE, VALUE_2, VALUE_3)
+        .inOrder();
+  }
+
+  @Test
+  public void getMultiValueFromSubsection_singleValueExtendsSingleParentValues() throws Exception {
+    setSingleValueForSubsection(allProjects, SUBSECTION, VALUE);
+    setSingleValueForSubsection(parent, SUBSECTION, VALUE_2);
+    setSingleValueForSubsection(project, SUBSECTION, VALUE_3);
+    assertThat(cfg().getStringList(SECTION, SUBSECTION, KEY))
+        .asList()
+        .containsExactly(VALUE, VALUE_2, VALUE_3)
+        .inOrder();
+  }
+
+  @Test
+  public void getMultiValueFromSubsection_singleValueOverridesMultiParentValues() throws Exception {
+    setMultiValueForSubsection(allProjects, SUBSECTION, VALUE, VALUE_2);
+    setMultiValueForSubsection(parent, SUBSECTION, VALUE_3, VALUE_4);
+    setSingleValueForSubsection(project, SUBSECTION, VALUE_5);
+    assertThat(cfg().getStringList(SECTION, SUBSECTION, KEY))
+        .asList()
+        .containsExactly(VALUE, VALUE_2, VALUE_3, VALUE_4, VALUE_5)
+        .inOrder();
+  }
+
+  @Test
+  public void getMultiValueFromSubsection_multiValueExtendsSingleParentValues() throws Exception {
+    setSingleValueForSubsection(allProjects, SUBSECTION, VALUE);
+    setSingleValueForSubsection(parent, SUBSECTION, VALUE_2);
+    setMultiValueForSubsection(project, SUBSECTION, VALUE_3, VALUE_4);
+    assertThat(cfg().getStringList(SECTION, SUBSECTION, KEY))
+        .asList()
+        .containsExactly(VALUE, VALUE_2, VALUE_3, VALUE_4)
+        .inOrder();
+  }
+
+  @Test
+  public void getMultiValueFromSubsection_multiValueExtendsMultiParentValues() throws Exception {
+    setMultiValueForSubsection(allProjects, SUBSECTION, VALUE, VALUE_2);
+    setMultiValueForSubsection(parent, SUBSECTION, VALUE_3, VALUE_4);
+    setMultiValueForSubsection(project, SUBSECTION, VALUE_5, VALUE_6);
+    assertThat(cfg().getStringList(SECTION, SUBSECTION, KEY))
+        .asList()
+        .containsExactly(VALUE, VALUE_2, VALUE_3, VALUE_4, VALUE_5, VALUE_6)
+        .inOrder();
+  }
+
+  @Test
+  public void getMultiValueFromSubsection_cannotUnsetSingleParentValues() throws Exception {
+    setSingleValueForSubsection(allProjects, SUBSECTION, VALUE);
+    setSingleValueForSubsection(parent, SUBSECTION, VALUE_2);
+    setSingleValueForSubsection(project, SUBSECTION, "");
+
+    // the empty string is returned as null value in the list
+    assertThat(cfg().getStringList(SECTION, SUBSECTION, KEY))
+        .asList()
+        .containsExactly(VALUE, VALUE_2, null)
+        .inOrder();
+  }
+
+  @Test
+  public void getMultiValueFromSubsection_cannotUnsetMultiParentValues() throws Exception {
+    setMultiValueForSubsection(allProjects, SUBSECTION, VALUE, VALUE_2);
+    setMultiValueForSubsection(parent, SUBSECTION, VALUE_3, VALUE_4);
+    setSingleValueForSubsection(project, SUBSECTION, "");
+
+    // the empty string is returned as null value in the list
+    assertThat(cfg().getStringList(SECTION, SUBSECTION, KEY))
+        .asList()
+        .containsExactly(VALUE, VALUE_2, VALUE_3, VALUE_4, null)
+        .inOrder();
+  }
+
+  @Test
+  public void getSubsections() throws Exception {
+    setSingleValueForSubsection(allProjects, SUBSECTION, VALUE);
+    setSingleValueForSubsection(parent, SUBSECTION_2, VALUE_2);
+    setSingleValueForSubsection(project, SUBSECTION_3, VALUE_3);
+    assertThat(cfg().getSubsections(SECTION))
+        .containsExactly(SUBSECTION, SUBSECTION_2, SUBSECTION_3);
+  }
+
+  @Test
+  public void getEmptySubsections() throws Exception {
+    createConfigWithEmptySubsection(allProjects, SUBSECTION);
+    createConfigWithEmptySubsection(parent, SUBSECTION_2);
+    createConfigWithEmptySubsection(project, SUBSECTION_3);
+    assertThat(cfg().getSubsections(SECTION))
+        .containsExactly(SUBSECTION, SUBSECTION_2, SUBSECTION_3);
+  }
+
+  private Config cfg() {
+    return codeOwnersPluginConfigFactory.create(project).get();
+  }
+
+  private void setSingleValue(Project.NameKey project, String value) throws Exception {
+    setSingleValueForSubsection(project, /* subsection= */ null, value);
+  }
+
+  private void setSingleValueForSubsection(
+      Project.NameKey project, @Nullable String subsection, String value) throws Exception {
+    setCodeOwnersConfig(project, subsection, KEY, value);
+  }
+
+  private void setMultiValue(Project.NameKey project, String... values) throws Exception {
+    setMultiValueForSubsection(project, /* subsection= */ null, values);
+  }
+
+  private void setMultiValueForSubsection(
+      Project.NameKey project, @Nullable String subsection, String... values) throws Exception {
+    setCodeOwnersConfig(project, subsection, KEY, ImmutableList.copyOf(values));
+  }
+
+  private void createConfigWithEmptySubsection(Project.NameKey project, String subsection)
+      throws Exception {
+    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", String.format("[%s \"%s\"]", SECTION, subsection)));
+    }
+    projectCache.evict(project);
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigValidatorTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigValidatorTest.java
new file mode 100644
index 0000000..e05323c
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigValidatorTest.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests for {@link CodeOwnersPluginConfigValidator}.
+ *
+ * <p>Integration tests for {@link CodeOwnersPluginConfigValidator} are contained in {@code
+ * com.google.gerrit.plugins.codeowners.acceptance.api.CodeOwnersPluginConfigValidatorIT}.
+ */
+public class CodeOwnersPluginConfigValidatorTest extends AbstractCodeOwnersTest {
+  private CodeOwnersPluginConfigValidator codeOwnersPluginConfigValidator;
+
+  @Before
+  public void setUpCodeOwnersPlugin() throws Exception {
+    codeOwnersPluginConfigValidator =
+        plugin.getSysInjector().getInstance(CodeOwnersPluginConfigValidator.class);
+  }
+
+  @Test
+  public void failsOnInvalidProjectConfig() throws Exception {
+    Config cfg = new Config();
+    cfg.setEnum(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        GeneralConfig.KEY_FALLBACK_CODE_OWNERS,
+        FallbackCodeOwners.ALL_USERS);
+    RevCommit commit =
+        testRepo
+            .commit()
+            .add("code-owners.config", cfg.toText())
+            .add("project.config", "INVALID")
+            .create();
+    CommitReceivedEvent receiveEvent = new CommitReceivedEvent();
+    receiveEvent.project =
+        projectCache.get(project).orElseThrow(illegalState(project)).getProject();
+    receiveEvent.refName = RefNames.REFS_CONFIG;
+    receiveEvent.commit = commit;
+    receiveEvent.revWalk = testRepo.getRevWalk();
+    receiveEvent.repoConfig = new Config();
+    CommitValidationException exception =
+        assertThrows(
+            CommitValidationException.class,
+            () -> codeOwnersPluginConfigValidator.onCommitReceived(receiveEvent));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "failed to validate file code-owners.config for revision %s in ref %s of project %s",
+                commit.getName(), RefNames.REFS_CONFIG, project));
+    assertThat(exception).hasCauseThat().isInstanceOf(ConfigInvalidException.class);
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigurationTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigurationTest.java
new file mode 100644
index 0000000..28b15a6
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigurationTest.java
@@ -0,0 +1,60 @@
+// 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.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests for {@link
+ * com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration}.
+ */
+public class CodeOwnersPluginConfigurationTest extends AbstractCodeOwnersTest {
+  private CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
+
+  @Before
+  public void setUpCodeOwnersPlugin() throws Exception {
+    codeOwnersPluginConfiguration =
+        plugin.getSysInjector().getInstance(CodeOwnersPluginConfiguration.class);
+  }
+
+  @Test
+  public void cannotGetProjectConfigForNullProject() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> codeOwnersPluginConfiguration.getProjectConfig(/* projectName= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("projectName");
+  }
+
+  @Test
+  public void cannotGetProjectConfigForNonExistingProject() throws Exception {
+    IllegalStateException exception =
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                codeOwnersPluginConfiguration.getProjectConfig(
+                    Project.nameKey("non-existing-project")));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "cannot get code-owners plugin config for non-existing project non-existing-project");
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshotTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshotTest.java
new file mode 100644
index 0000000..e382566
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginGlobalConfigSnapshotTest.java
@@ -0,0 +1,94 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.OptionalSubject.assertThat;
+
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link CodeOwnersPluginGlobalConfigSnapshot}. */
+public class CodeOwnersPluginGlobalConfigSnapshotTest extends AbstractCodeOwnersTest {
+  private CodeOwnersPluginGlobalConfigSnapshot.Factory codeOwnersPluginGlobalConfigSnapshotFactory;
+
+  @Before
+  public void setUpCodeOwnersPlugin() throws Exception {
+    codeOwnersPluginGlobalConfigSnapshotFactory =
+        plugin.getSysInjector().getInstance(CodeOwnersPluginGlobalConfigSnapshot.Factory.class);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableExperimentalRestEndpoints", value = "false")
+  public void checkExperimentalRestEndpointsEnabledThrowsExceptionIfDisabled() throws Exception {
+    MethodNotAllowedException exception =
+        assertThrows(
+            MethodNotAllowedException.class,
+            () -> cfgSnapshot().checkExperimentalRestEndpointsEnabled());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("experimental code owners REST endpoints are disabled");
+  }
+
+  @Test
+  public void experimentalRestEndpointsNotEnabled() throws Exception {
+    assertThat(cfgSnapshot().areExperimentalRestEndpointsEnabled()).isFalse();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableExperimentalRestEndpoints", value = "true")
+  public void experimentalRestEndpointsEnabled() throws Exception {
+    assertThat(cfgSnapshot().areExperimentalRestEndpointsEnabled()).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableExperimentalRestEndpoints", value = "invalid")
+  public void experimentalRestEndpointsNotEnabled_invalidConfig() throws Exception {
+    assertThat(cfgSnapshot().areExperimentalRestEndpointsEnabled()).isFalse();
+  }
+
+  @Test
+  public void codeOwnerConfigCacheSizeIsLimitedByDefault() throws Exception {
+    assertThat(cfgSnapshot().getMaxCodeOwnerConfigCacheSize())
+        .value()
+        .isEqualTo(CodeOwnersPluginGlobalConfigSnapshot.DEFAULT_MAX_CODE_OWNER_CONFIG_CACHE_SIZE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxCodeOwnerConfigCacheSize", value = "0")
+  public void codeOwnerConfigCacheSizeIsUnlimited() throws Exception {
+    assertThat(cfgSnapshot().getMaxCodeOwnerConfigCacheSize()).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxCodeOwnerConfigCacheSize", value = "10")
+  public void codeOwnerConfigCacheSizeIsLimited() throws Exception {
+    assertThat(cfgSnapshot().getMaxCodeOwnerConfigCacheSize()).value().isEqualTo(10);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxCodeOwnerConfigCacheSize", value = "invalid")
+  public void maxCodeOwnerConfigCacheSize_invalidConfig() throws Exception {
+    assertThat(cfgSnapshot().getMaxCodeOwnerConfigCacheSize()).isEmpty();
+  }
+
+  private CodeOwnersPluginGlobalConfigSnapshot cfgSnapshot() {
+    return codeOwnersPluginGlobalConfigSnapshotFactory.create();
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshotTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshotTest.java
new file mode 100644
index 0000000..f155572
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshotTest.java
@@ -0,0 +1,2012 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend.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.CodeOwnerSetSubject.hasEmail;
+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.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedSet;
+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.common.Nullable;
+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;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+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.PathExpressionMatcher;
+import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerConfigValidationPolicy;
+import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.inject.Inject;
+import com.google.inject.Key;
+import com.google.inject.util.Providers;
+import java.nio.file.Path;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link CodeOwnersPluginProjectConfigSnapshot}. */
+public class CodeOwnersPluginProjectConfigSnapshotTest extends AbstractCodeOwnersTest {
+  @Inject private ProjectOperations projectOperations;
+
+  private CodeOwnersPluginProjectConfigSnapshot.Factory
+      codeOwnersPluginProjectConfigSnapshotFactory;
+  private DynamicMap<CodeOwnerBackend> codeOwnerBackends;
+
+  @Before
+  public void setUpCodeOwnersPlugin() throws Exception {
+    codeOwnersPluginProjectConfigSnapshotFactory =
+        plugin.getSysInjector().getInstance(CodeOwnersPluginProjectConfigSnapshot.Factory.class);
+    codeOwnerBackends =
+        plugin.getSysInjector().getInstance(new Key<DynamicMap<CodeOwnerBackend>>() {});
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
+  public void getFileExtensionIfNoneIsConfiguredOnProjectLevel() throws Exception {
+    assertThat(cfgSnapshot().getFileExtension()).value().isEqualTo("foo");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
+  public void fileExtensionOnProjectLevelOverridesDefaultFileExtension() throws Exception {
+    configureFileExtension(project, "bar");
+    assertThat(cfgSnapshot().getFileExtension()).value().isEqualTo("bar");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
+  public void fileExtensionIsInheritedFromParentProject() throws Exception {
+    configureFileExtension(allProjects, "bar");
+    assertThat(cfgSnapshot().getFileExtension()).value().isEqualTo("bar");
+  }
+
+  @Test
+  public void inheritedFileExtensionCanBeOverridden() throws Exception {
+    configureFileExtension(allProjects, "foo");
+    configureFileExtension(project, "bar");
+    assertThat(cfgSnapshot().getFileExtension()).value().isEqualTo("bar");
+  }
+
+  @Test
+  public void getMergeCommitStrategyIfNoneIsConfigured() throws Exception {
+    assertThat(cfgSnapshot().getMergeCommitStrategy())
+        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.mergeCommitStrategy",
+      value = "FILES_WITH_CONFLICT_RESOLUTION")
+  public void getMergeCommitStrategyIfNoneIsConfiguredOnProjectLevel() throws Exception {
+    assertThat(cfgSnapshot().getMergeCommitStrategy())
+        .isEqualTo(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.mergeCommitStrategy",
+      value = "FILES_WITH_CONFLICT_RESOLUTION")
+  public void mergeCommitStrategyOnProjectLevelOverridesGlobalMergeCommitStrategy()
+      throws Exception {
+    configureMergeCommitStrategy(project, MergeCommitStrategy.ALL_CHANGED_FILES);
+    assertThat(cfgSnapshot().getMergeCommitStrategy())
+        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.mergeCommitStrategy",
+      value = "FILES_WITH_CONFLICT_RESOLUTION")
+  public void mergeCommitStrategyIsInheritedFromParentProject() throws Exception {
+    configureMergeCommitStrategy(allProjects, MergeCommitStrategy.ALL_CHANGED_FILES);
+    assertThat(cfgSnapshot().getMergeCommitStrategy())
+        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
+  }
+
+  @Test
+  public void inheritedMergeCommitStrategyCanBeOverridden() throws Exception {
+    configureMergeCommitStrategy(allProjects, MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
+    configureMergeCommitStrategy(project, MergeCommitStrategy.ALL_CHANGED_FILES);
+    assertThat(cfgSnapshot().getMergeCommitStrategy())
+        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
+  }
+
+  @Test
+  public void getFallbackCodeOwnersIfNoneIsConfigured() throws Exception {
+    assertThat(cfgSnapshot().getFallbackCodeOwners()).isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  public void getFallbackCodeOwnersIfNoneIsConfiguredOnProjectLevel() throws Exception {
+    assertThat(cfgSnapshot().getFallbackCodeOwners()).isEqualTo(FallbackCodeOwners.ALL_USERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  public void fallbackCodeOnwersOnProjectLevelOverridesGlobalFallbackCodeOwners() throws Exception {
+    configureFallbackCodeOwners(project, FallbackCodeOwners.NONE);
+    assertThat(cfgSnapshot().getFallbackCodeOwners()).isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  public void fallbackCodeOwnersIsInheritedFromParentProject() throws Exception {
+    configureFallbackCodeOwners(allProjects, FallbackCodeOwners.NONE);
+    assertThat(cfgSnapshot().getFallbackCodeOwners()).isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  public void inheritedFallbackCodeOwnersCanBeOverridden() throws Exception {
+    configureFallbackCodeOwners(allProjects, FallbackCodeOwners.ALL_USERS);
+    configureFallbackCodeOwners(project, FallbackCodeOwners.NONE);
+    assertThat(cfgSnapshot().getFallbackCodeOwners()).isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  public void getGlobalCodeOwnersIfNoneIsConfigured() throws Exception {
+    assertThat(cfgSnapshot().getGlobalCodeOwners()).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.globalCodeOwner",
+      values = {"global-code-owner-1@example.com", "global-code-owner-2@example.com"})
+  public void getGlobalCodeOwnerssIfNoneIsConfiguredOnProjectLevel() throws Exception {
+    TestAccount globalCodeOwner1 =
+        accountCreator.create(
+            "globalCodeOwner1",
+            "global-code-owner-1@example.com",
+            "Global Code Owner 1",
+            /* displayName= */ null);
+    TestAccount globalCodeOwner2 =
+        accountCreator.create(
+            "globalCodeOwner2",
+            "global-code-owner-2@example.com",
+            "Global Code Owner 2",
+            /* displayName= */ null);
+    assertThat(cfgSnapshot().getGlobalCodeOwners())
+        .comparingElementsUsing(hasEmail())
+        .containsExactly(globalCodeOwner1.email(), globalCodeOwner2.email());
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.globalCodeOwner",
+      values = {"global-code-owner-1@example.com", "global-code-owner-2@example.com"})
+  public void globalCodeOwnersOnProjectLevelExtendsGloballyConfiguredGlobalCodeOwners()
+      throws Exception {
+    TestAccount globalCodeOwner1 =
+        accountCreator.create(
+            "globalCodeOwner1",
+            "global-code-owner-1@example.com",
+            "Global Code Owner 1",
+            /* displayName= */ null);
+    TestAccount globalCodeOwner2 =
+        accountCreator.create(
+            "globalCodeOwner2",
+            "global-code-owner-2@example.com",
+            "Global Code Owner 2",
+            /* displayName= */ null);
+    TestAccount globalCodeOwner3 =
+        accountCreator.create(
+            "globalCodeOwner3",
+            "global-code-owner-3@example.com",
+            "Global Code Owner 3",
+            /* displayName= */ null);
+    TestAccount globalCodeOwner4 =
+        accountCreator.create(
+            "globalCodeOwner4",
+            "global-code-owner-4@example.com",
+            "Global Code Owner 4",
+            /* displayName= */ null);
+    configureGlobalCodeOwners(allProjects, globalCodeOwner3.email(), globalCodeOwner4.email());
+    assertThat(cfgSnapshot().getGlobalCodeOwners())
+        .comparingElementsUsing(hasEmail())
+        .containsExactly(
+            globalCodeOwner1.email(),
+            globalCodeOwner2.email(),
+            globalCodeOwner3.email(),
+            globalCodeOwner4.email());
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.globalCodeOwner",
+      values = {"global-code-owner-1@example.com", "global-code-owner-2@example.com"})
+  public void
+      globalCodeOwnersOnProjectLevelExtendsGloballyConfiguredGlobalCodeOwners_duplicatesAreFilteredOut()
+          throws Exception {
+    TestAccount globalCodeOwner1 =
+        accountCreator.create(
+            "globalCodeOwner1",
+            "global-code-owner-1@example.com",
+            "Global Code Owner 1",
+            /* displayName= */ null);
+    TestAccount globalCodeOwner2 =
+        accountCreator.create(
+            "globalCodeOwner2",
+            "global-code-owner-2@example.com",
+            "Global Code Owner 2",
+            /* displayName= */ null);
+    TestAccount globalCodeOwner3 =
+        accountCreator.create(
+            "globalCodeOwner3",
+            "global-code-owner-3@example.com",
+            "Global Code Owner 3",
+            /* displayName= */ null);
+    configureGlobalCodeOwners(allProjects, globalCodeOwner1.email(), globalCodeOwner3.email());
+    assertThat(cfgSnapshot().getGlobalCodeOwners())
+        .comparingElementsUsing(hasEmail())
+        .containsExactly(
+            globalCodeOwner1.email(), globalCodeOwner2.email(), globalCodeOwner3.email());
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.globalCodeOwner",
+      values = {"global-code-owner-1@example.com", "global-code-owner-2@example.com"})
+  public void globalCodeOwnersAreInheritedFromParentProject() throws Exception {
+    TestAccount globalCodeOwner1 =
+        accountCreator.create(
+            "globalCodeOwner1",
+            "global-code-owner-1@example.com",
+            "Global Code Owner 1",
+            /* displayName= */ null);
+    TestAccount globalCodeOwner2 =
+        accountCreator.create(
+            "globalCodeOwner2",
+            "global-code-owner-2@example.com",
+            "Global Code Owner 2",
+            /* displayName= */ null);
+    TestAccount globalCodeOwner3 =
+        accountCreator.create(
+            "globalCodeOwner3",
+            "global-code-owner-3@example.com",
+            "Global Code Owner 3",
+            /* displayName= */ null);
+    TestAccount globalCodeOwner4 =
+        accountCreator.create(
+            "globalCodeOwner4",
+            "global-code-owner-4@example.com",
+            "Global Code Owner 4",
+            /* displayName= */ null);
+    configureGlobalCodeOwners(allProjects, globalCodeOwner3.email(), globalCodeOwner4.email());
+    assertThat(cfgSnapshot().getGlobalCodeOwners())
+        .comparingElementsUsing(hasEmail())
+        .containsExactly(
+            globalCodeOwner1.email(),
+            globalCodeOwner2.email(),
+            globalCodeOwner3.email(),
+            globalCodeOwner4.email());
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.globalCodeOwner",
+      values = {"global-code-owner-1@example.com", "global-code-owner-2@example.com"})
+  public void globalCodeOwnersAreInheritedFromParentProject_duplicatesAreFilteredOut()
+      throws Exception {
+    TestAccount globalCodeOwner1 =
+        accountCreator.create(
+            "globalCodeOwner1",
+            "global-code-owner-1@example.com",
+            "Global Code Owner 1",
+            /* displayName= */ null);
+    TestAccount globalCodeOwner2 =
+        accountCreator.create(
+            "globalCodeOwner2",
+            "global-code-owner-2@example.com",
+            "Global Code Owner 2",
+            /* displayName= */ null);
+    TestAccount globalCodeOwner3 =
+        accountCreator.create(
+            "globalCodeOwner3",
+            "global-code-owner-3@example.com",
+            "Global Code Owner 3",
+            /* displayName= */ null);
+    configureGlobalCodeOwners(allProjects, globalCodeOwner1.email(), globalCodeOwner3.email());
+    assertThat(cfgSnapshot().getGlobalCodeOwners())
+        .comparingElementsUsing(hasEmail())
+        .containsExactly(
+            globalCodeOwner1.email(), globalCodeOwner2.email(), globalCodeOwner3.email());
+  }
+
+  @Test
+  public void inheritedGlobalCodeOwnersCanBeExtended() throws Exception {
+    TestAccount globalCodeOwner1 =
+        accountCreator.create(
+            "globalCodeOwner1",
+            "global-code-owner-1@example.com",
+            "Global Code Owner 1",
+            /* displayName= */ null);
+    TestAccount globalCodeOwner2 =
+        accountCreator.create(
+            "globalCodeOwner2",
+            "global-code-owner-2@example.com",
+            "Global Code Owner 2",
+            /* displayName= */ null);
+    TestAccount globalCodeOwner3 =
+        accountCreator.create(
+            "globalCodeOwner3",
+            "global-code-owner-3@example.com",
+            "Global Code Owner 3",
+            /* displayName= */ null);
+    TestAccount globalCodeOwner4 =
+        accountCreator.create(
+            "globalCodeOwner4",
+            "global-code-owner-4@example.com",
+            "Global Code Owner 4",
+            /* displayName= */ null);
+    configureGlobalCodeOwners(allProjects, globalCodeOwner1.email(), globalCodeOwner2.email());
+    configureGlobalCodeOwners(project, globalCodeOwner3.email(), globalCodeOwner4.email());
+    assertThat(cfgSnapshot().getGlobalCodeOwners())
+        .comparingElementsUsing(hasEmail())
+        .containsExactly(
+            globalCodeOwner1.email(),
+            globalCodeOwner2.email(),
+            globalCodeOwner3.email(),
+            globalCodeOwner4.email());
+  }
+
+  @Test
+  public void inheritedGlobalCodeOwnersCanBeExtended_duplicatesAreFilteredOut() throws Exception {
+    TestAccount globalCodeOwner1 =
+        accountCreator.create(
+            "globalCodeOwner1",
+            "global-code-owner-1@example.com",
+            "Global Code Owner 1",
+            /* displayName= */ null);
+    TestAccount globalCodeOwner2 =
+        accountCreator.create(
+            "globalCodeOwner2",
+            "global-code-owner-2@example.com",
+            "Global Code Owner 2",
+            /* displayName= */ null);
+    TestAccount globalCodeOwner3 =
+        accountCreator.create(
+            "globalCodeOwner3",
+            "global-code-owner-3@example.com",
+            "Global Code Owner 3",
+            /* displayName= */ null);
+    configureGlobalCodeOwners(allProjects, globalCodeOwner1.email(), globalCodeOwner2.email());
+    configureGlobalCodeOwners(project, globalCodeOwner1.email(), globalCodeOwner3.email());
+    assertThat(cfgSnapshot().getGlobalCodeOwners())
+        .comparingElementsUsing(hasEmail())
+        .containsExactly(
+            globalCodeOwner1.email(), globalCodeOwner2.email(), globalCodeOwner3.email());
+  }
+
+  @Test
+  public void inheritedGlobalCodeOwnersCannotBeRemoved() throws Exception {
+    TestAccount globalCodeOwner1 =
+        accountCreator.create(
+            "globalCodeOwner1",
+            "global-code-owner-1@example.com",
+            "Global Code Owner 1",
+            /* displayName= */ null);
+    TestAccount globalCodeOwner2 =
+        accountCreator.create(
+            "globalCodeOwner2",
+            "global-code-owner-2@example.com",
+            "Global Code Owner 2",
+            /* displayName= */ null);
+    configureGlobalCodeOwners(allProjects, globalCodeOwner1.email(), globalCodeOwner2.email());
+    configureGlobalCodeOwners(project, "");
+    assertThat(cfgSnapshot().getGlobalCodeOwners())
+        .comparingElementsUsing(hasEmail())
+        .containsExactly(globalCodeOwner1.email(), globalCodeOwner2.email());
+  }
+
+  @Test
+  public void getExemptedAccountsIfNoneIsConfigured() throws Exception {
+    assertThat(cfgSnapshot().getExemptedAccounts()).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.exemptedUser",
+      values = {"exempted-user-1@example.com", "exempted-user-2@example.com"})
+  public void getExemptedAccountsIfNoneIsConfiguredOnProjectLevel() throws Exception {
+    TestAccount exemptedUser1 =
+        accountCreator.create(
+            "exemptedUser1",
+            "exempted-user-1@example.com",
+            "Exempted User 1",
+            /* displayName= */ null);
+    TestAccount exemptedUser2 =
+        accountCreator.create(
+            "exemptedUser2",
+            "exempted-user-2@example.com",
+            "Exempted User 2",
+            /* displayName= */ null);
+    assertThat(cfgSnapshot().getExemptedAccounts())
+        .containsExactly(exemptedUser1.id(), exemptedUser2.id());
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.exemptedUser",
+      values = {"exempted-user-1@example.com", "exempted-user-2@example.com"})
+  public void exemptedAccountsOnProjectLevelExtendsGloballyConfiguredExemptedAcounts()
+      throws Exception {
+    TestAccount exemptedUser1 =
+        accountCreator.create(
+            "exemptedUser1",
+            "exempted-user-1@example.com",
+            "Exempted User 1",
+            /* displayName= */ null);
+    TestAccount exemptedUser2 =
+        accountCreator.create(
+            "exemptedUser2",
+            "exempted-user-2@example.com",
+            "Exempted User 2",
+            /* displayName= */ null);
+    TestAccount exemptedUser3 =
+        accountCreator.create(
+            "exemptedUser3",
+            "exempted-user-3@example.com",
+            "Exempted User 3",
+            /* displayName= */ null);
+    TestAccount exemptedUser4 =
+        accountCreator.create(
+            "exemptedUser4",
+            "exempted-user-4@example.com",
+            "Exempted User 4",
+            /* displayName= */ null);
+    configureExemptedUsers(allProjects, exemptedUser3.email(), exemptedUser4.email());
+    assertThat(cfgSnapshot().getExemptedAccounts())
+        .containsExactly(
+            exemptedUser1.id(), exemptedUser2.id(), exemptedUser3.id(), exemptedUser4.id());
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.exemptedUser",
+      values = {"exempted-user-1@example.com", "exempted-user-2@example.com"})
+  public void
+      exemptedAccountsOnProjectLevelExtendsGloballyConfiguredExemptedAcounts_duplicatesAreFilteredOut()
+          throws Exception {
+    TestAccount exemptedUser1 =
+        accountCreator.create(
+            "exemptedUser1",
+            "exempted-user-1@example.com",
+            "Exempted User 1",
+            /* displayName= */ null);
+    TestAccount exemptedUser2 =
+        accountCreator.create(
+            "exemptedUser2",
+            "exempted-user-2@example.com",
+            "Exempted User 2",
+            /* displayName= */ null);
+    TestAccount exemptedUser3 =
+        accountCreator.create(
+            "exemptedUser3",
+            "exempted-user-3@example.com",
+            "Exempted User 3",
+            /* displayName= */ null);
+    configureExemptedUsers(allProjects, exemptedUser1.email(), exemptedUser3.email());
+    assertThat(cfgSnapshot().getExemptedAccounts())
+        .containsExactly(exemptedUser1.id(), exemptedUser2.id(), exemptedUser3.id());
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.exemptedUser",
+      values = {"exempted-user-1@example.com", "exempted-user-2@example.com"})
+  public void exemptedAccountsAreInheritedFromParentProject() throws Exception {
+    TestAccount exemptedUser1 =
+        accountCreator.create(
+            "exemptedUser1",
+            "exempted-user-1@example.com",
+            "Exempted User 1",
+            /* displayName= */ null);
+    TestAccount exemptedUser2 =
+        accountCreator.create(
+            "exemptedUser2",
+            "exempted-user-2@example.com",
+            "Exempted User 2",
+            /* displayName= */ null);
+    TestAccount exemptedUser3 =
+        accountCreator.create(
+            "exemptedUser3",
+            "exempted-user-3@example.com",
+            "Exempted User 3",
+            /* displayName= */ null);
+    TestAccount exemptedUser4 =
+        accountCreator.create(
+            "exemptedUser4",
+            "exempted-user-4@example.com",
+            "Exempted User 4",
+            /* displayName= */ null);
+    configureExemptedUsers(allProjects, exemptedUser3.email(), exemptedUser4.email());
+    assertThat(cfgSnapshot().getExemptedAccounts())
+        .containsExactly(
+            exemptedUser1.id(), exemptedUser2.id(), exemptedUser3.id(), exemptedUser4.id());
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.exemptedUser",
+      values = {"exempted-user-1@example.com", "exempted-user-2@example.com"})
+  public void exemptedAccountsAreInheritedFromParentProject_duplicatesAreFilteredOut()
+      throws Exception {
+    TestAccount exemptedUser1 =
+        accountCreator.create(
+            "exemptedUser1",
+            "exempted-user-1@example.com",
+            "Exempted User 1",
+            /* displayName= */ null);
+    TestAccount exemptedUser2 =
+        accountCreator.create(
+            "exemptedUser2",
+            "exempted-user-2@example.com",
+            "Exempted User 2",
+            /* displayName= */ null);
+    TestAccount exemptedUser3 =
+        accountCreator.create(
+            "exemptedUser3",
+            "exempted-user-3@example.com",
+            "Exempted User 3",
+            /* displayName= */ null);
+    configureExemptedUsers(allProjects, exemptedUser1.email(), exemptedUser3.email());
+    assertThat(cfgSnapshot().getExemptedAccounts())
+        .containsExactly(exemptedUser1.id(), exemptedUser2.id(), exemptedUser3.id());
+  }
+
+  @Test
+  public void inheritedExemptedAccountsCanBeExtended() throws Exception {
+    TestAccount exemptedUser1 =
+        accountCreator.create(
+            "exemptedUser1",
+            "exempted-user-1@example.com",
+            "Exempted User 1",
+            /* displayName= */ null);
+    TestAccount exemptedUser2 =
+        accountCreator.create(
+            "exemptedUser2",
+            "exempted-user-2@example.com",
+            "Exempted User 2",
+            /* displayName= */ null);
+    TestAccount exemptedUser3 =
+        accountCreator.create(
+            "exemptedUser3",
+            "exempted-user-3@example.com",
+            "Exempted User 3",
+            /* displayName= */ null);
+    TestAccount exemptedUser4 =
+        accountCreator.create(
+            "exemptedUser4",
+            "exempted-user-4@example.com",
+            "Exempted User 4",
+            /* displayName= */ null);
+    configureExemptedUsers(allProjects, exemptedUser1.email(), exemptedUser2.email());
+    configureExemptedUsers(project, exemptedUser3.email(), exemptedUser4.email());
+    assertThat(cfgSnapshot().getExemptedAccounts())
+        .containsExactly(
+            exemptedUser1.id(), exemptedUser2.id(), exemptedUser3.id(), exemptedUser4.id());
+  }
+
+  @Test
+  public void inheritedExemptedAccountsCanBeExtended_duplicatesAreFilteredOut() throws Exception {
+    TestAccount exemptedUser1 =
+        accountCreator.create(
+            "exemptedUser1",
+            "exempted-user-1@example.com",
+            "Exempted User 1",
+            /* displayName= */ null);
+    TestAccount exemptedUser2 =
+        accountCreator.create(
+            "exemptedUser2",
+            "exempted-user-2@example.com",
+            "Exempted User 2",
+            /* displayName= */ null);
+    TestAccount exemptedUser3 =
+        accountCreator.create(
+            "exemptedUser3",
+            "exempted-user-3@example.com",
+            "Exempted User 3",
+            /* displayName= */ null);
+    configureExemptedUsers(allProjects, exemptedUser1.email(), exemptedUser2.email());
+    configureExemptedUsers(project, exemptedUser1.email(), exemptedUser3.email());
+    assertThat(cfgSnapshot().getExemptedAccounts())
+        .containsExactly(exemptedUser1.id(), exemptedUser2.id(), exemptedUser3.id());
+  }
+
+  @Test
+  public void inheritedExemptedAccountsCannotBeRemoved() throws Exception {
+    TestAccount exemptedUser1 =
+        accountCreator.create(
+            "exemptedUser1",
+            "exempted-user-1@example.com",
+            "Exempted User 1",
+            /* displayName= */ null);
+    TestAccount exemptedUser2 =
+        accountCreator.create(
+            "exemptedUser2",
+            "exempted-user-2@example.com",
+            "Exempted User 2",
+            /* displayName= */ null);
+    configureExemptedUsers(allProjects, exemptedUser1.email(), exemptedUser2.email());
+    configureExemptedUsers(project, "");
+    assertThat(cfgSnapshot().getExemptedAccounts())
+        .containsExactly(exemptedUser1.id(), exemptedUser2.id());
+  }
+
+  @Test
+  public void nonResolvableExemptedAccountsAreIgnored() throws Exception {
+    TestAccount exemptedUser =
+        accountCreator.create(
+            "exemptedUser", "exempted-user@example.com", "Exempted User", /* displayName= */ null);
+    configureExemptedUsers(project, exemptedUser.email(), "non-resolveable@example.com");
+    assertThat(cfgSnapshot().getExemptedAccounts()).containsExactly(exemptedUser.id());
+  }
+
+  @Test
+  public void exemptedAccountsByIdAreIgnored() throws Exception {
+    TestAccount exemptedUser1 =
+        accountCreator.create(
+            "exemptedUser1",
+            "exempted-user-1@example.com",
+            "Exempted User 1",
+            /* displayName= */ null);
+    TestAccount exemptedUser2 =
+        accountCreator.create(
+            "exemptedUser2",
+            "exempted-user-2@example.com",
+            "Exempted User 2",
+            /* displayName= */ null);
+    configureExemptedUsers(
+        project, exemptedUser1.email(), Integer.toString(exemptedUser2.id().get()));
+    assertThat(cfgSnapshot().getExemptedAccounts()).containsExactly(exemptedUser1.id());
+  }
+
+  @Test
+  public void getMaxPathsInChangeMessagesIfNoneIsConfigured() throws Exception {
+    assertThat(cfgSnapshot().getMaxPathsInChangeMessages())
+        .isEqualTo(GeneralConfig.DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "50")
+  public void getMaxPathsInChangeMessagesIfNoneIsConfiguredOnProjectLevel() throws Exception {
+    assertThat(cfgSnapshot().getMaxPathsInChangeMessages()).isEqualTo(50);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "50")
+  public void maxPathInChangeMessagesOnProjectLevelOverridesGlobalMaxPathInChangeMessages()
+      throws Exception {
+    configureMaxPathsInChangeMessages(project, 20);
+    assertThat(cfgSnapshot().getMaxPathsInChangeMessages()).isEqualTo(20);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "50")
+  public void maxPathInChangeMessagesIsInheritedFromParentProject() throws Exception {
+    configureMaxPathsInChangeMessages(allProjects, 20);
+    assertThat(cfgSnapshot().getMaxPathsInChangeMessages()).isEqualTo(20);
+  }
+
+  @Test
+  public void inheritedMaxPathInChangeMessagesCanBeOverridden() throws Exception {
+    configureMaxPathsInChangeMessages(allProjects, 50);
+    configureMaxPathsInChangeMessages(project, 20);
+    assertThat(cfgSnapshot().getMaxPathsInChangeMessages()).isEqualTo(20);
+  }
+
+  @Test
+  public void cannotCheckForNullBranchIfCodeOwnersFunctionalityIsDisabled() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class, () -> cfgSnapshot().isDisabled(/* branchName= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("branchName");
+  }
+
+  @Test
+  public void checkIfCodeOwnersFunctionalityIsDisabledForNonExistingBranch() throws Exception {
+    assertThat(cfgSnapshot().isDisabled("non-existing")).isFalse();
+  }
+
+  @Test
+  public void checkIfCodeOwnersFunctionalityIsDisabledForProjectWithEmptyConfig() throws Exception {
+    assertThat(cfgSnapshot().isDisabled()).isFalse();
+  }
+
+  @Test
+  public void checkIfCodeOwnersFunctionalityIsDisabledForBranchWithEmptyConfig() throws Exception {
+    assertThat(cfgSnapshot().isDisabled("master")).isFalse();
+  }
+
+  @Test
+  public void codeOwnersFunctionalityIsDisabledForProject() throws Exception {
+    disableCodeOwnersForProject(project);
+    assertThat(cfgSnapshot().isDisabled()).isTrue();
+  }
+
+  @Test
+  public void codeOwnersFunctionalityIsDisabledForBranchIfItIsDisabledForProject()
+      throws Exception {
+    disableCodeOwnersForProject(project);
+    assertThat(cfgSnapshot().isDisabled("master")).isTrue();
+  }
+
+  @Test
+  public void codeOwnersFunctionalityIsDisabledForBranch_exactRef() throws Exception {
+    configureDisabledBranch(project, "refs/heads/master");
+    assertThat(cfgSnapshot().isDisabled("master")).isTrue();
+    assertThat(cfgSnapshot().isDisabled("other")).isFalse();
+  }
+
+  @Test
+  public void codeOwnersFunctionalityIsDisabledForBranch_refPattern() throws Exception {
+    configureDisabledBranch(project, "refs/heads/*");
+    assertThat(cfgSnapshot().isDisabled("master")).isTrue();
+    assertThat(cfgSnapshot().isDisabled("other")).isTrue();
+    assertThat(cfgSnapshot().isDisabled(RefNames.REFS_META)).isFalse();
+  }
+
+  @Test
+  public void codeOwnersFunctionalityIsDisabledForBranch_regularExpression() throws Exception {
+    configureDisabledBranch(project, "^refs/heads/.*");
+    assertThat(cfgSnapshot().isDisabled("master")).isTrue();
+    assertThat(cfgSnapshot().isDisabled("other")).isTrue();
+    assertThat(cfgSnapshot().isDisabled(RefNames.REFS_META)).isFalse();
+  }
+
+  @Test
+  public void codeOwnersFunctionalityIsDisabledForBranch_invalidRegularExpression()
+      throws Exception {
+    configureDisabledBranch(project, "^refs/heads/[");
+    assertThat(cfgSnapshot().isDisabled("master")).isFalse();
+  }
+
+  @Test
+  public void disabledIsInheritedFromParentProject() throws Exception {
+    disableCodeOwnersForProject(allProjects);
+    assertThat(cfgSnapshot().isDisabled()).isTrue();
+  }
+
+  @Test
+  public void inheritedDisabledAlsoCountsForBranch() throws Exception {
+    disableCodeOwnersForProject(allProjects);
+    assertThat(cfgSnapshot().isDisabled("master")).isTrue();
+  }
+
+  @Test
+  public void inheritedDisabledValueIsIgnoredIfInvalid() throws Exception {
+    configureDisabled(project, "invalid");
+    assertThat(cfgSnapshot().isDisabled()).isFalse();
+  }
+
+  @Test
+  public void inheritedDisabledValueIsIgnoredForBranchIfInvalid() throws Exception {
+    configureDisabled(project, "invalid");
+    assertThat(cfgSnapshot().isDisabled("master")).isFalse();
+  }
+
+  @Test
+  public void disabledForOtherProjectHasNoEffect() throws Exception {
+    Project.NameKey otherProject = projectOperations.newProject().create();
+    disableCodeOwnersForProject(otherProject);
+    assertThat(cfgSnapshot().isDisabled()).isFalse();
+  }
+
+  @Test
+  public void disabledBranchForOtherProjectHasNoEffect() throws Exception {
+    Project.NameKey otherProject = projectOperations.newProject().create();
+    configureDisabledBranch(otherProject, "refs/heads/master");
+    assertThat(cfgSnapshot().isDisabled("master")).isFalse();
+  }
+
+  @Test
+  public void disabledBranchIsInheritedFromParentProject() throws Exception {
+    configureDisabledBranch(allProjects, "refs/heads/master");
+    assertThat(cfgSnapshot().isDisabled("master")).isTrue();
+  }
+
+  @Test
+  public void inheritedDisabledCanBeOverridden() throws Exception {
+    disableCodeOwnersForProject(allProjects);
+    enableCodeOwnersForProject(project);
+    assertThat(cfgSnapshot().isDisabled("master")).isFalse();
+  }
+
+  @Test
+  public void inheritedDisabledBranchCanBeExtended() throws Exception {
+    configureDisabledBranch(allProjects, "refs/heads/master");
+    configureDisabledBranch(project, "refs/heads/test");
+    assertThat(cfgSnapshot().isDisabled("master")).isTrue();
+    assertThat(cfgSnapshot().isDisabled("test")).isTrue();
+  }
+
+  @Test
+  public void inheritedDisabledBranchCannotBeRemoved() throws Exception {
+    configureDisabledBranch(allProjects, "refs/heads/master");
+
+    // trying to override the inherited config with an empty value to enable code owners for all
+    // branches doesn't work because the empty string is added to the inherited value list so that
+    // disabledBranch is ["refs/heads/master", ""] now
+    configureDisabledBranch(project, "");
+
+    assertThat(cfgSnapshot().isDisabled("master")).isTrue();
+  }
+
+  @Test
+  public void cannotGetBackendForNullBranch() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class, () -> cfgSnapshot().getBackend(/* branchName= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("branchName");
+  }
+
+  @Test
+  public void getBackendForNonExistingBranch() throws Exception {
+    assertThat(cfgSnapshot().getBackend("non-existing")).isInstanceOf(FindOwnersBackend.class);
+  }
+
+  @Test
+  public void getDefaultBackendWhenNoBackendIsConfigured() throws Exception {
+    assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(FindOwnersBackend.class);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = TestCodeOwnerBackend.ID)
+  public void getConfiguredDefaultBackend() throws Exception {
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(TestCodeOwnerBackend.class);
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = "non-existing-backend")
+  public void cannotGetBackendIfNonExistingBackendIsConfigured() throws Exception {
+    InvalidPluginConfigurationException exception =
+        assertThrows(
+            InvalidPluginConfigurationException.class, () -> cfgSnapshot().getBackend("master"));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid configuration of the code-owners plugin. Code owner backend"
+                + " 'non-existing-backend' that is configured in gerrit.config (parameter"
+                + " plugin.code-owners.backend) not found.");
+  }
+
+  @Test
+  public void getBackendConfiguredOnProjectLevel() throws Exception {
+    configureBackend(project, TestCodeOwnerBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(TestCodeOwnerBackend.class);
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = FindOwnersBackend.ID)
+  public void backendConfiguredOnProjectLevelOverridesDefaultBackend() throws Exception {
+    configureBackend(project, TestCodeOwnerBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(TestCodeOwnerBackend.class);
+    }
+  }
+
+  @Test
+  public void backendIsInheritedFromParentProject() throws Exception {
+    configureBackend(allProjects, TestCodeOwnerBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(TestCodeOwnerBackend.class);
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = FindOwnersBackend.ID)
+  public void inheritedBackendOverridesDefaultBackend() throws Exception {
+    configureBackend(allProjects, TestCodeOwnerBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(TestCodeOwnerBackend.class);
+    }
+  }
+
+  @Test
+  public void projectLevelBackendOverridesInheritedBackend() throws Exception {
+    configureBackend(allProjects, TestCodeOwnerBackend.ID);
+    configureBackend(project, FindOwnersBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(FindOwnersBackend.class);
+    }
+  }
+
+  @Test
+  public void cannotGetBackendIfNonExistingBackendIsConfiguredOnProjectLevel() throws Exception {
+    configureBackend(project, "non-existing-backend");
+    InvalidPluginConfigurationException exception =
+        assertThrows(
+            InvalidPluginConfigurationException.class, () -> cfgSnapshot().getBackend("master"));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Invalid configuration of the code-owners plugin. Code owner backend"
+                    + " 'non-existing-backend' that is configured for project %s in"
+                    + " code-owners.config (parameter codeOwners.backend) not found.",
+                project));
+  }
+
+  @Test
+  public void projectLevelBackendForOtherProjectHasNoEffect() throws Exception {
+    Project.NameKey otherProject = projectOperations.newProject().create();
+    configureBackend(otherProject, TestCodeOwnerBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(FindOwnersBackend.class);
+    }
+  }
+
+  @Test
+  public void getBackendConfiguredOnBranchLevel() throws Exception {
+    configureBackend(project, "refs/heads/master", TestCodeOwnerBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(TestCodeOwnerBackend.class);
+    }
+  }
+
+  @Test
+  public void getBackendConfiguredOnBranchLevelShortName() throws Exception {
+    configureBackend(project, "master", TestCodeOwnerBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(TestCodeOwnerBackend.class);
+    }
+  }
+
+  @Test
+  public void branchLevelBackendOnFullNameTakesPrecedenceOverBranchLevelBackendOnShortName()
+      throws Exception {
+    configureBackend(project, "master", TestCodeOwnerBackend.ID);
+    configureBackend(project, "refs/heads/master", FindOwnersBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(FindOwnersBackend.class);
+    }
+  }
+
+  @Test
+  public void branchLevelBackendOverridesProjectLevelBackend() throws Exception {
+    configureBackend(project, TestCodeOwnerBackend.ID);
+    configureBackend(project, "master", FindOwnersBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(FindOwnersBackend.class);
+    }
+  }
+
+  @Test
+  public void cannotGetBackendIfNonExistingBackendIsConfiguredOnBranchLevel() throws Exception {
+    configureBackend(project, "master", "non-existing-backend");
+    InvalidPluginConfigurationException exception =
+        assertThrows(
+            InvalidPluginConfigurationException.class, () -> cfgSnapshot().getBackend("master"));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Invalid configuration of the code-owners plugin. Code owner backend"
+                    + " 'non-existing-backend' that is configured for project %s in"
+                    + " code-owners.config (parameter codeOwners.master.backend) not found.",
+                project));
+  }
+
+  @Test
+  public void branchLevelBackendForOtherBranchHasNoEffect() throws Exception {
+    configureBackend(project, "foo", TestCodeOwnerBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(FindOwnersBackend.class);
+    }
+  }
+
+  @Test
+  public void getDefaultRequiredApprovalWhenNoRequiredApprovalIsConfigured() throws Exception {
+    RequiredApproval requiredApproval = cfgSnapshot().getRequiredApproval();
+    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 getGloballyConfiguredRequiredApproval() throws Exception {
+    RequiredApproval requiredApproval = cfgSnapshot().getRequiredApproval();
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Foo-Bar+1")
+  public void cannotGetRequiredApprovalIfNonExistingLabelIsConfiguredAsRequiredApproval()
+      throws Exception {
+    InvalidPluginConfigurationException exception =
+        assertThrows(
+            InvalidPluginConfigurationException.class, () -> cfgSnapshot().getRequiredApproval());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Invalid configuration of the code-owners plugin. Required approval 'Foo-Bar+1'"
+                    + " that is configured in gerrit.config (parameter"
+                    + " plugin.code-owners.requiredApproval) is invalid: Label Foo-Bar doesn't exist"
+                    + " for project %s.",
+                project.get()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Code-Review+3")
+  public void cannotGetRequiredApprovalIfNonExistingLabelValueIsConfiguredAsRequiredApproval()
+      throws Exception {
+    InvalidPluginConfigurationException exception =
+        assertThrows(
+            InvalidPluginConfigurationException.class, () -> cfgSnapshot().getRequiredApproval());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Invalid configuration of the code-owners plugin. Required approval"
+                    + " 'Code-Review+3' that is configured in gerrit.config (parameter"
+                    + " plugin.code-owners.requiredApproval) is invalid: Label Code-Review on"
+                    + " project %s doesn't allow value 3.",
+                project.get()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "INVALID")
+  public void cannotGetRequiredApprovalIfInvalidRequiredApprovalIsConfigured() throws Exception {
+    InvalidPluginConfigurationException exception =
+        assertThrows(
+            InvalidPluginConfigurationException.class, () -> cfgSnapshot().getRequiredApproval());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid configuration of the code-owners plugin. Required approval 'INVALID' that is"
+                + " configured in gerrit.config (parameter plugin.code-owners.requiredApproval) is"
+                + " invalid: Invalid format, expected '<label-name>+<label-value>'.");
+  }
+
+  @Test
+  public void getRequiredApprovalConfiguredOnProjectLevel() throws Exception {
+    configureRequiredApproval(project, "Code-Review+2");
+    RequiredApproval requiredApproval = cfgSnapshot().getRequiredApproval();
+    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 = cfgSnapshot().getRequiredApproval();
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(1);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Code-Review+1")
+  public void requiredApprovalConfiguredOnProjectLevelOverridesGloballyConfiguredRequiredApproval()
+      throws Exception {
+    configureRequiredApproval(project, "Code-Review+2");
+    RequiredApproval requiredApproval = cfgSnapshot().getRequiredApproval();
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
+  }
+
+  @Test
+  public void requiredApprovalIsInheritedFromParentProject() throws Exception {
+    configureRequiredApproval(allProjects, "Code-Review+2");
+    RequiredApproval requiredApproval = cfgSnapshot().getRequiredApproval();
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Code-Review+1")
+  public void inheritedRequiredApprovalOverridesGloballyConfiguredRequiredApproval()
+      throws Exception {
+    configureRequiredApproval(allProjects, "Code-Review+2");
+    RequiredApproval requiredApproval = cfgSnapshot().getRequiredApproval();
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
+  }
+
+  @Test
+  public void projectLevelRequiredApprovalOverridesInheritedRequiredApproval() throws Exception {
+    configureRequiredApproval(allProjects, "Code-Review+1");
+    configureRequiredApproval(project, "Code-Review+2");
+    RequiredApproval requiredApproval = cfgSnapshot().getRequiredApproval();
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
+  }
+
+  @Test
+  public void
+      cannotGetRequiredApprovalIfNonExistingLabelIsConfiguredAsRequiredApprovalOnProjectLevel()
+          throws Exception {
+    configureRequiredApproval(project, "Foo-Bar+1");
+    InvalidPluginConfigurationException exception =
+        assertThrows(
+            InvalidPluginConfigurationException.class, () -> cfgSnapshot().getRequiredApproval());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Invalid configuration of the code-owners plugin. Required approval 'Foo-Bar+1'"
+                    + " that is configured in code-owners.config (parameter"
+                    + " codeOwners.requiredApproval) is invalid: Label Foo-Bar doesn't exist for"
+                    + " project %s.",
+                project.get()));
+  }
+
+  @Test
+  public void
+      cannotGetRequiredApprovalIfNonExistingLabelValueIsConfiguredAsRequiredApprovalOnProjectLevel()
+          throws Exception {
+    configureRequiredApproval(project, "Code-Review+3");
+    InvalidPluginConfigurationException exception =
+        assertThrows(
+            InvalidPluginConfigurationException.class, () -> cfgSnapshot().getRequiredApproval());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Invalid configuration of the code-owners plugin. Required approval"
+                    + " 'Code-Review+3' that is configured in code-owners.config (parameter"
+                    + " codeOwners.requiredApproval) is invalid: Label Code-Review on project %s"
+                    + " doesn't allow value 3.",
+                project.get()));
+  }
+
+  @Test
+  public void cannotGetRequiredApprovalIfInvalidRequiredApprovalIsConfiguredOnProjectLevel()
+      throws Exception {
+    configureRequiredApproval(project, "INVALID");
+    InvalidPluginConfigurationException exception =
+        assertThrows(
+            InvalidPluginConfigurationException.class, () -> cfgSnapshot().getRequiredApproval());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid configuration of the code-owners plugin. Required approval 'INVALID' that is"
+                + " configured in code-owners.config (parameter codeOwners.requiredApproval) is"
+                + " invalid: Invalid format, expected '<label-name>+<label-value>'.");
+  }
+
+  @Test
+  public void projectLevelRequiredApprovalForOtherProjectHasNoEffect() throws Exception {
+    Project.NameKey otherProject = projectOperations.newProject().create();
+    configureRequiredApproval(otherProject, "Code-Review+2");
+    RequiredApproval requiredApproval = cfgSnapshot().getRequiredApproval();
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(1);
+  }
+
+  @Test
+  public void getOverrideApprovalWhenNoRequiredApprovalIsConfigured() throws Exception {
+    assertThat(cfgSnapshot().getOverrideApprovals()).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void getConfiguredOverrideApproval() throws Exception {
+    createOwnersOverrideLabel();
+    ImmutableSortedSet<RequiredApproval> requiredApprovals = cfgSnapshot().getOverrideApprovals();
+    assertThat(requiredApprovals).hasSize(1);
+    assertThat(requiredApprovals).element(0).hasLabelNameThat().isEqualTo("Owners-Override");
+    assertThat(requiredApprovals).element(0).hasValueThat().isEqualTo(1);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Foo-Bar+1")
+  public void getOverrideApprovalIfNonExistingLabelIsConfiguredAsOverrideApproval()
+      throws Exception {
+    assertThat(cfgSnapshot().getOverrideApprovals()).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Code-Review+3")
+  public void getOverrideApprovalIfNonExistingLabelValueIsConfiguredAsOverrideApproval()
+      throws Exception {
+    assertThat(cfgSnapshot().getOverrideApprovals()).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "INVALID")
+  public void getOverrideApprovalIfInvalidOverrideApprovalIsConfigured() throws Exception {
+    assertThat(cfgSnapshot().getOverrideApprovals()).isEmpty();
+  }
+
+  @Test
+  public void getOverrideApprovalConfiguredOnProjectLevel() throws Exception {
+    createOwnersOverrideLabel();
+    configureOverrideApproval(project, "Owners-Override+1");
+    ImmutableSortedSet<RequiredApproval> requiredApprovals = cfgSnapshot().getOverrideApprovals();
+    assertThat(requiredApprovals).hasSize(1);
+    assertThat(requiredApprovals).element(0).hasLabelNameThat().isEqualTo("Owners-Override");
+    assertThat(requiredApprovals).element(0).hasValueThat().isEqualTo(1);
+  }
+
+  @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 = cfgSnapshot().getOverrideApprovals();
+    assertThat(
+            requiredApprovals.stream()
+                .map(requiredApproval -> requiredApproval.toString())
+                .collect(toImmutableSet()))
+        .containsExactly("Owners-Override+1", "Other-Override+1");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void overrideApprovalConfiguredOnProjectLevelExtendsGloballyConfiguredOverrideApproval()
+      throws Exception {
+    createOwnersOverrideLabel();
+    createOwnersOverrideLabel("Other-Override");
+
+    configureOverrideApproval(project, "Other-Override+1");
+    ImmutableSortedSet<RequiredApproval> requiredApprovals = cfgSnapshot().getOverrideApprovals();
+    assertThat(requiredApprovals).hasSize(2);
+    assertThat(requiredApprovals).element(0).hasLabelNameThat().isEqualTo("Other-Override");
+    assertThat(requiredApprovals).element(0).hasValueThat().isEqualTo(1);
+    assertThat(requiredApprovals).element(1).hasLabelNameThat().isEqualTo("Owners-Override");
+    assertThat(requiredApprovals).element(1).hasValueThat().isEqualTo(1);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void
+      overrideApprovalConfiguredOnProjectLevelExtendsGloballyConfiguredOverrideApproval_duplicatesAreFilteredOut()
+          throws Exception {
+    createOwnersOverrideLabel();
+
+    configureOverrideApproval(project, "Owners-Override+1");
+    ImmutableSortedSet<RequiredApproval> requiredApprovals = cfgSnapshot().getOverrideApprovals();
+    assertThat(requiredApprovals).hasSize(1);
+    assertThat(requiredApprovals).element(0).hasLabelNameThat().isEqualTo("Owners-Override");
+    assertThat(requiredApprovals).element(0).hasValueThat().isEqualTo(1);
+  }
+
+  @Test
+  public void overrideApprovalIsInheritedFromParentProject() throws Exception {
+    createOwnersOverrideLabel();
+
+    configureOverrideApproval(allProjects, "Owners-Override+1");
+    ImmutableSortedSet<RequiredApproval> requiredApprovals = cfgSnapshot().getOverrideApprovals();
+    assertThat(requiredApprovals).hasSize(1);
+    assertThat(requiredApprovals).element(0).hasLabelNameThat().isEqualTo("Owners-Override");
+    assertThat(requiredApprovals).element(0).hasValueThat().isEqualTo(1);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void inheritedOverrideApprovalExtendsGloballyConfiguredOverrideApproval()
+      throws Exception {
+    createOwnersOverrideLabel();
+    createOwnersOverrideLabel("Other-Override");
+
+    configureOverrideApproval(allProjects, "Other-Override+1");
+    ImmutableSortedSet<RequiredApproval> requiredApprovals = cfgSnapshot().getOverrideApprovals();
+    assertThat(requiredApprovals).hasSize(2);
+    assertThat(requiredApprovals).element(0).hasLabelNameThat().isEqualTo("Other-Override");
+    assertThat(requiredApprovals).element(0).hasValueThat().isEqualTo(1);
+    assertThat(requiredApprovals).element(1).hasLabelNameThat().isEqualTo("Owners-Override");
+    assertThat(requiredApprovals).element(1).hasValueThat().isEqualTo(1);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Owners-Override+1")
+  public void
+      inheritedOverrideApprovalExtendsGloballyConfiguredOverrideApproval_duplicatesAreFilteredOut()
+          throws Exception {
+    createOwnersOverrideLabel();
+
+    configureOverrideApproval(allProjects, "Owners-Override+1");
+    ImmutableSortedSet<RequiredApproval> requiredApprovals = cfgSnapshot().getOverrideApprovals();
+    assertThat(requiredApprovals).hasSize(1);
+    assertThat(requiredApprovals).element(0).hasLabelNameThat().isEqualTo("Owners-Override");
+    assertThat(requiredApprovals).element(0).hasValueThat().isEqualTo(1);
+  }
+
+  @Test
+  public void projectLevelOverrideApprovalExtendsInheritedOverrideApproval() throws Exception {
+    createOwnersOverrideLabel();
+    createOwnersOverrideLabel("Other-Override");
+
+    configureOverrideApproval(allProjects, "Owners-Override+1");
+    configureOverrideApproval(project, "Other-Override+1");
+    ImmutableSortedSet<RequiredApproval> requiredApprovals = cfgSnapshot().getOverrideApprovals();
+    assertThat(requiredApprovals).hasSize(2);
+    assertThat(requiredApprovals).element(0).hasLabelNameThat().isEqualTo("Other-Override");
+    assertThat(requiredApprovals).element(0).hasValueThat().isEqualTo(1);
+    assertThat(requiredApprovals).element(1).hasLabelNameThat().isEqualTo("Owners-Override");
+    assertThat(requiredApprovals).element(1).hasValueThat().isEqualTo(1);
+  }
+
+  @Test
+  public void
+      projectLevelOverrideApprovalExtendsInheritedOverrideApproval_duplicatesAreFilteredOut()
+          throws Exception {
+    createOwnersOverrideLabel();
+
+    configureOverrideApproval(allProjects, "Owners-Override+1");
+    configureOverrideApproval(project, "Owners-Override+1");
+    ImmutableSortedSet<RequiredApproval> requiredApprovals = cfgSnapshot().getOverrideApprovals();
+    assertThat(requiredApprovals).hasSize(1);
+    assertThat(requiredApprovals).element(0).hasLabelNameThat().isEqualTo("Owners-Override");
+    assertThat(requiredApprovals).element(0).hasValueThat().isEqualTo(1);
+  }
+
+  @Test
+  public void projectLevelOverrideApprovalExtendsInheritedOverrideApprovalWithDifferentLabelValue()
+      throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+2", "Super-Override", "+1", "Override", " 0", "No Override");
+    gApi.projects().name(project.get()).label("Owners-Override").create(input).get();
+
+    configureOverrideApproval(allProjects, "Owners-Override+1");
+    configureOverrideApproval(project, "Owners-Override+2");
+
+    // if the same label is configured multiple times as override approval, only the definition with
+    // the lowest value is returned (since all higher values are implicitly considered as overrides
+    // as well)
+    ImmutableSortedSet<RequiredApproval> requiredApprovals = cfgSnapshot().getOverrideApprovals();
+    assertThat(requiredApprovals).hasSize(1);
+    assertThat(requiredApprovals).element(0).hasLabelNameThat().isEqualTo("Owners-Override");
+    assertThat(requiredApprovals).element(0).hasValueThat().isEqualTo(1);
+  }
+
+  @Test
+  public void getOverrideApprovalIfNonExistingLabelIsConfiguredAsOverrideApprovalOnProjectLevel()
+      throws Exception {
+    configureOverrideApproval(project, "Foo-Bar+1");
+    assertThat(cfgSnapshot().getOverrideApprovals()).isEmpty();
+  }
+
+  @Test
+  public void
+      getOverrideApprovalIfNonExistingLabelValueIsConfiguredAsOverrideApprovalOnProjectLevel()
+          throws Exception {
+    createOwnersOverrideLabel();
+    configureOverrideApproval(project, "Owners-Override+2");
+    assertThat(cfgSnapshot().getOverrideApprovals()).isEmpty();
+  }
+
+  @Test
+  public void getOverrideApprovalIfInvalidOverrideApprovalIsConfiguredOnProjectLevel()
+      throws Exception {
+    configureOverrideApproval(project, "INVALID");
+    assertThat(cfgSnapshot().getOverrideApprovals()).isEmpty();
+  }
+
+  @Test
+  public void projectLevelOverrideApprovalForOtherProjectHasNoEffect() throws Exception {
+    createOwnersOverrideLabel();
+    Project.NameKey otherProject = projectOperations.newProject().create();
+    configureOverrideApproval(otherProject, "Owners-Override+1");
+    assertThat(cfgSnapshot().getOverrideApprovals()).isEmpty();
+  }
+
+  @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.
+    ImmutableSortedSet<RequiredApproval> requiredApprovals = cfgSnapshot().getOverrideApprovals();
+    assertThat(requiredApprovals).hasSize(1);
+    assertThat(requiredApprovals).element(0).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApprovals).element(0).hasValueThat().isEqualTo(1);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void implicitApprovalsAreDisabledIfRequiredLabelIgnoresSelfApprovals() throws Exception {
+    assertThat(cfgSnapshot().areImplicitApprovalsEnabled()).isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.ignoreSelfApproval = true;
+    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(cfgSnapshot().areImplicitApprovalsEnabled()).isFalse();
+  }
+
+  @Test
+  public void cannotGetCodeOwnerConfigValidationPolicyForCommitReceivedForNullBranch()
+      throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                cfgSnapshot()
+                    .getCodeOwnerConfigValidationPolicyForCommitReceived(/* branchName= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("branchName");
+  }
+
+  @Test
+  public void getCodeOwnerConfigValidationPolicyForCommitReceived_notConfigured() throws Exception {
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("non-existing"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+  }
+
+  @Test
+  public void getCodeOwnerConfigValidationPolicyForCommitReceived_configuredOnProjectLevel()
+      throws Exception {
+    configureEnableValidationOnCommitReceived(project, CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("non-existing"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  public void getCodeOwnerConfigValidationPolicyForCommitReceived_configuredOnBranchLevel()
+      throws Exception {
+    configureEnableValidationOnCommitReceivedForBranch(
+        project, "refs/heads/master", CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(
+            cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("refs/heads/master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("foo"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+  }
+
+  @Test
+  public void getCodeOwnerConfigValidationPolicyForCommitReceived_branchLevelConfigTakesPrecedence()
+      throws Exception {
+    updateCodeOwnersConfig(
+        project,
+        codeOwnersConfig -> {
+          codeOwnersConfig.setEnum(
+              CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+              /* subsection= */ null,
+              GeneralConfig.KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
+              CodeOwnerConfigValidationPolicy.DRY_RUN);
+          codeOwnersConfig.setEnum(
+              GeneralConfig.SECTION_VALIDATION,
+              "refs/heads/master",
+              GeneralConfig.KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
+              CodeOwnerConfigValidationPolicy.FALSE);
+        });
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(
+            cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("refs/heads/master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("foo"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.DRY_RUN);
+  }
+
+  @Test
+  public void
+      getCodeOwnerConfigValidationPolicyForCommitReceived_inheritedBranchLevelConfigTakesPrecedence()
+          throws Exception {
+    configureEnableValidationOnCommitReceivedForBranch(
+        allProjects, "refs/heads/master", CodeOwnerConfigValidationPolicy.FALSE);
+    configureEnableValidationOnCommitReceived(project, CodeOwnerConfigValidationPolicy.DRY_RUN);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(
+            cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("refs/heads/master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("foo"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.DRY_RUN);
+  }
+
+  @Test
+  public void
+      getCodeOwnerConfigValidationPolicyForCommitReceived_inheritedBranchLevelCanBeOverridden()
+          throws Exception {
+    configureEnableValidationOnCommitReceivedForBranch(
+        allProjects, "refs/heads/master", CodeOwnerConfigValidationPolicy.FALSE);
+    configureEnableValidationOnCommitReceivedForBranch(
+        project, "refs/heads/master", CodeOwnerConfigValidationPolicy.DRY_RUN);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.DRY_RUN);
+  }
+
+  @Test
+  public void cannotGetCodeOwnerConfigValidationPolicyForSubmitForNullBranch() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit(/* branchName= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("branchName");
+  }
+
+  @Test
+  public void getCodeOwnerConfigValidationPolicyForSubmitd_notConfigured() throws Exception {
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("non-existing"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  public void getCodeOwnerConfigValidationPolicyForSubmit_configuredOnProjectLevel()
+      throws Exception {
+    configureEnableValidationOnSubmit(project, CodeOwnerConfigValidationPolicy.TRUE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("non-existing"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+  }
+
+  @Test
+  public void getCodeOwnerConfigValidationPolicyForSubmit_configuredOnBranchLevel()
+      throws Exception {
+    configureEnableValidationOnSubmitForBranch(
+        project, "refs/heads/master", CodeOwnerConfigValidationPolicy.TRUE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("refs/heads/master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("foo"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  public void getCodeOwnerConfigValidationPolicyForSubmit_branchLevelConfigTakesPrecedence()
+      throws Exception {
+    updateCodeOwnersConfig(
+        project,
+        codeOwnersConfig -> {
+          codeOwnersConfig.setEnum(
+              CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+              /* subsection= */ null,
+              GeneralConfig.KEY_ENABLE_VALIDATION_ON_SUBMIT,
+              CodeOwnerConfigValidationPolicy.DRY_RUN);
+          codeOwnersConfig.setEnum(
+              GeneralConfig.SECTION_VALIDATION,
+              "refs/heads/master",
+              GeneralConfig.KEY_ENABLE_VALIDATION_ON_SUBMIT,
+              CodeOwnerConfigValidationPolicy.TRUE);
+        });
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("refs/heads/master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("foo"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.DRY_RUN);
+  }
+
+  @Test
+  public void
+      getCodeOwnerConfigValidationPolicyForSubmit_inheritedBranchLevelConfigTakesPrecedence()
+          throws Exception {
+    configureEnableValidationOnSubmitForBranch(
+        allProjects, "refs/heads/master", CodeOwnerConfigValidationPolicy.TRUE);
+    configureEnableValidationOnSubmit(project, CodeOwnerConfigValidationPolicy.DRY_RUN);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("refs/heads/master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("foo"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.DRY_RUN);
+  }
+
+  @Test
+  public void getCodeOwnerConfigValidationPolicyForSubmit_inheritedBranchLevelCanBeOverridden()
+      throws Exception {
+    configureEnableValidationOnSubmitForBranch(
+        allProjects, "refs/heads/master", CodeOwnerConfigValidationPolicy.TRUE);
+    configureEnableValidationOnSubmitForBranch(
+        project, "refs/heads/master", CodeOwnerConfigValidationPolicy.DRY_RUN);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.DRY_RUN);
+  }
+
+  @Test
+  public void cannotGetRejectNonResolvableCodeOwnersForNullBranch() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> cfgSnapshot().rejectNonResolvableCodeOwners(/* branchName= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("branchName");
+  }
+
+  @Test
+  public void getRejectNonResolvableCodeOwners_notConfigured() throws Exception {
+    assertThat(cfgSnapshot().rejectNonResolvableCodeOwners("master")).isTrue();
+    assertThat(cfgSnapshot().rejectNonResolvableCodeOwners("non-existing")).isTrue();
+  }
+
+  @Test
+  public void getRejectNonResolvableCodeOwners_configuredOnProjectLevel() throws Exception {
+    configureRejectNonResolvableCodeOwners(project, false);
+    assertThat(cfgSnapshot().rejectNonResolvableCodeOwners("master")).isFalse();
+    assertThat(cfgSnapshot().rejectNonResolvableCodeOwners("non-existing")).isFalse();
+  }
+
+  @Test
+  public void getRejectNonResolvableCodeOwners_configuredOnBranchLevel() throws Exception {
+    configureRejectNonResolvableCodeOwnersForBranch(project, "refs/heads/master", false);
+    assertThat(cfgSnapshot().rejectNonResolvableCodeOwners("master")).isFalse();
+    assertThat(cfgSnapshot().rejectNonResolvableCodeOwners("refs/heads/master")).isFalse();
+    assertThat(cfgSnapshot().rejectNonResolvableCodeOwners("foo")).isTrue();
+  }
+
+  @Test
+  public void getRejectNonResolvableCodeOwners_branchLevelConfigTakesPrecedence() throws Exception {
+    updateCodeOwnersConfig(
+        project,
+        codeOwnersConfig -> {
+          codeOwnersConfig.setBoolean(
+              CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+              /* subsection= */ null,
+              GeneralConfig.KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS,
+              /* value= */ false);
+          codeOwnersConfig.setBoolean(
+              GeneralConfig.SECTION_VALIDATION,
+              "refs/heads/master",
+              GeneralConfig.KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS,
+              /* value= */ true);
+        });
+    assertThat(cfgSnapshot().rejectNonResolvableCodeOwners("master")).isTrue();
+    assertThat(cfgSnapshot().rejectNonResolvableCodeOwners("refs/heads/master")).isTrue();
+    assertThat(cfgSnapshot().rejectNonResolvableCodeOwners("foo")).isFalse();
+  }
+
+  @Test
+  public void getRejectNonResolvableCodeOwners_inheritedBranchLevelConfigTakesPrecedence()
+      throws Exception {
+    configureRejectNonResolvableCodeOwnersForBranch(allProjects, "refs/heads/master", true);
+    configureRejectNonResolvableCodeOwners(project, false);
+    assertThat(cfgSnapshot().rejectNonResolvableCodeOwners("master")).isTrue();
+    assertThat(cfgSnapshot().rejectNonResolvableCodeOwners("refs/heads/master")).isTrue();
+    assertThat(cfgSnapshot().rejectNonResolvableCodeOwners("foo")).isFalse();
+  }
+
+  @Test
+  public void getRejectNonResolvableCodeOwners_inheritedBranchLevelCanBeOverridden()
+      throws Exception {
+    configureRejectNonResolvableCodeOwnersForBranch(allProjects, "refs/heads/master", true);
+    configureRejectNonResolvableCodeOwnersForBranch(project, "refs/heads/master", false);
+    assertThat(cfgSnapshot().rejectNonResolvableCodeOwners("master")).isFalse();
+  }
+
+  @Test
+  public void cannotGetRejectNonResolvableImportsForNullBranch() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> cfgSnapshot().rejectNonResolvableImports(/* branchName= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("branchName");
+  }
+
+  @Test
+  public void getRejectNonResolvableImports_notConfigured() throws Exception {
+    assertThat(cfgSnapshot().rejectNonResolvableImports("master")).isTrue();
+    assertThat(cfgSnapshot().rejectNonResolvableImports("non-existing")).isTrue();
+  }
+
+  @Test
+  public void getRejectNonResolvableImports_configuredOnProjectLevel() throws Exception {
+    configureRejectNonResolvableImports(project, false);
+    assertThat(cfgSnapshot().rejectNonResolvableImports("master")).isFalse();
+    assertThat(cfgSnapshot().rejectNonResolvableImports("non-existing")).isFalse();
+  }
+
+  @Test
+  public void getRejectNonResolvableImports_configuredOnBranchLevel() throws Exception {
+    configureRejectNonResolvableImportsForBranch(project, "refs/heads/master", false);
+    assertThat(cfgSnapshot().rejectNonResolvableImports("master")).isFalse();
+    assertThat(cfgSnapshot().rejectNonResolvableImports("refs/heads/master")).isFalse();
+    assertThat(cfgSnapshot().rejectNonResolvableImports("foo")).isTrue();
+  }
+
+  @Test
+  public void getRejectNonResolvableImports_branchLevelConfigTakesPrecedence() throws Exception {
+    updateCodeOwnersConfig(
+        project,
+        codeOwnersConfig -> {
+          codeOwnersConfig.setBoolean(
+              CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+              /* subsection= */ null,
+              GeneralConfig.KEY_REJECT_NON_RESOLVABLE_IMPORTS,
+              /* value= */ false);
+          codeOwnersConfig.setBoolean(
+              GeneralConfig.SECTION_VALIDATION,
+              "refs/heads/master",
+              GeneralConfig.KEY_REJECT_NON_RESOLVABLE_IMPORTS,
+              /* value= */ true);
+        });
+    assertThat(cfgSnapshot().rejectNonResolvableImports("master")).isTrue();
+    assertThat(cfgSnapshot().rejectNonResolvableImports("refs/heads/master")).isTrue();
+    assertThat(cfgSnapshot().rejectNonResolvableImports("foo")).isFalse();
+  }
+
+  @Test
+  public void getRejectNonResolvableImports_inheritedBranchLevelConfigTakesPrecedence()
+      throws Exception {
+    configureRejectNonResolvableImportsForBranch(allProjects, "refs/heads/master", true);
+    configureRejectNonResolvableImports(project, false);
+    assertThat(cfgSnapshot().rejectNonResolvableImports("master")).isTrue();
+    assertThat(cfgSnapshot().rejectNonResolvableImports("refs/heads/master")).isTrue();
+    assertThat(cfgSnapshot().rejectNonResolvableImports("foo")).isFalse();
+  }
+
+  @Test
+  public void getRejectNonResolvableImports_inheritedBranchLevelCanBeOverridden() throws Exception {
+    configureRejectNonResolvableImportsForBranch(allProjects, "refs/heads/master", true);
+    configureRejectNonResolvableImportsForBranch(project, "refs/heads/master", false);
+    assertThat(cfgSnapshot().rejectNonResolvableImports("master")).isFalse();
+  }
+
+  private CodeOwnersPluginProjectConfigSnapshot cfgSnapshot() {
+    return codeOwnersPluginProjectConfigSnapshotFactory.create(project);
+  }
+
+  private void configureFileExtension(Project.NameKey project, String fileExtension)
+      throws Exception {
+    setCodeOwnersConfig(
+        project, /* subsection= */ null, GeneralConfig.KEY_FILE_EXTENSION, fileExtension);
+  }
+
+  private void configureMergeCommitStrategy(
+      Project.NameKey project, MergeCommitStrategy mergeCommitStrategy) throws Exception {
+    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 configureGlobalCodeOwners(Project.NameKey project, String... globalCodeOwners)
+      throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        GeneralConfig.KEY_GLOBAL_CODE_OWNER,
+        ImmutableList.copyOf(globalCodeOwners));
+  }
+
+  private void configureExemptedUsers(Project.NameKey project, String... exemptedUsers)
+      throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        GeneralConfig.KEY_EXEMPTED_USER,
+        ImmutableList.copyOf(exemptedUsers));
+  }
+
+  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 void configureDisabled(Project.NameKey project, String disabled) throws Exception {
+    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);
+  }
+
+  private void configureBackend(Project.NameKey project, String backendName) throws Exception {
+    configureBackend(project, /* branch= */ null, backendName);
+  }
+
+  private void configureBackend(
+      Project.NameKey project, @Nullable String branch, String backendName) throws Exception {
+    setCodeOwnersConfig(project, branch, BackendConfig.KEY_BACKEND, backendName);
+  }
+
+  private void configureRequiredApproval(Project.NameKey project, String requiredApproval)
+      throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        RequiredApprovalConfig.KEY_REQUIRED_APPROVAL,
+        requiredApproval);
+  }
+
+  private void configureOverrideApproval(Project.NameKey project, String requiredApproval)
+      throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL,
+        requiredApproval);
+  }
+
+  private void configureEnableValidationOnCommitReceived(
+      Project.NameKey project, CodeOwnerConfigValidationPolicy codeOwnerConfigValidationPolicy)
+      throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        GeneralConfig.KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
+        codeOwnerConfigValidationPolicy.name());
+  }
+
+  private void configureEnableValidationOnCommitReceivedForBranch(
+      Project.NameKey project,
+      String branchSubsection,
+      CodeOwnerConfigValidationPolicy codeOwnerConfigValidationPolicy)
+      throws Exception {
+    updateCodeOwnersConfig(
+        project,
+        codeOwnersConfig ->
+            codeOwnersConfig.setString(
+                GeneralConfig.SECTION_VALIDATION,
+                branchSubsection,
+                GeneralConfig.KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
+                codeOwnerConfigValidationPolicy.name()));
+  }
+
+  private void configureEnableValidationOnSubmit(
+      Project.NameKey project, CodeOwnerConfigValidationPolicy codeOwnerConfigValidationPolicy)
+      throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        GeneralConfig.KEY_ENABLE_VALIDATION_ON_SUBMIT,
+        codeOwnerConfigValidationPolicy.name());
+  }
+
+  private void configureEnableValidationOnSubmitForBranch(
+      Project.NameKey project,
+      String branchSubsection,
+      CodeOwnerConfigValidationPolicy codeOwnerConfigValidationPolicy)
+      throws Exception {
+    updateCodeOwnersConfig(
+        project,
+        codeOwnersConfig ->
+            codeOwnersConfig.setString(
+                GeneralConfig.SECTION_VALIDATION,
+                branchSubsection,
+                GeneralConfig.KEY_ENABLE_VALIDATION_ON_SUBMIT,
+                codeOwnerConfigValidationPolicy.name()));
+  }
+
+  private void configureRejectNonResolvableCodeOwners(Project.NameKey project, boolean value)
+      throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        GeneralConfig.KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS,
+        Boolean.toString(value));
+  }
+
+  private void configureRejectNonResolvableCodeOwnersForBranch(
+      Project.NameKey project, String branchSubsection, boolean value) throws Exception {
+    updateCodeOwnersConfig(
+        project,
+        codeOwnersConfig ->
+            codeOwnersConfig.setString(
+                GeneralConfig.SECTION_VALIDATION,
+                branchSubsection,
+                GeneralConfig.KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS,
+                Boolean.toString(value)));
+  }
+
+  private void configureRejectNonResolvableImports(Project.NameKey project, boolean value)
+      throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        GeneralConfig.KEY_REJECT_NON_RESOLVABLE_IMPORTS,
+        Boolean.toString(value));
+  }
+
+  private void configureRejectNonResolvableImportsForBranch(
+      Project.NameKey project, String branchSubsection, boolean value) throws Exception {
+    updateCodeOwnersConfig(
+        project,
+        codeOwnersConfig ->
+            codeOwnersConfig.setString(
+                GeneralConfig.SECTION_VALIDATION,
+                branchSubsection,
+                GeneralConfig.KEY_REJECT_NON_RESOLVABLE_IMPORTS,
+                Boolean.toString(value)));
+  }
+
+  private AutoCloseable registerTestBackend() {
+    RegistrationHandle registrationHandle =
+        ((PrivateInternals_DynamicMapImpl<CodeOwnerBackend>) codeOwnerBackends)
+            .put("gerrit", TestCodeOwnerBackend.ID, Providers.of(new TestCodeOwnerBackend()));
+    return registrationHandle::remove;
+  }
+
+  private static class TestCodeOwnerBackend implements CodeOwnerBackend {
+    static final String ID = "test-backend";
+
+    @Override
+    public Optional<CodeOwnerConfig> getCodeOwnerConfig(
+        CodeOwnerConfig.Key codeOwnerConfigKey,
+        @Nullable RevWalk revWalk,
+        @Nullable ObjectId revision) {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public Optional<CodeOwnerConfig> upsertCodeOwnerConfig(
+        CodeOwnerConfig.Key codeOwnerConfigKey,
+        CodeOwnerConfigUpdate codeOwnerConfigUpdate,
+        @Nullable IdentifiedUser currentUser) {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public boolean isCodeOwnerConfigFile(NameKey project, String fileName) {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public Path getFilePath(CodeOwnerConfig.Key codeOwnerConfigKey) {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public Optional<PathExpressionMatcher> getPathExpressionMatcher() {
+      return Optional.empty();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java
new file mode 100644
index 0000000..9220d02
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java
@@ -0,0 +1,1661 @@
+// 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.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_IMPLICIT_APPROVALS;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_VALIDATION_ON_SUBMIT;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_EXEMPTED_USER;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_EXEMPT_PURE_REVERTS;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_FALLBACK_CODE_OWNERS;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_FILE_EXTENSION;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_GLOBAL_CODE_OWNER;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_INVALID_CODE_OWNER_CONFIG_INFO_URL;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_MAX_PATHS_IN_CHANGE_MESSAGES;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_MERGE_COMMIT_STRATEGY;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_OVERRIDE_INFO_URL;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_READ_ONLY;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_REJECT_NON_RESOLVABLE_IMPORTS;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.SECTION_VALIDATION;
+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.Iterables;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
+import com.google.gerrit.plugins.codeowners.backend.EnableImplicitApprovals;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerConfigValidationPolicy;
+import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig}. */
+public class GeneralConfigTest extends AbstractCodeOwnersTest {
+  private GeneralConfig generalConfig;
+
+  @Before
+  public void setUpCodeOwnersPlugin() throws Exception {
+    generalConfig = plugin.getSysInjector().getInstance(GeneralConfig.class);
+  }
+
+  @Test
+  public void cannotGetFileExtensionForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getFileExtension(/* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noFileExtensionConfigured() throws Exception {
+    assertThat(generalConfig.getFileExtension(new Config())).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
+  public void fileExtensionIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+      throws Exception {
+    assertThat(generalConfig.getFileExtension(new Config())).value().isEqualTo("foo");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
+  public void fileExtensionInPluginConfigOverridesFileExtensionInGerritConfig() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, /* subsection= */ null, KEY_FILE_EXTENSION, "bar");
+    assertThat(generalConfig.getFileExtension(cfg)).value().isEqualTo("bar");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.allowedEmailDomain",
+      values = {"example.com", "example.net"})
+  public void getConfiguredEmailDomains() throws Exception {
+    assertThat(generalConfig.getAllowedEmailDomains())
+        .containsExactly("example.com", "example.net");
+  }
+
+  @Test
+  public void noEmailDomainsConfigured() throws Exception {
+    assertThat(generalConfig.getAllowedEmailDomains()).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.allowedEmailDomain", value = "")
+  public void emptyEmailDomainsConfigured() throws Exception {
+    assertThat(generalConfig.getAllowedEmailDomains()).isEmpty();
+  }
+
+  @Test
+  public void cannotGetReadOnlyForNullProject() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getReadOnly(/* project= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void cannotGetReadOnlyForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getReadOnly(project, /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noReadOnlyConfiguration() throws Exception {
+    assertThat(generalConfig.getReadOnly(project, new Config())).isFalse();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.readOnly", value = "true")
+  public void readOnlyConfigurationIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+      throws Exception {
+    assertThat(generalConfig.getReadOnly(project, new Config())).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.readOnly", value = "true")
+  public void readOnlyConfigurationInPluginConfigOverridesReadOnlyConfigurationInGerritConfig()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, /* subsection= */ null, KEY_READ_ONLY, "false");
+    assertThat(generalConfig.getReadOnly(project, cfg)).isFalse();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.readOnly", value = "true")
+  public void invalidReadOnlyConfigurationInPluginConfigIsIgnored() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, /* subsection= */ null, KEY_READ_ONLY, "INVALID");
+    assertThat(generalConfig.getReadOnly(project, cfg)).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.readOnly", value = "INVALID")
+  public void invalidReadOnlyConfigurationInGerritConfigIsIgnored() throws Exception {
+    assertThat(generalConfig.getReadOnly(project, new Config())).isFalse();
+  }
+
+  @Test
+  public void cannotGetExemptPureRevertsForNullProject() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getExemptPureReverts(/* project= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void cannotGetExemptPureRevertsForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getExemptPureReverts(project, /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noExemptPureRevertsConfiguration() throws Exception {
+    assertThat(generalConfig.getExemptPureReverts(project, new Config())).isFalse();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.exemptPureReverts", value = "true")
+  public void
+      exemptPureRevertsConfigurationIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+          throws Exception {
+    assertThat(generalConfig.getExemptPureReverts(project, new Config())).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.exemptPureReverts", value = "true")
+  public void
+      exemptPureRevertsConfigurationInPluginConfigOverridesExemptPureRevertsConfigurationInGerritConfig()
+          throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, null, KEY_EXEMPT_PURE_REVERTS, "false");
+    assertThat(generalConfig.getExemptPureReverts(project, cfg)).isFalse();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.exemptPureReverts", value = "true")
+  public void invalidExemptPureRevertsInPluginConfigIsIgnored() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, /* subsection= */ null, KEY_EXEMPT_PURE_REVERTS, "INVALID");
+    assertThat(generalConfig.getExemptPureReverts(project, cfg)).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.exemptPureReverts", value = "INVALID")
+  public void invalidExemptPureRevertsConfigurationInGerritConfigIsIgnored() throws Exception {
+    assertThat(generalConfig.getExemptPureReverts(project, new Config())).isFalse();
+  }
+
+  @Test
+  public void cannotGetRejectNonResolvableCodeOwnersForNullProject() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.getRejectNonResolvableCodeOwners(/* project= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void cannotGetRejectNonResolvableCodeOwnersForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.getRejectNonResolvableCodeOwners(project, /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noRejectNonResolvableCodeOwnersConfiguration() throws Exception {
+    assertThat(generalConfig.getRejectNonResolvableCodeOwners(project, new Config())).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.rejectNonResolvableCodeOwners", value = "false")
+  public void
+      rejectNonResolvableCodeOwnersConfigurationIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+          throws Exception {
+    assertThat(generalConfig.getRejectNonResolvableCodeOwners(project, new Config())).isFalse();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.rejectNonResolvableCodeOwners", value = "false")
+  public void
+      rejectNonResolvableCodeOwnersConfigurationInPluginConfigOverridesRejectNonResolvableCodeOwnersConfigurationInGerritConfig()
+          throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS, /* subsection= */ null, KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS, "true");
+    assertThat(generalConfig.getRejectNonResolvableCodeOwners(project, cfg)).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.rejectNonResolvableCodeOwners", value = "false")
+  public void invalidRejectNonResolvableCodeOwnersInPluginConfigIsIgnored() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS,
+        "INVALID");
+    assertThat(generalConfig.getRejectNonResolvableCodeOwners(project, cfg)).isFalse();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.rejectNonResolvableCodeOwners", value = "INVALID")
+  public void invalidRejectNonResolvableCodeOwnersConfigurationInGerritConfigIsIgnored()
+      throws Exception {
+    assertThat(generalConfig.getRejectNonResolvableCodeOwners(project, new Config())).isTrue();
+  }
+
+  @Test
+  public void cannotGetRejectNonResolvableCodeOwnersForBranchForNullBranch() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.getRejectNonResolvableCodeOwnersForBranch(
+                    /* branchNameKey= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("branchNameKey");
+  }
+
+  @Test
+  public void cannotGetRejectNonResolvableCodeOwnersForBranchForNullConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.getRejectNonResolvableCodeOwnersForBranch(
+                    BranchNameKey.create(project, "master"), /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noBranchSpecificRejectNonResolvableCodeOwnersConfiguration() throws Exception {
+    assertThat(
+            generalConfig.getRejectNonResolvableCodeOwnersForBranch(
+                BranchNameKey.create(project, "master"), new Config()))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificRejectNonResolvableCodeOwnersConfiguration_exact()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setBoolean(
+        SECTION_VALIDATION,
+        "refs/heads/foo",
+        KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS,
+        /* value= */ false);
+    assertThat(
+            generalConfig.getRejectNonResolvableCodeOwnersForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificRejectNonResolvableCodeOwnersConfiguration_refPattern()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setBoolean(
+        SECTION_VALIDATION,
+        "refs/heads/foo/*",
+        KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS,
+        /* value= */ false);
+    assertThat(
+            generalConfig.getRejectNonResolvableCodeOwnersForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificRejectNonResolvableCodeOwnersConfiguration_regEx()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setBoolean(
+        SECTION_VALIDATION,
+        "^refs/heads/.*foo.*",
+        KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS,
+        /* value= */ false);
+    assertThat(
+            generalConfig.getRejectNonResolvableCodeOwnersForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificRejectNonResolvableCodeOwnersConfiguration_invalidRegEx()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setBoolean(
+        SECTION_VALIDATION,
+        "^refs/heads/[",
+        KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS,
+        /* value= */ false);
+    assertThat(
+            generalConfig.getRejectNonResolvableCodeOwnersForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void matchingBranchSpecificRejectNonResolvableCodeOwnersConfiguration_exact()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setBoolean(
+        SECTION_VALIDATION,
+        "refs/heads/master",
+        KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS,
+        /* value= */ false);
+    assertThat(
+            generalConfig.getRejectNonResolvableCodeOwnersForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .value()
+        .isEqualTo(false);
+  }
+
+  @Test
+  public void matchingBranchSpecificRejectNonResolvableCodeOwnersConfiguration_refPattern()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setBoolean(
+        SECTION_VALIDATION,
+        "refs/heads/*",
+        KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS,
+        /* value= */ false);
+    assertThat(
+            generalConfig.getRejectNonResolvableCodeOwnersForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .value()
+        .isEqualTo(false);
+  }
+
+  @Test
+  public void matchingBranchSpecificRejectNonResolvableCodeOwnersConfiguration_regEx()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setBoolean(
+        SECTION_VALIDATION,
+        "^refs/heads/.*bar.*",
+        KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS,
+        /* value= */ false);
+    assertThat(
+            generalConfig.getRejectNonResolvableCodeOwnersForBranch(
+                BranchNameKey.create(project, "foobarbaz"), cfg))
+        .value()
+        .isEqualTo(false);
+  }
+
+  @Test
+  public void branchSpecificRejectNonResolvableCodeOwnersConfigurationIsIgnoredIfValueIsInvalid()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "refs/heads/master", KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS, "INVALID");
+    assertThat(
+            generalConfig.getRejectNonResolvableCodeOwnersForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void multipleMatchingBranchSpecificRejectNonResolvableCodeOwnersConfiguration()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setBoolean(
+        SECTION_VALIDATION,
+        "refs/heads/master",
+        KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS,
+        /* value= */ false);
+    cfg.setBoolean(
+        SECTION_VALIDATION,
+        "refs/heads/*",
+        KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS,
+        /* value= */ false);
+    cfg.setBoolean(
+        SECTION_VALIDATION,
+        "^refs/heads/.*",
+        KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS,
+        /* value= */ false);
+
+    // it is non-deterministic which of the branch-specific configurations takes precedence, but
+    // since they all configure the same value it's not important for this assertion
+    assertThat(
+            generalConfig.getRejectNonResolvableCodeOwnersForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .value()
+        .isEqualTo(false);
+  }
+
+  @Test
+  public void cannotGetRejectNonResolvableImportsForNullProject() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getRejectNonResolvableImports(/* project= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void cannotGetRejectNonResolvableImportsForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getRejectNonResolvableImports(project, /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noRejectNonResolvableImportsConfiguration() throws Exception {
+    assertThat(generalConfig.getRejectNonResolvableImports(project, new Config())).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.rejectNonResolvableImports", value = "false")
+  public void
+      rejectNonResolvableImportsConfigurationIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+          throws Exception {
+    assertThat(generalConfig.getRejectNonResolvableImports(project, new Config())).isFalse();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.rejectNonResolvableImports", value = "false")
+  public void
+      rejectNonResolvableImportsConfigurationInPluginConfigOverridesRejectNonResolvableImportsConfigurationInGerritConfig()
+          throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS, /* scubsection= */ null, KEY_REJECT_NON_RESOLVABLE_IMPORTS, "true");
+    assertThat(generalConfig.getRejectNonResolvableImports(project, cfg)).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.rejectNonResolvableImports", value = "false")
+  public void invalidRejectNonResolvableImportsInPluginConfigIsIgnored() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS, /* subsection= */ null, KEY_REJECT_NON_RESOLVABLE_IMPORTS, "INVALID");
+    assertThat(generalConfig.getRejectNonResolvableImports(project, cfg)).isFalse();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.rejectNonResolvableImports", value = "INVALID")
+  public void invalidRejectNonResolvableImportsConfigurationInGerritConfigIsIgnored()
+      throws Exception {
+    assertThat(generalConfig.getRejectNonResolvableImports(project, new Config())).isTrue();
+  }
+
+  @Test
+  public void cannotGetRejectNonResolvableImportsForBranchForNullBranch() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.getRejectNonResolvableImportsForBranch(
+                    /* branchNameKey= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("branchNameKey");
+  }
+
+  @Test
+  public void cannotGetRejectNonResolvableImportsForBranchForNullConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.getRejectNonResolvableImportsForBranch(
+                    BranchNameKey.create(project, "master"), /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noBranchSpecificRejectNonResolvableImportsConfiguration() throws Exception {
+    assertThat(
+            generalConfig.getRejectNonResolvableImportsForBranch(
+                BranchNameKey.create(project, "master"), new Config()))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificRejectNonResolvableImportsConfiguration_exact()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setBoolean(
+        SECTION_VALIDATION,
+        "refs/heads/foo",
+        KEY_REJECT_NON_RESOLVABLE_IMPORTS,
+        /* value= */ false);
+    assertThat(
+            generalConfig.getRejectNonResolvableImportsForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificRejectNonResolvableImportsConfiguration_refPattern()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setBoolean(
+        SECTION_VALIDATION,
+        "refs/heads/foo/*",
+        KEY_REJECT_NON_RESOLVABLE_IMPORTS,
+        /* value= */ false);
+    assertThat(
+            generalConfig.getRejectNonResolvableImportsForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificRejectNonResolvableImportsConfiguration_regEx()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setBoolean(
+        SECTION_VALIDATION,
+        "^refs/heads/.*foo.*",
+        KEY_REJECT_NON_RESOLVABLE_IMPORTS,
+        /* value= */ false);
+    assertThat(
+            generalConfig.getRejectNonResolvableImportsForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificRejectNonResolvableImportsConfiguration_invalidRegEx()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setBoolean(
+        SECTION_VALIDATION, "^refs/heads/[", KEY_REJECT_NON_RESOLVABLE_IMPORTS, /* value= */ false);
+    assertThat(
+            generalConfig.getRejectNonResolvableImportsForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void matchingBranchSpecificRejectNonResolvableImportsConfiguration_exact()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setBoolean(
+        SECTION_VALIDATION,
+        "refs/heads/master",
+        KEY_REJECT_NON_RESOLVABLE_IMPORTS,
+        /* value= */ false);
+    assertThat(
+            generalConfig.getRejectNonResolvableImportsForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .value()
+        .isEqualTo(false);
+  }
+
+  @Test
+  public void matchingBranchSpecificRejectNonResolvableImportsConfiguration_refPattern()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setBoolean(
+        SECTION_VALIDATION, "refs/heads/*", KEY_REJECT_NON_RESOLVABLE_IMPORTS, /* value= */ false);
+    assertThat(
+            generalConfig.getRejectNonResolvableImportsForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .value()
+        .isEqualTo(false);
+  }
+
+  @Test
+  public void matchingBranchSpecificRejectNonResolvableImportsConfiguration_regEx()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setBoolean(
+        SECTION_VALIDATION,
+        "^refs/heads/.*bar.*",
+        KEY_REJECT_NON_RESOLVABLE_IMPORTS,
+        /* value= */ false);
+    assertThat(
+            generalConfig.getRejectNonResolvableImportsForBranch(
+                BranchNameKey.create(project, "foobarbaz"), cfg))
+        .value()
+        .isEqualTo(false);
+  }
+
+  @Test
+  public void branchSpecificRejectNonResolvableImportsConfigurationIsIgnoredIfValueIsInvalid()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "refs/heads/master", KEY_REJECT_NON_RESOLVABLE_IMPORTS, "INVALID");
+    assertThat(
+            generalConfig.getRejectNonResolvableImportsForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void multipleMatchingBranchSpecificRejectNonResolvableImportsConfiguration()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setBoolean(
+        SECTION_VALIDATION,
+        "refs/heads/master",
+        KEY_REJECT_NON_RESOLVABLE_IMPORTS,
+        /* value= */ false);
+    cfg.setBoolean(
+        SECTION_VALIDATION, "refs/heads/*", KEY_REJECT_NON_RESOLVABLE_IMPORTS, /* value= */ false);
+    cfg.setBoolean(
+        SECTION_VALIDATION,
+        "^refs/heads/.*",
+        KEY_REJECT_NON_RESOLVABLE_IMPORTS,
+        /* value= */ false);
+
+    // it is non-deterministic which of the branch-specific configurations takes precedence, but
+    // since they all configure the same value it's not important for this assertion
+    assertThat(
+            generalConfig.getRejectNonResolvableImportsForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .value()
+        .isEqualTo(false);
+  }
+
+  @Test
+  public void cannotGetEnableValidationOnCommitReceivedForNullProject() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceived(
+                    /* project= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void cannotGetEnableValidationOnCommitReceivedForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceived(
+                    project, /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noEnableValidationOnCommitReceivedConfiguration() throws Exception {
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceived(
+                project, new Config()))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnCommitReceived", value = "false")
+  public void
+      enableValidationOnCommitReceivedConfigurationIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+          throws Exception {
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceived(
+                project, new Config()))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnCommitReceived", value = "false")
+  public void
+      enableValidationOnCommitReceivedConfigurationInPluginConfigOverridesEnableValidationOnCommitReceivedConfigurationInGerritConfig()
+          throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
+        "true");
+    assertThat(generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceived(project, cfg))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnCommitReceived", value = "false")
+  public void invalidEnableValidationOnCommitReceivedConfigurationInPluginConfigIsIgnored()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
+        "INVALID");
+    assertThat(generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceived(project, cfg))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnCommitReceived", value = "INVALID")
+  public void invalidEnableValidationOnCommitReceivedConfigurationInGerritConfigIsIgnored()
+      throws Exception {
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceived(
+                project, new Config()))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+  }
+
+  @Test
+  public void cannotGetEnableValidationOnCommitReceivedForBranchForNullBranch() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                    /* branchNameKey= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("branchNameKey");
+  }
+
+  @Test
+  public void cannotGetEnableValidationOnCommitReceivedForBranchForNullPluginConfig()
+      throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                    BranchNameKey.create(project, "master"), /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noBranchSpecificEnableValidationOnCommitReceivedConfiguration() throws Exception {
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "master"), new Config()))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificEnableValidationOnCommitReceivedConfiguration_exact()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "refs/heads/foo", KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificEnableValidationOnCommitReceivedConfiguration_refPattern()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "refs/heads/foo/*", KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificEnableValidationOnCommitReceivedConfiguration_regEx()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION,
+        "^refs/heads/.*foo.*",
+        KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
+        "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificEnableValidationOnCommitReceivedConfiguration_invalidRegEx()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "^refs/heads/[", KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void matchingBranchSpecificEnableValidationOnCommitReceivedConfiguration_exact()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "refs/heads/master", KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .value()
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  public void matchingBranchSpecificEnableValidationOnCommitReceivedConfiguration_refPattern()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "refs/heads/*", KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .value()
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  public void matchingBranchSpecificEnableValidationOnCommitReceivedConfiguration_regEx()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION,
+        "^refs/heads/.*bar.*",
+        KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
+        "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "foobarbaz"), cfg))
+        .value()
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  public void branchSpecificEnableValidationOnCommitReceivedConfigurationIsIgnoredIfValueIsInvalid()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION,
+        "refs/heads/master",
+        KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
+        "INVALID");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void multipleMatchingBranchSpecificEnableValidationOnCommitReceivedConfiguration()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "refs/heads/master", KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED, "false");
+    cfg.setString(
+        SECTION_VALIDATION, "refs/heads/*", KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED, "false");
+    cfg.setString(
+        SECTION_VALIDATION, "^refs/heads/.*", KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED, "false");
+
+    // it is non-deterministic which of the branch-specific configurations takes precedence, but
+    // since they all configure the same value it's not important for this assertion
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .value()
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  public void cannotGetEnableValidationOnSubmitForNullProject() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.getCodeOwnerConfigValidationPolicyForSubmit(
+                    /* project= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void cannotGetEnableValidationOnSubmitForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.getCodeOwnerConfigValidationPolicyForSubmit(
+                    project, /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noEnableValidationOnSubmitConfiguration() throws Exception {
+    assertThat(generalConfig.getCodeOwnerConfigValidationPolicyForSubmit(project, new Config()))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
+  public void
+      enableValidationOnSubmitConfigurationIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+          throws Exception {
+    assertThat(generalConfig.getCodeOwnerConfigValidationPolicyForSubmit(project, new Config()))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
+  public void
+      enableValidationOnSubmitConfigurationInPluginConfigOverridesEnableValidationOnSubmitConfigurationInGerritConfig()
+          throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS, /* subsection= */ null, KEY_ENABLE_VALIDATION_ON_SUBMIT, "false");
+    assertThat(generalConfig.getCodeOwnerConfigValidationPolicyForSubmit(project, cfg))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "true")
+  public void invalidEnableValidationOnSubmitConfigurationInPluginConfigIsIgnored()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS, /* subsection= */ null, KEY_ENABLE_VALIDATION_ON_SUBMIT, "INVALID");
+    assertThat(generalConfig.getCodeOwnerConfigValidationPolicyForSubmit(project, cfg))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableValidationOnSubmit", value = "INVALID")
+  public void invalidEnableValidationOnSubmitConfigurationInGerritConfigIsIgnored()
+      throws Exception {
+    assertThat(generalConfig.getCodeOwnerConfigValidationPolicyForSubmit(project, new Config()))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  public void cannotGetEnableValidationOnSubmitForBranchForNullBranch() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                    /* branchNameKey= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("branchNameKey");
+  }
+
+  @Test
+  public void cannotGetEnableValidationOnSubmitForBranchForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                    BranchNameKey.create(project, "master"), /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noBranchSpecificEnableValidationOnSubmitConfiguration() throws Exception {
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                BranchNameKey.create(project, "master"), new Config()))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificEnableValidationOnSubmitConfiguration_exact()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_VALIDATION, "refs/heads/foo", KEY_ENABLE_VALIDATION_ON_SUBMIT, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificEnableValidationOnSubmitConfiguration_refPattern()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_VALIDATION, "refs/heads/foo/*", KEY_ENABLE_VALIDATION_ON_SUBMIT, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificEnableValidationOnSubmitConfiguration_regEx()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "^refs/heads/.*foo.*", KEY_ENABLE_VALIDATION_ON_SUBMIT, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificEnableValidationOnSubmitConfiguration_invalidRegEx()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_VALIDATION, "^refs/heads/[", KEY_ENABLE_VALIDATION_ON_SUBMIT, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void matchingBranchSpecificEnableValidationOnSubmitConfiguration_exact() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "refs/heads/master", KEY_ENABLE_VALIDATION_ON_SUBMIT, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .value()
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  public void matchingBranchSpecificEnableValidationOnSubmitConfiguration_refPattern()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_VALIDATION, "refs/heads/*", KEY_ENABLE_VALIDATION_ON_SUBMIT, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .value()
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  public void matchingBranchSpecificEnableValidationOnSubmitConfiguration_regEx() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "^refs/heads/.*bar.*", KEY_ENABLE_VALIDATION_ON_SUBMIT, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                BranchNameKey.create(project, "foobarbaz"), cfg))
+        .value()
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  public void branchSpecificEnableValidationOnSubmitConfigurationIsIgnoredIfValueIsInvalid()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "refs/heads/master", KEY_ENABLE_VALIDATION_ON_SUBMIT, "INVALID");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void multipleMatchingBranchSpecificEnableValidationOnSubmitConfiguration()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "refs/heads/master", KEY_ENABLE_VALIDATION_ON_SUBMIT, "false");
+    cfg.setString(SECTION_VALIDATION, "refs/heads/*", KEY_ENABLE_VALIDATION_ON_SUBMIT, "false");
+    cfg.setString(SECTION_VALIDATION, "^refs/heads/.*", KEY_ENABLE_VALIDATION_ON_SUBMIT, "false");
+
+    // it is non-deterministic which of the branch-specific configurations takes precedence, but
+    // since they all configure the same value it's not important for this assertion
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .value()
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  public void cannotGetMergeCommitStrategyForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getMergeCommitStrategy(project, /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void cannotGetMergeCommitStrategyForNullProjectName() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getMergeCommitStrategy(/* project= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void noMergeCommitStrategyConfigured() throws Exception {
+    assertThat(generalConfig.getMergeCommitStrategy(project, new Config()))
+        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.mergeCommitStrategy",
+      value = "FILES_WITH_CONFLICT_RESOLUTION")
+  public void mergeCommitStrategyIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+      throws Exception {
+    assertThat(generalConfig.getMergeCommitStrategy(project, new Config()))
+        .isEqualTo(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.mergeCommitStrategy",
+      value = "FILES_WITH_CONFLICT_RESOLUTION")
+  public void mergeCommitStrategyInPluginConfigOverridesMergeCommitStrategyInGerritConfig()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        KEY_MERGE_COMMIT_STRATEGY,
+        MergeCommitStrategy.ALL_CHANGED_FILES.name());
+    assertThat(generalConfig.getMergeCommitStrategy(project, cfg))
+        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.mergeCommitStrategy",
+      value = "FILES_WITH_CONFLICT_RESOLUTION")
+  public void invalidMergeCommitStrategyInPluginConfigIsIgnored() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS, /* subsection= */ null, KEY_MERGE_COMMIT_STRATEGY, "INVALID");
+    assertThat(generalConfig.getMergeCommitStrategy(project, cfg))
+        .isEqualTo(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.mergeCommitStrategy", value = "INVALID")
+  public void invalidMergeCommitStrategyInGerritConfigIsIgnored() throws Exception {
+    assertThat(generalConfig.getMergeCommitStrategy(project, new Config()))
+        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
+  }
+
+  @Test
+  public void cannotValidateProjectLevelConfigWithNullFileName() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.validateProjectLevelConfig(/*project= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("fileName");
+  }
+
+  @Test
+  public void cannotValidateProjectLevelConfigWithForNullProjectLevelConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.validateProjectLevelConfig(
+                    "code-owners.config", /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("projectLevelConfig");
+  }
+
+  @Test
+  public void validateEmptyProjectLevelConfig() throws Exception {
+    ImmutableList<CommitValidationMessage> commitValidationMessage =
+        generalConfig.validateProjectLevelConfig("code-owners.config", new Config());
+    assertThat(commitValidationMessage).isEmpty();
+  }
+
+  @Test
+  public void validateValidProjectLevelConfig() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        KEY_MERGE_COMMIT_STRATEGY,
+        MergeCommitStrategy.ALL_CHANGED_FILES.name());
+    ImmutableList<CommitValidationMessage> commitValidationMessage =
+        generalConfig.validateProjectLevelConfig("code-owners.config", cfg);
+    assertThat(commitValidationMessage).isEmpty();
+  }
+
+  @Test
+  public void validateInvalidProjectLevelConfig_invalidMergeCommitStrategy() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS, /* subsection= */ null, KEY_MERGE_COMMIT_STRATEGY, "INVALID");
+    ImmutableList<CommitValidationMessage> commitValidationMessages =
+        generalConfig.validateProjectLevelConfig("code-owners.config", cfg);
+    assertThat(commitValidationMessages).hasSize(1);
+    CommitValidationMessage commitValidationMessage =
+        Iterables.getOnlyElement(commitValidationMessages);
+    assertThat(commitValidationMessage.getType()).isEqualTo(ValidationMessage.Type.ERROR);
+    assertThat(commitValidationMessage.getMessage())
+        .isEqualTo(
+            "Merge commit strategy 'INVALID' that is configured in code-owners.config"
+                + " (parameter codeOwners.mergeCommitStrategy) is invalid.");
+  }
+
+  @Test
+  public void cannotGetEnableImplicitApprovalsForNullProject() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getEnableImplicitApprovals(/* project= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void cannotGetEnableImplicitApprovalsForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getEnableImplicitApprovals(project, /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noEnableImplicitApprovalsConfiguration() throws Exception {
+    assertThat(generalConfig.getEnableImplicitApprovals(project, new Config()))
+        .isEqualTo(EnableImplicitApprovals.FALSE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void
+      enableImplicitApprovalsConfigurationIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+          throws Exception {
+    assertThat(generalConfig.getEnableImplicitApprovals(project, new Config()))
+        .isEqualTo(EnableImplicitApprovals.TRUE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void
+      enableImplicitApprovalsConfigurationInPluginConfigOverridesEnableImplicitApprovalsConfigurationInGerritConfig()
+          throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS, /* subsection= */ null, KEY_ENABLE_IMPLICIT_APPROVALS, "false");
+    assertThat(generalConfig.getEnableImplicitApprovals(project, cfg))
+        .isEqualTo(EnableImplicitApprovals.FALSE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void invalidEnableImplicitApprovalsConfigurationInPluginConfigIsIgnored()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS, /* subsection= */ null, KEY_ENABLE_IMPLICIT_APPROVALS, "INVALID");
+    assertThat(generalConfig.getEnableImplicitApprovals(project, cfg))
+        .isEqualTo(EnableImplicitApprovals.TRUE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "INVALID")
+  public void invalidEnableImplicitApprovalsConfigurationInGerritConfigIsIgnored()
+      throws Exception {
+    assertThat(generalConfig.getEnableImplicitApprovals(project, cfg))
+        .isEqualTo(EnableImplicitApprovals.FALSE);
+  }
+
+  @Test
+  public void cannotGetGlobalCodeOwnersForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getGlobalCodeOwners(/* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noGlobalCodeOwners() throws Exception {
+    assertThat(generalConfig.getGlobalCodeOwners(new Config())).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.globalCodeOwner",
+      values = {"bot1@example.com", "bot2@example.com"})
+  public void globalCodeOwnersAreRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+      throws Exception {
+    assertThat(generalConfig.getGlobalCodeOwners(new Config()))
+        .containsExactly(
+            CodeOwnerReference.create("bot1@example.com"),
+            CodeOwnerReference.create("bot2@example.com"));
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.globalCodeOwner",
+      values = {"bot1@example.com", "bot2@example.com"})
+  public void globalCodeOwnersInPluginConfigExtendGlobalCodeOwnersInGerritConfig()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS, /* subsection= */ null, KEY_GLOBAL_CODE_OWNER, "bot3@example.com");
+    assertThat(generalConfig.getGlobalCodeOwners(cfg))
+        .containsExactly(
+            CodeOwnerReference.create("bot1@example.com"),
+            CodeOwnerReference.create("bot2@example.com"),
+            CodeOwnerReference.create("bot3@example.com"));
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.globalCodeOwner",
+      values = {"bot1@example.com", "bot2@example.com"})
+  public void
+      globalCodeOwnersInPluginConfigExtendGlobalCodeOwnersInGerritConfig_duplicatesFilteredOut()
+          throws Exception {
+    Config cfg = new Config();
+    cfg.setStringList(
+        SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        KEY_GLOBAL_CODE_OWNER,
+        ImmutableList.of("bot1@example.com", "bot3@example.com"));
+    assertThat(generalConfig.getGlobalCodeOwners(cfg))
+        .containsExactly(
+            CodeOwnerReference.create("bot1@example.com"),
+            CodeOwnerReference.create("bot2@example.com"),
+            CodeOwnerReference.create("bot3@example.com"));
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.globalCodeOwner",
+      values = {"bot1@example.com", "bot2@example.com"})
+  public void inheritedGlobalOwnersCannotBeRemovedOnProjectLevel() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, /* subsection= */ null, KEY_GLOBAL_CODE_OWNER, "");
+    assertThat(generalConfig.getGlobalCodeOwners(cfg))
+        .containsExactly(
+            CodeOwnerReference.create("bot1@example.com"),
+            CodeOwnerReference.create("bot2@example.com"));
+  }
+
+  @Test
+  public void cannotGetExemptedUsersForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getExemptedUsers(/* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noExemptedUsers() throws Exception {
+    assertThat(generalConfig.getExemptedUsers(new Config())).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.exemptedUser",
+      values = {"bot1@example.com", "bot2@example.com"})
+  public void exemptedUsersAreRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+      throws Exception {
+    assertThat(generalConfig.getExemptedUsers(new Config()))
+        .containsExactly("bot1@example.com", "bot2@example.com");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.exemptedUser",
+      values = {"bot1@example.com", "bot2@example.com"})
+  public void exemptedUsersInPluginConfigExtendExemptedUsersInGerritConfig() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS, /* subsection= */ null, KEY_EXEMPTED_USER, "bot3@example.com");
+    assertThat(generalConfig.getExemptedUsers(cfg))
+        .containsExactly("bot1@example.com", "bot2@example.com", "bot3@example.com");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.exemptedUser",
+      values = {"bot1@example.com", "bot2@example.com"})
+  public void exemptedUsersInPluginConfigExtendExemptedUsersInGerritConfig_duplicatesFilteredOut()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setStringList(
+        SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        KEY_EXEMPTED_USER,
+        ImmutableList.of("bot1@example.com", "bot3@example.com"));
+    assertThat(generalConfig.getExemptedUsers(cfg))
+        .containsExactly("bot1@example.com", "bot2@example.com", "bot3@example.com");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.exemptedUser",
+      values = {"bot1@example.com", "bot2@example.com"})
+  public void inheritedExemptedUsersCannotBeRemovedOnProjectLevel() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, /* subsection= */ null, KEY_EXEMPTED_USER, "");
+    assertThat(generalConfig.getExemptedUsers(cfg))
+        .containsExactly("bot1@example.com", "bot2@example.com");
+  }
+
+  @Test
+  public void cannotGetOverrideInfoUrlForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getOverrideInfoUrl(/* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noOverrideInfoUrlConfigured() throws Exception {
+    assertThat(generalConfig.getOverrideInfoUrl(new Config())).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideInfoUrl", value = "http://foo.example.com")
+  public void overrideInfoIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+      throws Exception {
+    assertThat(generalConfig.getOverrideInfoUrl(new Config()))
+        .value()
+        .isEqualTo("http://foo.example.com");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideInfoUrl", value = "http://foo.example.com")
+  public void overrideInfoUrlInPluginConfigOverridesOverrideInfoUrlInGerritConfig()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        KEY_OVERRIDE_INFO_URL,
+        "http://bar.example.com");
+    assertThat(generalConfig.getOverrideInfoUrl(cfg)).value().isEqualTo("http://bar.example.com");
+  }
+
+  @Test
+  public void cannotGetInvalidCodeOwnerConfigInfoUrlForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getInvalidCodeOwnerConfigInfoUrl(/* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noInvalidCodeOwnerConfigInfoUrlConfigured() throws Exception {
+    assertThat(generalConfig.getInvalidCodeOwnerConfigInfoUrl(new Config())).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.invalidCodeOwnerConfigInfoUrl",
+      value = "http://foo.example.com")
+  public void invalidCodeOwnerConfigInfoIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+      throws Exception {
+    assertThat(generalConfig.getInvalidCodeOwnerConfigInfoUrl(new Config()))
+        .value()
+        .isEqualTo("http://foo.example.com");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.invalidCodeOwnerConfigInfoUrl",
+      value = "http://foo.example.com")
+  public void invalidCodeOwnerConfigInfoUrlInPluginConfigOverridesOverrideInfoUrlInGerritConfig()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        KEY_INVALID_CODE_OWNER_CONFIG_INFO_URL,
+        "http://bar.example.com");
+    assertThat(generalConfig.getInvalidCodeOwnerConfigInfoUrl(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, /* subsection= */ 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, /* subsection= */ 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, /* subsection= */ 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, /* subsection= */ 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/backend/config/OverrideApprovalConfigTest.java
similarity index 93%
rename from javatests/com/google/gerrit/plugins/codeowners/config/OverrideApprovalConfigTest.java
rename to javatests/com/google/gerrit/plugins/codeowners/backend/config/OverrideApprovalConfigTest.java
index 3ea4e18..e2c3243 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/config/OverrideApprovalConfigTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/OverrideApprovalConfigTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.config;
+package com.google.gerrit.plugins.codeowners.backend.config;
 
 import static com.google.gerrit.plugins.codeowners.testing.RequiredApprovalSubject.assertThat;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
@@ -24,7 +24,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-/** Tests for {@link OverrideApprovalConfig}. */
+/** Tests for {@link com.google.gerrit.plugins.codeowners.backend.config.OverrideApprovalConfig}. */
 public class OverrideApprovalConfigTest extends AbstractRequiredApprovalConfigTest {
   private OverrideApprovalConfig overrideApprovalConfig;
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/config/RequiredApprovalConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/RequiredApprovalConfigTest.java
similarity index 94%
rename from javatests/com/google/gerrit/plugins/codeowners/config/RequiredApprovalConfigTest.java
rename to javatests/com/google/gerrit/plugins/codeowners/backend/config/RequiredApprovalConfigTest.java
index 37da12d..70849ba 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/config/RequiredApprovalConfigTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/RequiredApprovalConfigTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.config;
+package com.google.gerrit.plugins.codeowners.backend.config;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.plugins.codeowners.testing.RequiredApprovalSubject.assertThat;
@@ -26,7 +26,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-/** Tests for {@link RequiredApprovalConfig}. */
+/** Tests for {@link com.google.gerrit.plugins.codeowners.backend.config.RequiredApprovalConfig}. */
 public class RequiredApprovalConfigTest extends AbstractRequiredApprovalConfigTest {
   private RequiredApprovalConfig requiredApprovalConfig;
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/config/RequiredApprovalTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/RequiredApprovalTest.java
similarity index 98%
rename from javatests/com/google/gerrit/plugins/codeowners/config/RequiredApprovalTest.java
rename to javatests/com/google/gerrit/plugins/codeowners/backend/config/RequiredApprovalTest.java
index fba885d..ef2e43d 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/config/RequiredApprovalTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/RequiredApprovalTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.config;
+package com.google.gerrit.plugins.codeowners.backend.config;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
@@ -30,7 +30,7 @@
 import java.util.Arrays;
 import org.junit.Test;
 
-/** Tests for {@link RequiredApproval}. */
+/** Tests for {@link com.google.gerrit.plugins.codeowners.backend.config.RequiredApproval}. */
 public class RequiredApprovalTest extends AbstractCodeOwnersTest {
   @Test
   public void cannotCheckIsCodeOwnerApprovalForNullPatchSetApproval() throws Exception {
diff --git a/javatests/com/google/gerrit/plugins/codeowners/config/StatusConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/StatusConfigTest.java
similarity index 85%
rename from javatests/com/google/gerrit/plugins/codeowners/config/StatusConfigTest.java
rename to javatests/com/google/gerrit/plugins/codeowners/backend/config/StatusConfigTest.java
index 6ad8f3b..1537c55 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/config/StatusConfigTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/StatusConfigTest.java
@@ -12,12 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners.config;
+package com.google.gerrit.plugins.codeowners.backend.config;
 
 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.StatusConfig.KEY_DISABLED;
-import static com.google.gerrit.plugins.codeowners.config.StatusConfig.KEY_DISABLED_BRANCH;
+import static com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
+import static com.google.gerrit.plugins.codeowners.backend.config.StatusConfig.KEY_DISABLED;
+import static com.google.gerrit.plugins.codeowners.backend.config.StatusConfig.KEY_DISABLED_BRANCH;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
@@ -28,12 +28,11 @@
 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 org.eclipse.jgit.lib.Config;
 import org.junit.Before;
 import org.junit.Test;
 
-/** Tests for {@link StatusConfig}. */
+/** Tests for {@link com.google.gerrit.plugins.codeowners.backend.config.StatusConfig}. */
 public class StatusConfigTest extends AbstractCodeOwnersTest {
   private StatusConfig statusConfig;
 
@@ -97,9 +96,7 @@
   @Test
   @GerritConfig(name = "plugin.code-owners.disabled", value = "INVALID")
   public void isDisabledForProject_invalidValueInGerritConfigIsIgnored() throws Exception {
-    Config cfg = new Config();
-    cfg.setString(SECTION_CODE_OWNERS, null, KEY_DISABLED, "invalid");
-    assertThat(statusConfig.isDisabledForProject(cfg, project)).isFalse();
+    assertThat(statusConfig.isDisabledForProject(new Config(), project)).isFalse();
   }
 
   @Test
@@ -127,6 +124,14 @@
   }
 
   @Test
+  public void isDisabledForBranchForConfigWithEmptyValue() throws Exception {
+    Config cfg = new Config();
+    cfg.setStringList(SECTION_CODE_OWNERS, null, KEY_DISABLED_BRANCH, ImmutableList.of(""));
+    assertThat(statusConfig.isDisabledForBranch(cfg, BranchNameKey.create(project, "master")))
+        .isFalse();
+  }
+
+  @Test
   public void isDisabledForBranch_exactRef() throws Exception {
     Config cfg = new Config();
     cfg.setStringList(
@@ -230,12 +235,25 @@
   @Test
   @GerritConfig(name = "plugin.code-owners.disabledBranch", value = "refs/heads/master")
   public void
-      disabledBranchConfigurationInPluginConfigOverridesDisabledBranchConfigurationInGerritConfig()
+      disabledBranchConfigurationInPluginConfigExtendsDisabledBranchConfigurationInGerritConfig()
+          throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, null, KEY_DISABLED_BRANCH, "refs/heads/test");
+    assertThat(statusConfig.isDisabledForBranch(cfg, BranchNameKey.create(project, "master")))
+        .isTrue();
+    assertThat(statusConfig.isDisabledForBranch(cfg, BranchNameKey.create(project, "test")))
+        .isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.disabledBranch", value = "refs/heads/master")
+  public void
+      disabledBranchConfigurationInPluginConfigCannotRemoveDisabledBranchConfigurationInGerritConfig()
           throws Exception {
     Config cfg = new Config();
     cfg.setString(SECTION_CODE_OWNERS, null, KEY_DISABLED_BRANCH, "");
     assertThat(statusConfig.isDisabledForBranch(cfg, BranchNameKey.create(project, "master")))
-        .isFalse();
+        .isTrue();
   }
 
   @Test
@@ -260,9 +278,7 @@
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () ->
-                statusConfig.validateProjectLevelConfig(
-                    null, new ProjectLevelConfig.Bare("code-owners.config")));
+            () -> statusConfig.validateProjectLevelConfig(null, new Config()));
     assertThat(npe).hasMessageThat().isEqualTo("fileName");
   }
 
@@ -278,16 +294,15 @@
   @Test
   public void validateEmptyProjectLevelConfig() throws Exception {
     ImmutableList<CommitValidationMessage> commitValidationMessage =
-        statusConfig.validateProjectLevelConfig(
-            "code-owners.config", new ProjectLevelConfig.Bare("code-owners.config"));
+        statusConfig.validateProjectLevelConfig("code-owners.config", new Config());
     assertThat(commitValidationMessage).isEmpty();
   }
 
   @Test
   public void validateValidProjectLevelConfig() throws Exception {
-    ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config");
-    cfg.getConfig().setBoolean(SECTION_CODE_OWNERS, null, KEY_DISABLED, true);
-    cfg.getConfig().setString(SECTION_CODE_OWNERS, null, KEY_DISABLED_BRANCH, "refs/heads/master");
+    Config cfg = new Config();
+    cfg.setBoolean(SECTION_CODE_OWNERS, null, KEY_DISABLED, true);
+    cfg.setString(SECTION_CODE_OWNERS, null, KEY_DISABLED_BRANCH, "refs/heads/master");
     ImmutableList<CommitValidationMessage> commitValidationMessage =
         statusConfig.validateProjectLevelConfig("code-owners.config", cfg);
     assertThat(commitValidationMessage).isEmpty();
@@ -295,8 +310,8 @@
 
   @Test
   public void validateInvalidProjectLevelConfig_invalidDisabledValue() throws Exception {
-    ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config");
-    cfg.getConfig().setString(SECTION_CODE_OWNERS, null, KEY_DISABLED, "INVALID");
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, null, KEY_DISABLED, "INVALID");
     ImmutableList<CommitValidationMessage> commitValidationMessages =
         statusConfig.validateProjectLevelConfig("code-owners.config", cfg);
     assertThat(commitValidationMessages).hasSize(1);
@@ -311,8 +326,8 @@
 
   @Test
   public void validateInvalidProjectLevelConfig_invalidDisabledBranch() throws Exception {
-    ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config");
-    cfg.getConfig().setString(SECTION_CODE_OWNERS, null, KEY_DISABLED_BRANCH, "^refs/heads/[");
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, null, KEY_DISABLED_BRANCH, "^refs/heads/[");
     ImmutableList<CommitValidationMessage> commitValidationMessages =
         statusConfig.validateProjectLevelConfig("code-owners.config", cfg);
     assertThat(commitValidationMessages).hasSize(1);
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/BUILD b/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/BUILD
index 6c0751b..30bd6b2 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/BUILD
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/BUILD
@@ -4,8 +4,8 @@
     srcs = glob(["*Test.java"]),
     group = "findowners_backend",
     deps = [
-        "//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/backend",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing/backend:testutil",
         "//plugins/code-owners/javatests/com/google/gerrit/plugins/codeowners/backend:testbases",
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 f0ec10b..6499fa7 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParserTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParserTest.java
@@ -140,10 +140,12 @@
                         EMAIL_1, "@example.com", "admin@", "admin@example@com", EMAIL_2)));
     assertThat(exception.getFullMessage(FindOwnersBackend.CODE_OWNER_CONFIG_FILE_NAME))
         .isEqualTo(
-            "invalid code owner config file '/OWNERS':\n"
-                + "  invalid line: @example.com\n"
-                + "  invalid line: admin@\n"
-                + "  invalid line: admin@example@com");
+            String.format(
+                "invalid code owner config file '/OWNERS' (project = %s, branch = master):\n"
+                    + "  invalid line: @example.com\n"
+                    + "  invalid line: admin@\n"
+                    + "  invalid line: admin@example@com",
+                project));
   }
 
   @Test
@@ -158,9 +160,11 @@
                     getCodeOwnerConfig(EMAIL_1, "INVALID", "NOT_AN_EMAIL", EMAIL_2)));
     assertThat(exception.getFullMessage(FindOwnersBackend.CODE_OWNER_CONFIG_FILE_NAME))
         .isEqualTo(
-            "invalid code owner config file '/OWNERS':\n"
-                + "  invalid line: INVALID\n"
-                + "  invalid line: NOT_AN_EMAIL");
+            String.format(
+                "invalid code owner config file '/OWNERS' (project = %s, branch = master):\n"
+                    + "  invalid line: INVALID\n"
+                    + "  invalid line: NOT_AN_EMAIL",
+                project));
   }
 
   @Test
@@ -580,6 +584,28 @@
   }
 
   @Test
+  public void cannotParseCodeOwnerConfigWithPerFileLineThatHasAnInvalidImportKeyword()
+      throws Exception {
+    // The 'import' keyword doesn't exist
+    String invalidLine = "per-file foo=import /foo/bar/OWNERS";
+
+    CodeOwnerConfigParseException exception =
+        assertThrows(
+            CodeOwnerConfigParseException.class,
+            () ->
+                codeOwnerConfigParser.parse(
+                    TEST_REVISION,
+                    CodeOwnerConfig.Key.create(project, "master", "/"),
+                    invalidLine));
+    assertThat(exception.getFullMessage(FindOwnersBackend.CODE_OWNER_CONFIG_FILE_NAME))
+        .isEqualTo(
+            String.format(
+                "invalid code owner config file '/OWNERS' (project = %s, branch = master):\n"
+                    + "  invalid line: %s",
+                project, invalidLine));
+  }
+
+  @Test
   public void perFileCodeOwnerConfigImportWithImportModeGlobalCodeOwnerSetsOnly() throws Exception {
     CodeOwnerConfigReference codeOwnerConfigReference =
         CodeOwnerConfigReference.builder(
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/proto/BUILD b/javatests/com/google/gerrit/plugins/codeowners/backend/proto/BUILD
index f121487..fbd1ac9 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/proto/BUILD
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/proto/BUILD
@@ -5,8 +5,8 @@
     group = "proto_backend",
     deps = [
         "//lib:protobuf",
-        "//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/backend",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing",
         "//plugins/code-owners/javatests/com/google/gerrit/plugins/codeowners/backend:testbases",
     ],
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/proto/ProtoCodeOwnerConfigParserTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/proto/ProtoCodeOwnerConfigParserTest.java
index 3518a10..2e768db 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/proto/ProtoCodeOwnerConfigParserTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/proto/ProtoCodeOwnerConfigParserTest.java
@@ -90,7 +90,8 @@
                     "owners_config {\n  owner_sets {\nINVALID_LINE\n  }\n}\n"));
     assertThat(exception.getFullMessage(ProtoBackend.CODE_OWNER_CONFIG_FILE_NAME))
         .isEqualTo(
-            "invalid code owner config file '/OWNERS_METADATA':\n" + "  4:3: Expected \"{\".");
+            "invalid code owner config file '/OWNERS_METADATA' (project = project, branch = master):\n"
+                + "  4:3: Expected \"{\".");
   }
 
   @Test
@@ -177,7 +178,7 @@
             () -> codeOwnerConfigParser.formatAsString(codeOwnerConfig));
     assertThat(exception)
         .hasMessageThat()
-        .isEqualTo("ignoreGlobaleAndParentCodeOwners is not supported");
+        .isEqualTo("ignoreGlobalAndParentCodeOwners is not supported");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigurationTest.java b/javatests/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigurationTest.java
deleted file mode 100644
index 641bf10..0000000
--- a/javatests/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigurationTest.java
+++ /dev/null
@@ -1,1026 +0,0 @@
-// 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.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;
-import com.google.gerrit.entities.BranchNameKey;
-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;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
-import com.google.gerrit.plugins.codeowners.api.MergeCommitStrategy;
-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;
-import com.google.inject.Key;
-import com.google.inject.util.Providers;
-import java.nio.file.Path;
-import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.junit.Before;
-import org.junit.Test;
-
-/** Tests for {@link CodeOwnersPluginConfiguration}. */
-public class CodeOwnersPluginConfigurationTest extends AbstractCodeOwnersTest {
-  @Inject private ProjectOperations projectOperations;
-
-  private CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
-  private DynamicMap<CodeOwnerBackend> codeOwnerBackends;
-
-  @Before
-  public void setUpCodeOwnersPlugin() throws Exception {
-    codeOwnersPluginConfiguration =
-        plugin.getSysInjector().getInstance(CodeOwnersPluginConfiguration.class);
-    codeOwnerBackends =
-        plugin.getSysInjector().getInstance(new Key<DynamicMap<CodeOwnerBackend>>() {});
-  }
-
-  @Test
-  public void cannotCheckForNullProjectIfCodeOwnersFunctionalityIsDisabled() throws Exception {
-    NullPointerException npe =
-        assertThrows(
-            NullPointerException.class,
-            () -> codeOwnersPluginConfiguration.isDisabled(/* project= */ (Project.NameKey) null));
-    assertThat(npe).hasMessageThat().isEqualTo("project");
-  }
-
-  @Test
-  public void cannotCheckForNullBranchIfCodeOwnersFunctionalityIsDisabled() throws Exception {
-    NullPointerException npe =
-        assertThrows(
-            NullPointerException.class,
-            () ->
-                codeOwnersPluginConfiguration.isDisabled(
-                    /* branchNameKey= */ (BranchNameKey) null));
-    assertThat(npe).hasMessageThat().isEqualTo("branchNameKey");
-  }
-
-  @Test
-  public void cannotCheckIfCodeOwnersFunctionalityIsDisabledForNonExistingProject()
-      throws Exception {
-    IllegalStateException exception =
-        assertThrows(
-            IllegalStateException.class,
-            () ->
-                codeOwnersPluginConfiguration.isDisabled(Project.nameKey("non-existing-project")));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            "cannot get code-owners plugin config for non-existing project non-existing-project");
-  }
-
-  @Test
-  public void cannotCheckIfCodeOwnersFunctionalityIsDisabledForBranchOfNonExistingProject()
-      throws Exception {
-    IllegalStateException exception =
-        assertThrows(
-            IllegalStateException.class,
-            () ->
-                codeOwnersPluginConfiguration.isDisabled(
-                    BranchNameKey.create(Project.nameKey("non-existing-project"), "master")));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            "cannot get code-owners plugin config for non-existing project non-existing-project");
-  }
-
-  @Test
-  public void checkIfCodeOwnersFunctionalityIsDisabledForNonExistingBranch() throws Exception {
-    assertThat(
-            codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "non-existing")))
-        .isFalse();
-  }
-
-  @Test
-  public void checkIfCodeOwnersFunctionalityIsDisabledForProjectWithEmptyConfig() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isFalse();
-  }
-
-  @Test
-  public void checkIfCodeOwnersFunctionalityIsDisabledForBranchWithEmptyConfig() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isFalse();
-  }
-
-  @Test
-  public void codeOwnersFunctionalityIsDisabledForProject() throws Exception {
-    disableCodeOwnersForProject(project);
-    assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isTrue();
-  }
-
-  @Test
-  public void codeOwnersFunctionalityIsDisabledForBranchIfItIsDisabledForProject()
-      throws Exception {
-    disableCodeOwnersForProject(project);
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isTrue();
-  }
-
-  @Test
-  public void codeOwnersFunctionalityIsDisabledForBranch_exactRef() throws Exception {
-    configureDisabledBranch(project, "refs/heads/master");
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isTrue();
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "other")))
-        .isFalse();
-  }
-
-  @Test
-  public void codeOwnersFunctionalityIsDisabledForBranch_refPattern() throws Exception {
-    configureDisabledBranch(project, "refs/heads/*");
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isTrue();
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "other")))
-        .isTrue();
-    assertThat(
-            codeOwnersPluginConfiguration.isDisabled(
-                BranchNameKey.create(project, RefNames.REFS_META)))
-        .isFalse();
-  }
-
-  @Test
-  public void codeOwnersFunctionalityIsDisabledForBranch_regularExpression() throws Exception {
-    configureDisabledBranch(project, "^refs/heads/.*");
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isTrue();
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "other")))
-        .isTrue();
-    assertThat(
-            codeOwnersPluginConfiguration.isDisabled(
-                BranchNameKey.create(project, RefNames.REFS_META)))
-        .isFalse();
-  }
-
-  @Test
-  public void codeOwnersFunctionalityIsDisabledForBranch_invalidRegularExpression()
-      throws Exception {
-    configureDisabledBranch(project, "^refs/heads/[");
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isFalse();
-  }
-
-  @Test
-  public void disabledIsInheritedFromParentProject() throws Exception {
-    disableCodeOwnersForProject(allProjects);
-    assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isTrue();
-  }
-
-  @Test
-  public void inheritedDisabledAlsoCountsForBranch() throws Exception {
-    disableCodeOwnersForProject(allProjects);
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isTrue();
-  }
-
-  @Test
-  public void inheritedDisabledValueIsIgnoredIfInvalid() throws Exception {
-    configureDisabled(project, "invalid");
-    assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isFalse();
-  }
-
-  @Test
-  public void inheritedDisabledValueIsIgnoredForBranchIfInvalid() throws Exception {
-    configureDisabled(project, "invalid");
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isFalse();
-  }
-
-  @Test
-  public void disabledForOtherProjectHasNoEffect() throws Exception {
-    Project.NameKey otherProject = projectOperations.newProject().create();
-    disableCodeOwnersForProject(otherProject);
-    assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isFalse();
-  }
-
-  @Test
-  public void disabledBranchForOtherProjectHasNoEffect() throws Exception {
-    Project.NameKey otherProject = projectOperations.newProject().create();
-    configureDisabledBranch(otherProject, "refs/heads/master");
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isFalse();
-  }
-
-  @Test
-  public void disabledBranchIsInheritedFromParentProject() throws Exception {
-    configureDisabledBranch(allProjects, "refs/heads/master");
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isTrue();
-  }
-
-  @Test
-  public void inheritedDisabledCanBeOverridden() throws Exception {
-    disableCodeOwnersForProject(allProjects);
-    enableCodeOwnersForProject(project);
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isFalse();
-  }
-
-  @Test
-  public void inheritedDisabledBranchCanBeOverridden() throws Exception {
-    configureDisabledBranch(allProjects, "refs/heads/master");
-    enableCodeOwnersForAllBranches(project);
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isFalse();
-  }
-
-  @Test
-  public void cannotGetBackendForNonExistingProject() throws Exception {
-    IllegalStateException exception =
-        assertThrows(
-            IllegalStateException.class,
-            () ->
-                codeOwnersPluginConfiguration.getBackend(
-                    BranchNameKey.create(Project.nameKey("non-existing-project"), "master")));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            "cannot get code-owners plugin config for non-existing project non-existing-project");
-  }
-
-  @Test
-  public void getBackendForNonExistingBranch() throws Exception {
-    assertThat(
-            codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "non-existing")))
-        .isInstanceOf(FindOwnersBackend.class);
-  }
-
-  @Test
-  public void getDefaultBackendWhenNoBackendIsConfigured() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-        .isInstanceOf(FindOwnersBackend.class);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.backend", value = TestCodeOwnerBackend.ID)
-  public void getConfiguredDefaultBackend() throws Exception {
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(TestCodeOwnerBackend.class);
-    }
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.backend", value = "non-existing-backend")
-  public void cannotGetBackendIfNonExistingBackendIsConfigured() throws Exception {
-    InvalidPluginConfigurationException exception =
-        assertThrows(
-            InvalidPluginConfigurationException.class,
-            () ->
-                codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            "Invalid configuration of the code-owners plugin. Code owner backend"
-                + " 'non-existing-backend' that is configured in gerrit.config (parameter"
-                + " plugin.code-owners.backend) not found.");
-  }
-
-  @Test
-  public void getBackendConfiguredOnProjectLevel() throws Exception {
-    configureBackend(project, TestCodeOwnerBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(TestCodeOwnerBackend.class);
-    }
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.backend", value = FindOwnersBackend.ID)
-  public void backendConfiguredOnProjectLevelOverridesDefaultBackend() throws Exception {
-    configureBackend(project, TestCodeOwnerBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(TestCodeOwnerBackend.class);
-    }
-  }
-
-  @Test
-  public void backendIsInheritedFromParentProject() throws Exception {
-    configureBackend(allProjects, TestCodeOwnerBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(TestCodeOwnerBackend.class);
-    }
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.backend", value = FindOwnersBackend.ID)
-  public void inheritedBackendOverridesDefaultBackend() throws Exception {
-    configureBackend(allProjects, TestCodeOwnerBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(TestCodeOwnerBackend.class);
-    }
-  }
-
-  @Test
-  public void projectLevelBackendOverridesInheritedBackend() throws Exception {
-    configureBackend(allProjects, TestCodeOwnerBackend.ID);
-    configureBackend(project, FindOwnersBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(FindOwnersBackend.class);
-    }
-  }
-
-  @Test
-  public void cannotGetBackendIfNonExistingBackendIsConfiguredOnProjectLevel() throws Exception {
-    configureBackend(project, "non-existing-backend");
-    InvalidPluginConfigurationException exception =
-        assertThrows(
-            InvalidPluginConfigurationException.class,
-            () ->
-                codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            String.format(
-                "Invalid configuration of the code-owners plugin. Code owner backend"
-                    + " 'non-existing-backend' that is configured for project %s in"
-                    + " code-owners.config (parameter codeOwners.backend) not found.",
-                project));
-  }
-
-  @Test
-  public void projectLevelBackendForOtherProjectHasNoEffect() throws Exception {
-    Project.NameKey otherProject = projectOperations.newProject().create();
-    configureBackend(otherProject, TestCodeOwnerBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(FindOwnersBackend.class);
-    }
-  }
-
-  @Test
-  public void getBackendConfiguredOnBranchLevel() throws Exception {
-    configureBackend(project, "refs/heads/master", TestCodeOwnerBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(TestCodeOwnerBackend.class);
-    }
-  }
-
-  @Test
-  public void getBackendConfiguredOnBranchLevelShortName() throws Exception {
-    configureBackend(project, "master", TestCodeOwnerBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(TestCodeOwnerBackend.class);
-    }
-  }
-
-  @Test
-  public void branchLevelBackendOnFullNameTakesPrecedenceOverBranchLevelBackendOnShortName()
-      throws Exception {
-    configureBackend(project, "master", TestCodeOwnerBackend.ID);
-    configureBackend(project, "refs/heads/master", FindOwnersBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(FindOwnersBackend.class);
-    }
-  }
-
-  @Test
-  public void branchLevelBackendOverridesProjectLevelBackend() throws Exception {
-    configureBackend(project, TestCodeOwnerBackend.ID);
-    configureBackend(project, "master", FindOwnersBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(FindOwnersBackend.class);
-    }
-  }
-
-  @Test
-  public void cannotGetBackendIfNonExistingBackendIsConfiguredOnBranchLevel() throws Exception {
-    configureBackend(project, "master", "non-existing-backend");
-    InvalidPluginConfigurationException exception =
-        assertThrows(
-            InvalidPluginConfigurationException.class,
-            () ->
-                codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            String.format(
-                "Invalid configuration of the code-owners plugin. Code owner backend"
-                    + " 'non-existing-backend' that is configured for project %s in"
-                    + " code-owners.config (parameter codeOwners.master.backend) not found.",
-                project));
-  }
-
-  @Test
-  public void branchLevelBackendForOtherBranchHasNoEffect() throws Exception {
-    configureBackend(project, "foo", TestCodeOwnerBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(FindOwnersBackend.class);
-    }
-  }
-
-  @Test
-  public void cannotGetRequiredApprovalForNonExistingProject() throws Exception {
-    IllegalStateException exception =
-        assertThrows(
-            IllegalStateException.class,
-            () ->
-                codeOwnersPluginConfiguration.getRequiredApproval(
-                    Project.nameKey("non-existing-project")));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            "cannot get code-owners plugin config for non-existing project non-existing-project");
-  }
-
-  @Test
-  public void getDefaultRequiredApprovalWhenNoRequiredApprovalIsConfigured() throws Exception {
-    RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    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).hasLabelNameThat().isEqualTo("Code-Review");
-    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Foo-Bar+1")
-  public void cannotGetRequiredApprovalIfNonExistingLabelIsConfiguredAsRequiredApproval()
-      throws Exception {
-    InvalidPluginConfigurationException exception =
-        assertThrows(
-            InvalidPluginConfigurationException.class,
-            () -> codeOwnersPluginConfiguration.getRequiredApproval(project));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            String.format(
-                "Invalid configuration of the code-owners plugin. Required approval 'Foo-Bar+1'"
-                    + " that is configured in gerrit.config (parameter"
-                    + " plugin.code-owners.requiredApproval) is invalid: Label Foo-Bar doesn't exist"
-                    + " for project %s.",
-                project.get()));
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Code-Review+3")
-  public void cannotGetRequiredApprovalIfNonExistingLabelValueIsConfiguredAsRequiredApproval()
-      throws Exception {
-    InvalidPluginConfigurationException exception =
-        assertThrows(
-            InvalidPluginConfigurationException.class,
-            () -> codeOwnersPluginConfiguration.getRequiredApproval(project));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            String.format(
-                "Invalid configuration of the code-owners plugin. Required approval"
-                    + " 'Code-Review+3' that is configured in gerrit.config (parameter"
-                    + " plugin.code-owners.requiredApproval) is invalid: Label Code-Review on"
-                    + " project %s doesn't allow value 3.",
-                project.get()));
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "INVALID")
-  public void cannotGetRequiredApprovalIfInvalidRequiredApprovalIsConfigured() throws Exception {
-    InvalidPluginConfigurationException exception =
-        assertThrows(
-            InvalidPluginConfigurationException.class,
-            () -> codeOwnersPluginConfiguration.getRequiredApproval(project));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            "Invalid configuration of the code-owners plugin. Required approval 'INVALID' that is"
-                + " configured in gerrit.config (parameter plugin.code-owners.requiredApproval) is"
-                + " invalid: Invalid format, expected '<label-name>+<label-value>'.");
-  }
-
-  @Test
-  public void getRequiredApprovalConfiguredOnProjectLevel() throws Exception {
-    configureRequiredApproval(project, "Code-Review+2");
-    RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    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
-  @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Code-Review+1")
-  public void requiredApprovalConfiguredOnProjectLevelOverridesDefaultRequiredApproval()
-      throws Exception {
-    configureRequiredApproval(project, "Code-Review+2");
-    RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    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).hasLabelNameThat().isEqualTo("Code-Review");
-    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.backend", value = FindOwnersBackend.ID)
-  public void inheritedRequiredApprovalOverridesDefaultRequiredApproval() throws Exception {
-    configureRequiredApproval(allProjects, "Code-Review+2");
-    RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
-    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
-  }
-
-  @Test
-  public void projectLevelRequiredApprovalOverridesInheritedRequiredApproval() throws Exception {
-    configureRequiredApproval(allProjects, "Code-Review+1");
-    configureRequiredApproval(project, "Code-Review+2");
-    RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
-    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
-  }
-
-  @Test
-  public void
-      cannotGetRequiredApprovalIfNonExistingLabelIsConfiguredAsRequiredApprovalOnProjectLevel()
-          throws Exception {
-    configureRequiredApproval(project, "Foo-Bar+1");
-    InvalidPluginConfigurationException exception =
-        assertThrows(
-            InvalidPluginConfigurationException.class,
-            () -> codeOwnersPluginConfiguration.getRequiredApproval(project));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            String.format(
-                "Invalid configuration of the code-owners plugin. Required approval 'Foo-Bar+1'"
-                    + " that is configured in code-owners.config (parameter"
-                    + " codeOwners.requiredApproval) is invalid: Label Foo-Bar doesn't exist for"
-                    + " project %s.",
-                project.get()));
-  }
-
-  @Test
-  public void
-      cannotGetRequiredApprovalIfNonExistingLabelValueIsConfiguredAsRequiredApprovalOnProjectLevel()
-          throws Exception {
-    configureRequiredApproval(project, "Code-Review+3");
-    InvalidPluginConfigurationException exception =
-        assertThrows(
-            InvalidPluginConfigurationException.class,
-            () -> codeOwnersPluginConfiguration.getRequiredApproval(project));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            String.format(
-                "Invalid configuration of the code-owners plugin. Required approval"
-                    + " 'Code-Review+3' that is configured in code-owners.config (parameter"
-                    + " codeOwners.requiredApproval) is invalid: Label Code-Review on project %s"
-                    + " doesn't allow value 3.",
-                project.get()));
-  }
-
-  @Test
-  public void cannotGetRequiredApprovalIfInvalidRequiredApprovalIsConfiguredOnProjectLevel()
-      throws Exception {
-    configureRequiredApproval(project, "INVALID");
-    InvalidPluginConfigurationException exception =
-        assertThrows(
-            InvalidPluginConfigurationException.class,
-            () -> codeOwnersPluginConfiguration.getRequiredApproval(project));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            "Invalid configuration of the code-owners plugin. Required approval 'INVALID' that is"
-                + " configured in code-owners.config (parameter codeOwners.requiredApproval) is"
-                + " invalid: Invalid format, expected '<label-name>+<label-value>'.");
-  }
-
-  @Test
-  public void projectLevelRequiredApprovalForOtherProjectHasNoEffect() throws Exception {
-    Project.NameKey otherProject = projectOperations.newProject().create();
-    configureRequiredApproval(otherProject, "Code-Review+2");
-    RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
-    assertThat(requiredApproval).hasValueThat().isEqualTo(1);
-  }
-
-  @Test
-  public void cannotGetOverrideApprovalForNonExistingProject() throws Exception {
-    IllegalStateException exception =
-        assertThrows(
-            IllegalStateException.class,
-            () ->
-                codeOwnersPluginConfiguration.getOverrideApproval(
-                    Project.nameKey("non-existing-project")));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            "cannot get code-owners plugin config for non-existing project non-existing-project");
-  }
-
-  @Test
-  public void getOverrideApprovalWhenNoRequiredApprovalIsConfigured() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getOverrideApproval(project)).isEmpty();
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Code-Review+2")
-  public void getConfiguredDefaultOverrideApproval() throws Exception {
-    ImmutableSet<RequiredApproval> requiredApproval =
-        codeOwnersPluginConfiguration.getOverrideApproval(project);
-    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");
-    ImmutableSet<RequiredApproval> requiredApproval =
-        codeOwnersPluginConfiguration.getOverrideApproval(project);
-    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
-  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "INVALID")
-  public void getOverrideApprovalIfInvalidOverrideApprovalIsConfigured() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getOverrideApproval(project)).isEmpty();
-  }
-
-  @Test
-  public void getOverrideApprovalIfInvalidOverrideApprovalIsConfiguredOnProjectLevel()
-      throws Exception {
-    configureOverrideApproval(project, "INVALID");
-    assertThat(codeOwnersPluginConfiguration.getOverrideApproval(project)).isEmpty();
-  }
-
-  @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 =
-        assertThrows(
-            MethodNotAllowedException.class,
-            () -> codeOwnersPluginConfiguration.checkExperimentalRestEndpointsEnabled());
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo("experimental code owners REST endpoints are disabled");
-  }
-
-  @Test
-  public void experimentalRestEndpointsNotEnabled() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.areExperimentalRestEndpointsEnabled()).isFalse();
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.enableExperimentalRestEndpoints", value = "true")
-  public void experimentalRestEndpointsEnabled() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.areExperimentalRestEndpointsEnabled()).isTrue();
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.enableExperimentalRestEndpoints", value = "invalid")
-  public void experimentalRestEndpointsNotEnabled_invalidConfig() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.areExperimentalRestEndpointsEnabled()).isFalse();
-  }
-
-  @Test
-  public void cannotGetFileExtensionForNullProject() throws Exception {
-    NullPointerException npe =
-        assertThrows(
-            NullPointerException.class,
-            () -> codeOwnersPluginConfiguration.getFileExtension(/* project= */ null));
-    assertThat(npe).hasMessageThat().isEqualTo("project");
-  }
-
-  @Test
-  public void getFileExtensionIfNoneIsConfigured() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getFileExtension(project)).isEmpty();
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
-  public void getFileExtensionIfNoneIsConfiguredOnProjectLevel() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getFileExtension(project)).value().isEqualTo("foo");
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
-  public void fileExtensionOnProjectLevelOverridesDefaultFileExtension() throws Exception {
-    configureFileExtension(project, "bar");
-    assertThat(codeOwnersPluginConfiguration.getFileExtension(project)).value().isEqualTo("bar");
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
-  public void fileExtensionIsInheritedFromParentProject() throws Exception {
-    configureFileExtension(allProjects, "bar");
-    assertThat(codeOwnersPluginConfiguration.getFileExtension(project)).value().isEqualTo("bar");
-  }
-
-  @Test
-  public void inheritedFileExtensionCanBeOverridden() throws Exception {
-    configureFileExtension(allProjects, "foo");
-    configureFileExtension(project, "bar");
-    assertThat(codeOwnersPluginConfiguration.getFileExtension(project)).value().isEqualTo("bar");
-  }
-
-  @Test
-  public void cannotGetMergeCommitStrategyForNullProject() throws Exception {
-    NullPointerException npe =
-        assertThrows(
-            NullPointerException.class,
-            () -> codeOwnersPluginConfiguration.getMergeCommitStrategy(/* project= */ null));
-    assertThat(npe).hasMessageThat().isEqualTo("project");
-  }
-
-  @Test
-  public void getMergeCommitStrategyIfNoneIsConfigured() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
-        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "plugin.code-owners.mergeCommitStrategy",
-      value = "FILES_WITH_CONFLICT_RESOLUTION")
-  public void getMergeCommitStrategyIfNoneIsConfiguredOnProjectLevel() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
-        .isEqualTo(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "plugin.code-owners.mergeCommitStrategy",
-      value = "FILES_WITH_CONFLICT_RESOLUTION")
-  public void mergeCommitStrategyOnProjectLevelOverridesGlobalMergeCommitStrategy()
-      throws Exception {
-    configureMergeCommitStrategy(project, MergeCommitStrategy.ALL_CHANGED_FILES);
-    assertThat(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
-        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "plugin.code-owners.mergeCommitStrategy",
-      value = "FILES_WITH_CONFLICT_RESOLUTION")
-  public void mergeCommitStrategyIsInheritedFromParentProject() throws Exception {
-    configureMergeCommitStrategy(allProjects, MergeCommitStrategy.ALL_CHANGED_FILES);
-    assertThat(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
-        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
-  }
-
-  @Test
-  public void inheritedMergeCommitStrategyCanBeOverridden() throws Exception {
-    configureMergeCommitStrategy(allProjects, MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
-    configureMergeCommitStrategy(project, MergeCommitStrategy.ALL_CHANGED_FILES);
-    assertThat(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
-        .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();
-  }
-
-  private void configureDisabled(Project.NameKey project, String disabled) throws Exception {
-    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);
-  }
-
-  private void enableCodeOwnersForAllBranches(Project.NameKey project) throws Exception {
-    setCodeOwnersConfig(project, /* subsection= */ null, StatusConfig.KEY_DISABLED_BRANCH, "");
-  }
-
-  private void configureBackend(Project.NameKey project, String backendName) throws Exception {
-    configureBackend(project, /* branch= */ null, backendName);
-  }
-
-  private void configureBackend(
-      Project.NameKey project, @Nullable String branch, String backendName) throws Exception {
-    setCodeOwnersConfig(project, branch, BackendConfig.KEY_BACKEND, backendName);
-  }
-
-  private void configureRequiredApproval(Project.NameKey project, String requiredApproval)
-      throws Exception {
-    setCodeOwnersConfig(
-        project,
-        /* subsection= */ null,
-        RequiredApprovalConfig.KEY_REQUIRED_APPROVAL,
-        requiredApproval);
-  }
-
-  private void configureOverrideApproval(Project.NameKey project, String requiredApproval)
-      throws Exception {
-    setCodeOwnersConfig(
-        project,
-        /* subsection= */ null,
-        OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL,
-        requiredApproval);
-  }
-
-  private void configureFileExtension(Project.NameKey project, String fileExtension)
-      throws Exception {
-    setCodeOwnersConfig(
-        project, /* subsection= */ null, GeneralConfig.KEY_FILE_EXTENSION, fileExtension);
-  }
-
-  private void configureMergeCommitStrategy(
-      Project.NameKey project, MergeCommitStrategy mergeCommitStrategy) throws Exception {
-    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 AutoCloseable registerTestBackend() {
-    RegistrationHandle registrationHandle =
-        ((PrivateInternals_DynamicMapImpl<CodeOwnerBackend>) codeOwnerBackends)
-            .put("gerrit", TestCodeOwnerBackend.ID, Providers.of(new TestCodeOwnerBackend()));
-    return registrationHandle::remove;
-  }
-
-  private static class TestCodeOwnerBackend implements CodeOwnerBackend {
-    static final String ID = "test-backend";
-
-    @Override
-    public Optional<CodeOwnerConfig> getCodeOwnerConfig(
-        CodeOwnerConfig.Key codeOwnerConfigKey,
-        @Nullable RevWalk revWalk,
-        @Nullable ObjectId revision) {
-      throw new UnsupportedOperationException("not implemented");
-    }
-
-    @Override
-    public Optional<CodeOwnerConfig> upsertCodeOwnerConfig(
-        CodeOwnerConfig.Key codeOwnerConfigKey,
-        CodeOwnerConfigUpdate codeOwnerConfigUpdate,
-        @Nullable IdentifiedUser currentUser) {
-      throw new UnsupportedOperationException("not implemented");
-    }
-
-    @Override
-    public boolean isCodeOwnerConfigFile(NameKey project, String fileName) {
-      throw new UnsupportedOperationException("not implemented");
-    }
-
-    @Override
-    public Path getFilePath(CodeOwnerConfig.Key codeOwnerConfigKey) {
-      throw new UnsupportedOperationException("not implemented");
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/config/GeneralConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/config/GeneralConfigTest.java
deleted file mode 100644
index 6bc8ac5..0000000
--- a/javatests/com/google/gerrit/plugins/codeowners/config/GeneralConfigTest.java
+++ /dev/null
@@ -1,503 +0,0 @@
-// 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.config;
-
-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.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_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;
-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.Iterables;
-import com.google.gerrit.acceptance.config.GerritConfig;
-import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
-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;
-import org.eclipse.jgit.lib.Config;
-import org.junit.Before;
-import org.junit.Test;
-
-/** Tests for {@link GeneralConfig}. */
-public class GeneralConfigTest extends AbstractCodeOwnersTest {
-  private GeneralConfig generalConfig;
-
-  @Before
-  public void setUpCodeOwnersPlugin() throws Exception {
-    generalConfig = plugin.getSysInjector().getInstance(GeneralConfig.class);
-  }
-
-  @Test
-  public void cannotGetFileExtensionForNullPluginConfig() throws Exception {
-    NullPointerException npe =
-        assertThrows(NullPointerException.class, () -> generalConfig.getFileExtension(null));
-    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
-  }
-
-  @Test
-  public void noFileExtensionConfigured() throws Exception {
-    assertThat(generalConfig.getFileExtension(new Config())).isEmpty();
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
-  public void fileExtensionIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
-      throws Exception {
-    assertThat(generalConfig.getFileExtension(new Config())).value().isEqualTo("foo");
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
-  public void fileExtensionInPluginConfigOverridesFileExtensionInGerritConfig() throws Exception {
-    Config cfg = new Config();
-    cfg.setString(SECTION_CODE_OWNERS, null, KEY_FILE_EXTENSION, "bar");
-    assertThat(generalConfig.getFileExtension(cfg)).value().isEqualTo("bar");
-  }
-
-  @Test
-  @GerritConfig(
-      name = "plugin.code-owners.allowedEmailDomain",
-      values = {"example.com", "example.net"})
-  public void getConfiguredEmailDomains() throws Exception {
-    assertThat(generalConfig.getAllowedEmailDomains())
-        .containsExactly("example.com", "example.net");
-  }
-
-  @Test
-  public void noEmailDomainsConfigured() throws Exception {
-    assertThat(generalConfig.getAllowedEmailDomains()).isEmpty();
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.allowedEmailDomain", value = "")
-  public void emptyEmailDomainsConfigured() throws Exception {
-    assertThat(generalConfig.getAllowedEmailDomains()).isEmpty();
-  }
-
-  @Test
-  public void cannotGetReadOnlyForNullPluginConfig() throws Exception {
-    NullPointerException npe =
-        assertThrows(NullPointerException.class, () -> generalConfig.getReadOnly(null));
-    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
-  }
-
-  @Test
-  public void noReadOnlyConfiguration() throws Exception {
-    assertThat(generalConfig.getReadOnly(new Config())).isFalse();
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.readOnly", value = "true")
-  public void readOnlyConfigurationIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
-      throws Exception {
-    assertThat(generalConfig.getReadOnly(new Config())).isTrue();
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.readOnly", value = "true")
-  public void readOnlyConfigurationInPluginConfigOverridesReadOnlyConfigurationInGerritConfig()
-      throws Exception {
-    Config cfg = new Config();
-    cfg.setString(SECTION_CODE_OWNERS, null, KEY_READ_ONLY, "false");
-    assertThat(generalConfig.getReadOnly(cfg)).isFalse();
-  }
-
-  @Test
-  public void cannotGetEnableValidationOnCommitReceivedForNullProject() throws Exception {
-    NullPointerException npe =
-        assertThrows(
-            NullPointerException.class,
-            () ->
-                generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceived(
-                    null, new Config()));
-    assertThat(npe).hasMessageThat().isEqualTo("project");
-  }
-
-  @Test
-  public void cannotGetEnableValidationOnCommitReceivedForNullPluginConfig() throws Exception {
-    NullPointerException npe =
-        assertThrows(
-            NullPointerException.class,
-            () -> generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceived(project, null));
-    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
-  }
-
-  @Test
-  public void noEnableValidationOnCommitReceivedConfiguration() throws Exception {
-    assertThat(
-            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceived(
-                project, new Config()))
-        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.enableValidationOnCommitReceived", value = "false")
-  public void
-      enableValidationOnCommitReceivedConfigurationIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
-          throws Exception {
-    assertThat(
-            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceived(
-                project, new Config()))
-        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.enableValidationOnCommitReceived", value = "false")
-  public void
-      enableValidationOnCommitReceivedConfigurationInPluginConfigOverridesEnableValidationOnCommitReceivedConfigurationInGerritConfig()
-          throws Exception {
-    Config cfg = new Config();
-    cfg.setString(SECTION_CODE_OWNERS, null, KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED, "true");
-    assertThat(generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceived(project, cfg))
-        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
-  }
-
-  @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(
-            NullPointerException.class, () -> generalConfig.getMergeCommitStrategy(project, null));
-    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
-  }
-
-  @Test
-  public void cannotGetMergeCommitStrategyForNullProjectName() throws Exception {
-    NullPointerException npe =
-        assertThrows(
-            NullPointerException.class,
-            () -> generalConfig.getMergeCommitStrategy(null, new Config()));
-    assertThat(npe).hasMessageThat().isEqualTo("project");
-  }
-
-  @Test
-  public void noMergeCommitStrategyConfigured() throws Exception {
-    assertThat(generalConfig.getMergeCommitStrategy(project, new Config()))
-        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "plugin.code-owners.mergeCommitStrategy",
-      value = "FILES_WITH_CONFLICT_RESOLUTION")
-  public void mergeCommitStrategyIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
-      throws Exception {
-    assertThat(generalConfig.getMergeCommitStrategy(project, new Config()))
-        .isEqualTo(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "plugin.code-owners.mergeCommitStrategy",
-      value = "FILES_WITH_CONFLICT_RESOLUTION")
-  public void mergeCommitStrategyInPluginConfigOverridesMergeCommitStrategyInGerritConfig()
-      throws Exception {
-    Config cfg = new Config();
-    cfg.setString(
-        SECTION_CODE_OWNERS,
-        null,
-        KEY_MERGE_COMMIT_STRATEGY,
-        MergeCommitStrategy.ALL_CHANGED_FILES.name());
-    assertThat(generalConfig.getMergeCommitStrategy(project, cfg))
-        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "plugin.code-owners.mergeCommitStrategy",
-      value = "FILES_WITH_CONFLICT_RESOLUTION")
-  public void invalidMergeCommitStrategyInPluginConfigIsIgnored() throws Exception {
-    Config cfg = new Config();
-    cfg.setString(SECTION_CODE_OWNERS, null, KEY_MERGE_COMMIT_STRATEGY, "INVALID");
-    assertThat(generalConfig.getMergeCommitStrategy(project, cfg))
-        .isEqualTo(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.mergeCommitStrategy", value = "INVALID")
-  public void invalidMergeCommitStrategyInGerritConfigIsIgnored() throws Exception {
-    assertThat(generalConfig.getMergeCommitStrategy(project, new Config()))
-        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
-  }
-
-  @Test
-  public void cannotValidateProjectLevelConfigWithNullFileName() throws Exception {
-    NullPointerException npe =
-        assertThrows(
-            NullPointerException.class,
-            () ->
-                generalConfig.validateProjectLevelConfig(
-                    null, new ProjectLevelConfig.Bare("code-owners.config")));
-    assertThat(npe).hasMessageThat().isEqualTo("fileName");
-  }
-
-  @Test
-  public void cannotValidateProjectLevelConfigWithForNullProjectLevelConfig() throws Exception {
-    NullPointerException npe =
-        assertThrows(
-            NullPointerException.class,
-            () -> generalConfig.validateProjectLevelConfig("code-owners.config", null));
-    assertThat(npe).hasMessageThat().isEqualTo("projectLevelConfig");
-  }
-
-  @Test
-  public void validateEmptyProjectLevelConfig() throws Exception {
-    ImmutableList<CommitValidationMessage> commitValidationMessage =
-        generalConfig.validateProjectLevelConfig(
-            "code-owners.config", new ProjectLevelConfig.Bare("code-owners.config"));
-    assertThat(commitValidationMessage).isEmpty();
-  }
-
-  @Test
-  public void validateValidProjectLevelConfig() throws Exception {
-    ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config");
-    cfg.getConfig()
-        .setString(
-            SECTION_CODE_OWNERS,
-            null,
-            KEY_MERGE_COMMIT_STRATEGY,
-            MergeCommitStrategy.ALL_CHANGED_FILES.name());
-    ImmutableList<CommitValidationMessage> commitValidationMessage =
-        generalConfig.validateProjectLevelConfig("code-owners.config", cfg);
-    assertThat(commitValidationMessage).isEmpty();
-  }
-
-  @Test
-  public void validateInvalidProjectLevelConfig_invalidMergeCommitStrategy() throws Exception {
-    ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config");
-    cfg.getConfig().setString(SECTION_CODE_OWNERS, null, KEY_MERGE_COMMIT_STRATEGY, "INVALID");
-    ImmutableList<CommitValidationMessage> commitValidationMessages =
-        generalConfig.validateProjectLevelConfig("code-owners.config", cfg);
-    assertThat(commitValidationMessages).hasSize(1);
-    CommitValidationMessage commitValidationMessage =
-        Iterables.getOnlyElement(commitValidationMessages);
-    assertThat(commitValidationMessage.getType()).isEqualTo(ValidationMessage.Type.ERROR);
-    assertThat(commitValidationMessage.getMessage())
-        .isEqualTo(
-            "Merge commit strategy 'INVALID' that is configured in code-owners.config"
-                + " (parameter codeOwners.mergeCommitStrategy) is invalid.");
-  }
-
-  @Test
-  public void cannotGetEnableImplicitApprovalsForNullPluginConfig() throws Exception {
-    NullPointerException npe =
-        assertThrows(
-            NullPointerException.class, () -> generalConfig.getEnableImplicitApprovals(null));
-    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
-  }
-
-  @Test
-  public void noEnableImplicitApprovalsConfiguration() throws Exception {
-    assertThat(generalConfig.getEnableImplicitApprovals(new Config())).isFalse();
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
-  public void
-      enableImplicitApprovalsConfigurationIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
-          throws Exception {
-    assertThat(generalConfig.getEnableImplicitApprovals(new Config())).isTrue();
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
-  public void
-      enableImplicitApprovalsConfigurationInPluginConfigOverridesEnableImplicitApprovalsConfigurationInGerritConfig()
-          throws Exception {
-    Config cfg = new Config();
-    cfg.setString(SECTION_CODE_OWNERS, null, KEY_ENABLE_IMPLICIT_APPROVALS, "false");
-    assertThat(generalConfig.getEnableImplicitApprovals(cfg)).isFalse();
-  }
-
-  @Test
-  public void cannotGetGlobalCodeOwnersForNullPluginConfig() throws Exception {
-    NullPointerException npe =
-        assertThrows(NullPointerException.class, () -> generalConfig.getGlobalCodeOwners(null));
-    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
-  }
-
-  @Test
-  public void noGlobalCodeOwners() throws Exception {
-    assertThat(generalConfig.getGlobalCodeOwners(new Config())).isEmpty();
-  }
-
-  @Test
-  @GerritConfig(
-      name = "plugin.code-owners.globalCodeOwner",
-      values = {"bot1@example.com", "bot2@example.com"})
-  public void globalCodeOwnersAreRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
-      throws Exception {
-    assertThat(generalConfig.getGlobalCodeOwners(new Config()))
-        .containsExactly(
-            CodeOwnerReference.create("bot1@example.com"),
-            CodeOwnerReference.create("bot2@example.com"));
-  }
-
-  @Test
-  @GerritConfig(
-      name = "plugin.code-owners.globalCodeOwner",
-      values = {"bot1@example.com", "bot2@example.com"})
-  public void globalCodeOnwersInPluginConfigOverrideGlobalCodeOwnersInGerritConfig()
-      throws Exception {
-    Config cfg = new Config();
-    cfg.setString(SECTION_CODE_OWNERS, null, KEY_GLOBAL_CODE_OWNER, "bot3@example.com");
-    assertThat(generalConfig.getGlobalCodeOwners(cfg))
-        .containsExactly(CodeOwnerReference.create("bot3@example.com"));
-  }
-
-  @Test
-  public void cannotGetOverrideInfoUrlForNullPluginConfig() throws Exception {
-    NullPointerException npe =
-        assertThrows(NullPointerException.class, () -> generalConfig.getOverrideInfoUrl(null));
-    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
-  }
-
-  @Test
-  public void noOverrideInfoUrlConfigured() throws Exception {
-    assertThat(generalConfig.getOverrideInfoUrl(new Config())).isEmpty();
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.overrideInfoUrl", value = "http://foo.example.com")
-  public void overrideInfoIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
-      throws Exception {
-    assertThat(generalConfig.getOverrideInfoUrl(new Config()))
-        .value()
-        .isEqualTo("http://foo.example.com");
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.overrideInfoUrl", value = "http://foo.example.com")
-  public void overrideInfoUrlInPluginConfigOverridesOverrideInfoUrlInGerritConfig()
-      throws Exception {
-    Config cfg = new Config();
-    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);
-  }
-}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/restapi/BUILD b/javatests/com/google/gerrit/plugins/codeowners/restapi/BUILD
index 2c509ff..03bef60 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/restapi/BUILD
+++ b/javatests/com/google/gerrit/plugins/codeowners/restapi/BUILD
@@ -4,9 +4,12 @@
     srcs = glob(["*Test.java"]),
     group = "restapi",
     deps = [
-        "//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/acceptance/testsuite",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/api",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/backend",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/common",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/restapi",
         "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing",
     ],
 )
diff --git a/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJsonTest.java b/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJsonTest.java
index 51b6ea5..9972afe 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJsonTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJsonTest.java
@@ -22,7 +22,7 @@
 import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedSet;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
@@ -31,15 +31,15 @@
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerBranchConfigInfo;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerProjectConfigInfo;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnersStatusInfo;
-import com.google.gerrit.plugins.codeowners.api.MergeCommitStrategy;
 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.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginProjectConfigSnapshot;
+import com.google.gerrit.plugins.codeowners.backend.config.RequiredApproval;
 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;
-import com.google.gerrit.plugins.codeowners.config.RequiredApproval;
+import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.project.BranchResource;
 import com.google.gerrit.server.project.ProjectResource;
@@ -64,6 +64,7 @@
   @Rule public final MockitoRule mockito = MockitoJUnit.rule().strictness(Strictness.STRICT_STUBS);
 
   @Mock private CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
+  @Mock private CodeOwnersPluginProjectConfigSnapshot codeOwnersPluginConfigSnapshot;
 
   @Inject private CurrentUser currentUser;
 
@@ -79,7 +80,6 @@
     codeOwnerProjectConfigJson =
         new CodeOwnerProjectConfigJson(
             codeOwnersPluginConfiguration,
-            plugin.getSysInjector().getInstance(CodeOwnerConfigScanner.Factory.class),
             plugin.getSysInjector().getInstance(new Key<Provider<ListBranches>>() {}));
     findOwnersBackend = plugin.getSysInjector().getInstance(FindOwnersBackend.class);
     protoBackend = plugin.getSysInjector().getInstance(ProtoBackend.class);
@@ -108,14 +108,15 @@
   public void formatBackendIds() throws Exception {
     createBranch(BranchNameKey.create(project, "stable-2.10"));
 
-    when(codeOwnersPluginConfiguration.getBackend(project)).thenReturn(findOwnersBackend);
-    when(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
+    when(codeOwnersPluginConfigSnapshot.getBackend()).thenReturn(findOwnersBackend);
+    when(codeOwnersPluginConfigSnapshot.getBackend("refs/heads/master"))
         .thenReturn(findOwnersBackend);
-    when(codeOwnersPluginConfiguration.getBackend(
-            BranchNameKey.create(project, "refs/meta/config")))
+    when(codeOwnersPluginConfigSnapshot.getBackend("refs/meta/config"))
         .thenReturn(findOwnersBackend);
-    when(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "stable-2.10")))
+    when(codeOwnersPluginConfigSnapshot.getBackend("refs/heads/stable-2.10"))
         .thenReturn(protoBackend);
+    when(codeOwnersPluginConfiguration.getProjectConfig(project))
+        .thenReturn(codeOwnersPluginConfigSnapshot);
 
     BackendInfo backendInfo = codeOwnerProjectConfigJson.formatBackendInfo(createProjectResource());
     assertThat(backendInfo.id).isEqualTo(CodeOwnerBackendId.FIND_OWNERS.getBackendId());
@@ -131,12 +132,13 @@
 
   @Test
   public void idsPerBranchNotSetIfThereIsNoBranchSpecificBackendConfiguration() throws Exception {
-    when(codeOwnersPluginConfiguration.getBackend(project)).thenReturn(findOwnersBackend);
-    when(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
+    when(codeOwnersPluginConfigSnapshot.getBackend()).thenReturn(findOwnersBackend);
+    when(codeOwnersPluginConfigSnapshot.getBackend("refs/heads/master"))
         .thenReturn(findOwnersBackend);
-    when(codeOwnersPluginConfiguration.getBackend(
-            BranchNameKey.create(project, "refs/meta/config")))
+    when(codeOwnersPluginConfigSnapshot.getBackend("refs/meta/config"))
         .thenReturn(findOwnersBackend);
+    when(codeOwnersPluginConfiguration.getProjectConfig(project))
+        .thenReturn(codeOwnersPluginConfigSnapshot);
 
     BackendInfo backendInfo = codeOwnerProjectConfigJson.formatBackendInfo(createProjectResource());
     assertThat(backendInfo.id).isEqualTo(CodeOwnerBackendId.FIND_OWNERS.getBackendId());
@@ -148,31 +150,34 @@
     createOwnersOverrideLabel();
     createBranch(BranchNameKey.create(project, "stable-2.10"));
 
-    when(codeOwnersPluginConfiguration.isDisabled(project)).thenReturn(false);
-    when(codeOwnersPluginConfiguration.isDisabled(any(BranchNameKey.class))).thenReturn(false);
-    when(codeOwnersPluginConfiguration.getBackend(project)).thenReturn(findOwnersBackend);
-    when(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-        .thenReturn(findOwnersBackend);
-    when(codeOwnersPluginConfiguration.getBackend(
-            BranchNameKey.create(project, "refs/meta/config")))
-        .thenReturn(findOwnersBackend);
-    when(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "stable-2.10")))
-        .thenReturn(protoBackend);
-    when(codeOwnersPluginConfiguration.getFileExtension(project)).thenReturn(Optional.of("foo"));
-    when(codeOwnersPluginConfiguration.getOverrideInfoUrl(project))
-        .thenReturn(Optional.of("http://foo.example.com"));
-    when(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
+    when(codeOwnersPluginConfigSnapshot.getFileExtension()).thenReturn(Optional.of("foo"));
+    when(codeOwnersPluginConfigSnapshot.getMergeCommitStrategy())
         .thenReturn(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
-    when(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+    when(codeOwnersPluginConfigSnapshot.getFallbackCodeOwners())
         .thenReturn(FallbackCodeOwners.ALL_USERS);
-    when(codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(project)).thenReturn(true);
-    when(codeOwnersPluginConfiguration.getRequiredApproval(project))
+    when(codeOwnersPluginConfigSnapshot.getOverrideInfoUrl())
+        .thenReturn(Optional.of("http://foo.example.com"));
+    when(codeOwnersPluginConfigSnapshot.getInvalidCodeOwnerConfigInfoUrl())
+        .thenReturn(Optional.of("http://bar.example.com"));
+    when(codeOwnersPluginConfigSnapshot.isDisabled()).thenReturn(false);
+    when(codeOwnersPluginConfigSnapshot.isDisabled(any(String.class))).thenReturn(false);
+    when(codeOwnersPluginConfigSnapshot.getBackend()).thenReturn(findOwnersBackend);
+    when(codeOwnersPluginConfigSnapshot.getBackend("refs/heads/master"))
+        .thenReturn(findOwnersBackend);
+    when(codeOwnersPluginConfigSnapshot.getBackend("refs/meta/config"))
+        .thenReturn(findOwnersBackend);
+    when(codeOwnersPluginConfigSnapshot.getBackend("refs/heads/stable-2.10"))
+        .thenReturn(protoBackend);
+    when(codeOwnersPluginConfigSnapshot.areImplicitApprovalsEnabled()).thenReturn(true);
+    when(codeOwnersPluginConfigSnapshot.getRequiredApproval())
         .thenReturn(RequiredApproval.create(getDefaultCodeReviewLabel(), (short) 2));
-    when(codeOwnersPluginConfiguration.getOverrideApproval(project))
+    when(codeOwnersPluginConfigSnapshot.getOverrideApprovals())
         .thenReturn(
-            ImmutableSet.of(
+            ImmutableSortedSet.of(
                 RequiredApproval.create(
                     LabelType.withDefaultValues("Owners-Override"), (short) 1)));
+    when(codeOwnersPluginConfiguration.getProjectConfig(project))
+        .thenReturn(codeOwnersPluginConfigSnapshot);
 
     CodeOwnerProjectConfigInfo codeOwnerProjectConfigInfo =
         codeOwnerProjectConfigJson.format(createProjectResource());
@@ -181,6 +186,8 @@
     assertThat(codeOwnerProjectConfigInfo.general.fileExtension).isEqualTo("foo");
     assertThat(codeOwnerProjectConfigInfo.general.overrideInfoUrl)
         .isEqualTo("http://foo.example.com");
+    assertThat(codeOwnerProjectConfigInfo.general.invalidCodeOwnerConfigInfoUrl)
+        .isEqualTo("http://bar.example.com");
     assertThat(codeOwnerProjectConfigInfo.general.mergeCommitStrategy)
         .isEqualTo(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
     assertThat(codeOwnerProjectConfigInfo.general.fallbackCodeOwners)
@@ -200,7 +207,9 @@
 
   @Test
   public void disabledBranchesNotSetIfDisabledOnProjectLevel() throws Exception {
-    when(codeOwnersPluginConfiguration.isDisabled(project)).thenReturn(true);
+    when(codeOwnersPluginConfigSnapshot.isDisabled()).thenReturn(true);
+    when(codeOwnersPluginConfiguration.getProjectConfig(project))
+        .thenReturn(codeOwnersPluginConfigSnapshot);
     CodeOwnersStatusInfo statusInfo =
         codeOwnerProjectConfigJson.formatStatusInfo(createProjectResource());
     assertThat(statusInfo.disabled).isTrue();
@@ -209,12 +218,11 @@
 
   @Test
   public void emptyStatus() throws Exception {
-    when(codeOwnersPluginConfiguration.isDisabled(project)).thenReturn(false);
-    when(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .thenReturn(false);
-    when(codeOwnersPluginConfiguration.isDisabled(
-            BranchNameKey.create(project, "refs/meta/config")))
-        .thenReturn(false);
+    when(codeOwnersPluginConfigSnapshot.isDisabled()).thenReturn(false);
+    when(codeOwnersPluginConfigSnapshot.isDisabled("refs/heads/master")).thenReturn(false);
+    when(codeOwnersPluginConfigSnapshot.isDisabled("refs/meta/config")).thenReturn(false);
+    when(codeOwnersPluginConfiguration.getProjectConfig(project))
+        .thenReturn(codeOwnersPluginConfigSnapshot);
     CodeOwnersStatusInfo statusInfo =
         codeOwnerProjectConfigJson.formatStatusInfo(createProjectResource());
     assertThat(statusInfo.disabled).isNull();
@@ -223,12 +231,11 @@
 
   @Test
   public void withDisabledBranches() throws Exception {
-    when(codeOwnersPluginConfiguration.isDisabled(project)).thenReturn(false);
-    when(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .thenReturn(true);
-    when(codeOwnersPluginConfiguration.isDisabled(
-            BranchNameKey.create(project, "refs/meta/config")))
-        .thenReturn(false);
+    when(codeOwnersPluginConfigSnapshot.isDisabled()).thenReturn(false);
+    when(codeOwnersPluginConfigSnapshot.isDisabled("refs/heads/master")).thenReturn(true);
+    when(codeOwnersPluginConfigSnapshot.isDisabled("refs/meta/config")).thenReturn(false);
+    when(codeOwnersPluginConfiguration.getProjectConfig(project))
+        .thenReturn(codeOwnersPluginConfigSnapshot);
     CodeOwnersStatusInfo statusInfo =
         codeOwnerProjectConfigJson.formatStatusInfo(createProjectResource());
     assertThat(statusInfo.disabled).isNull();
@@ -239,11 +246,13 @@
   public void withMultipleOverrides() throws Exception {
     createOwnersOverrideLabel();
 
-    when(codeOwnersPluginConfiguration.getOverrideApproval(project))
+    when(codeOwnersPluginConfigSnapshot.getOverrideApprovals())
         .thenReturn(
-            ImmutableSet.of(
+            ImmutableSortedSet.of(
                 RequiredApproval.create(LabelType.withDefaultValues("Owners-Override"), (short) 1),
                 RequiredApproval.create(LabelType.withDefaultValues("Code-Review"), (short) 2)));
+    when(codeOwnersPluginConfiguration.getProjectConfig(project))
+        .thenReturn(codeOwnersPluginConfigSnapshot);
 
     ImmutableList<RequiredApprovalInfo> requiredApprovalInfos =
         codeOwnerProjectConfigJson.formatOverrideApprovalInfo(project);
@@ -266,24 +275,28 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    when(codeOwnersPluginConfiguration.isDisabled(any(BranchNameKey.class))).thenReturn(false);
-    when(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-        .thenReturn(findOwnersBackend);
-    when(codeOwnersPluginConfiguration.getFileExtension(project)).thenReturn(Optional.of("foo"));
-    when(codeOwnersPluginConfiguration.getOverrideInfoUrl(project))
-        .thenReturn(Optional.of("http://foo.example.com"));
-    when(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
+    when(codeOwnersPluginConfigSnapshot.getFileExtension()).thenReturn(Optional.of("foo"));
+    when(codeOwnersPluginConfigSnapshot.getMergeCommitStrategy())
         .thenReturn(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
-    when(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+    when(codeOwnersPluginConfigSnapshot.getFallbackCodeOwners())
         .thenReturn(FallbackCodeOwners.ALL_USERS);
-    when(codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(project)).thenReturn(true);
-    when(codeOwnersPluginConfiguration.getRequiredApproval(project))
+    when(codeOwnersPluginConfigSnapshot.getOverrideInfoUrl())
+        .thenReturn(Optional.of("http://foo.example.com"));
+    when(codeOwnersPluginConfigSnapshot.getInvalidCodeOwnerConfigInfoUrl())
+        .thenReturn(Optional.of("http://bar.example.com"));
+    when(codeOwnersPluginConfigSnapshot.isDisabled(any(String.class))).thenReturn(false);
+    when(codeOwnersPluginConfigSnapshot.getBackend("refs/heads/master"))
+        .thenReturn(findOwnersBackend);
+    when(codeOwnersPluginConfigSnapshot.areImplicitApprovalsEnabled()).thenReturn(true);
+    when(codeOwnersPluginConfigSnapshot.getRequiredApproval())
         .thenReturn(RequiredApproval.create(getDefaultCodeReviewLabel(), (short) 2));
-    when(codeOwnersPluginConfiguration.getOverrideApproval(project))
+    when(codeOwnersPluginConfigSnapshot.getOverrideApprovals())
         .thenReturn(
-            ImmutableSet.of(
+            ImmutableSortedSet.of(
                 RequiredApproval.create(
                     LabelType.withDefaultValues("Owners-Override"), (short) 1)));
+    when(codeOwnersPluginConfiguration.getProjectConfig(project))
+        .thenReturn(codeOwnersPluginConfigSnapshot);
 
     CodeOwnerBranchConfigInfo codeOwnerBranchConfigInfo =
         codeOwnerProjectConfigJson.format(createBranchResource("refs/heads/master"));
@@ -291,6 +304,8 @@
     assertThat(codeOwnerBranchConfigInfo.general.fileExtension).isEqualTo("foo");
     assertThat(codeOwnerBranchConfigInfo.general.overrideInfoUrl)
         .isEqualTo("http://foo.example.com");
+    assertThat(codeOwnerBranchConfigInfo.general.invalidCodeOwnerConfigInfoUrl)
+        .isEqualTo("http://bar.example.com");
     assertThat(codeOwnerBranchConfigInfo.general.mergeCommitStrategy)
         .isEqualTo(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
     assertThat(codeOwnerBranchConfigInfo.general.fallbackCodeOwners)
@@ -304,12 +319,13 @@
     assertThat(codeOwnerBranchConfigInfo.overrideApproval.get(0).label)
         .isEqualTo("Owners-Override");
     assertThat(codeOwnerBranchConfigInfo.overrideApproval.get(0).value).isEqualTo(1);
-    assertThat(codeOwnerBranchConfigInfo.noCodeOwnersDefined).isNull();
   }
 
   @Test
   public void formatCodeOwnerBranchConfig_disabled() throws Exception {
-    when(codeOwnersPluginConfiguration.isDisabled(any(BranchNameKey.class))).thenReturn(true);
+    when(codeOwnersPluginConfigSnapshot.isDisabled(any(String.class))).thenReturn(true);
+    when(codeOwnersPluginConfiguration.getProjectConfig(project))
+        .thenReturn(codeOwnersPluginConfigSnapshot);
 
     CodeOwnerBranchConfigInfo codeOwnerBranchConfigInfo =
         codeOwnerProjectConfigJson.format(createBranchResource("refs/heads/master"));
@@ -318,35 +334,6 @@
     assertThat(codeOwnerBranchConfigInfo.backendId).isNull();
     assertThat(codeOwnerBranchConfigInfo.requiredApproval).isNull();
     assertThat(codeOwnerBranchConfigInfo.overrideApproval).isNull();
-    assertThat(codeOwnerBranchConfigInfo.noCodeOwnersDefined).isNull();
-  }
-
-  @Test
-  public void formatCodeOwnerBranchConfig_bootstrappingMode() throws Exception {
-    createOwnersOverrideLabel();
-
-    when(codeOwnersPluginConfiguration.isDisabled(any(BranchNameKey.class))).thenReturn(false);
-    when(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-        .thenReturn(findOwnersBackend);
-    when(codeOwnersPluginConfiguration.getFileExtension(project)).thenReturn(Optional.of("foo"));
-    when(codeOwnersPluginConfiguration.getOverrideInfoUrl(project))
-        .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(
-            ImmutableSet.of(
-                RequiredApproval.create(
-                    LabelType.withDefaultValues("Owners-Override"), (short) 1)));
-
-    CodeOwnerBranchConfigInfo codeOwnerBranchConfigInfo =
-        codeOwnerProjectConfigJson.format(createBranchResource("refs/heads/master"));
-    assertThat(codeOwnerBranchConfigInfo.noCodeOwnersDefined).isTrue();
   }
 
   private ProjectResource createProjectResource() {
diff --git a/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerStatusInfoJsonTest.java b/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerStatusInfoJsonTest.java
index 75aaeef..456e609 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerStatusInfoJsonTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerStatusInfoJsonTest.java
@@ -27,13 +27,13 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.ChangeType;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
-import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatus;
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatusInfo;
 import com.google.gerrit.plugins.codeowners.api.FileCodeOwnerStatusInfo;
 import com.google.gerrit.plugins.codeowners.api.PathCodeOwnerStatusInfo;
-import com.google.gerrit.plugins.codeowners.backend.ChangedFile;
 import com.google.gerrit.plugins.codeowners.backend.FileCodeOwnerStatus;
 import com.google.gerrit.plugins.codeowners.backend.PathCodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.common.ChangedFile;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
 import com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusInfoSubject;
 import com.google.gerrit.truth.ListSubject;
 import java.nio.file.Paths;
diff --git a/javatests/com/google/gerrit/plugins/codeowners/BUILD b/javatests/com/google/gerrit/plugins/codeowners/util/BUILD
similarity index 70%
rename from javatests/com/google/gerrit/plugins/codeowners/BUILD
rename to javatests/com/google/gerrit/plugins/codeowners/util/BUILD
index 0a2f23e..623a675 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/BUILD
+++ b/javatests/com/google/gerrit/plugins/codeowners/util/BUILD
@@ -2,9 +2,9 @@
 
 acceptance_tests(
     srcs = glob(["*Test.java"]),
-    group = "codeowners",
+    group = "util",
     deps = [
-        "//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/util",
     ],
 )
diff --git a/javatests/com/google/gerrit/plugins/codeowners/JgitPathTest.java b/javatests/com/google/gerrit/plugins/codeowners/util/JgitPathTest.java
similarity index 65%
rename from javatests/com/google/gerrit/plugins/codeowners/JgitPathTest.java
rename to javatests/com/google/gerrit/plugins/codeowners/util/JgitPathTest.java
index f2c3e81..4b73459 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/JgitPathTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/util/JgitPathTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.plugins.codeowners;
+package com.google.gerrit.plugins.codeowners.util;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
@@ -23,7 +23,7 @@
 import java.nio.file.Paths;
 import org.junit.Test;
 
-/** Tests for {@link JgitPath}. */
+/** Tests for {@link com.google.gerrit.plugins.codeowners.util.JgitPath}. */
 public class JgitPathTest extends AbstractCodeOwnersTest {
   @Test
   public void getJgitPathOfStringPath() throws Exception {
@@ -56,4 +56,29 @@
     assertThat(JgitPath.of("foo/bar/OWNERS").getAsAbsolutePath())
         .isEqualTo(Paths.get("/foo/bar/OWNERS"));
   }
+
+  @Test
+  public void testToString() throws Exception {
+    String path = "foo/bar/baz.md";
+    assertThat(JgitPath.of(path).toString()).isEqualTo(path);
+    assertThat(JgitPath.of("/" + path).toString()).isEqualTo(path);
+  }
+
+  @Test
+  public void testEquals() throws Exception {
+    String path = "foo/bar/baz.md";
+    assertThat(JgitPath.of(path)).isEqualTo(JgitPath.of(path));
+    assertThat(JgitPath.of("/" + path)).isEqualTo(JgitPath.of(path));
+    assertThat(JgitPath.of("/" + path)).isNotEqualTo(JgitPath.of("foo/bar/baz.txt"));
+    assertThat(JgitPath.of("/" + path)).isNotEqualTo(new Object());
+  }
+
+  @Test
+  public void testHashCode() throws Exception {
+    String path = "foo/bar/baz.md";
+    assertThat(JgitPath.of(path).hashCode()).isEqualTo(JgitPath.of(path).hashCode());
+    assertThat(JgitPath.of("/" + path).hashCode()).isEqualTo(JgitPath.of(path).hashCode());
+    assertThat(JgitPath.of("/" + path).hashCode())
+        .isNotEqualTo(JgitPath.of("foo/bar/baz.txt").hashCode());
+  }
 }
diff --git a/proto/BUILD b/proto/BUILD
index 05494e4..ed0f86f 100644
--- a/proto/BUILD
+++ b/proto/BUILD
@@ -1,8 +1,6 @@
 load("@rules_proto//proto:defs.bzl", "proto_library")
 load("@rules_java//java:defs.bzl", "java_proto_library")
 
-package(default_visibility = ["//plugins/code-owners:visibility"])
-
 proto_library(
     name = "owners_metadata_proto",
     srcs = ["owners_metadata.proto"],
@@ -10,6 +8,10 @@
 
 java_proto_library(
     name = "owners_metadata_java_proto",
-    visibility = ["//visibility:public"],
+    visibility = [
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/api:__pkg__",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/backend:__pkg__",
+        "//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/restapi:__pkg__",
+    ],
     deps = [":owners_metadata_proto"],
 )
diff --git a/resources/Documentation/about.md b/resources/Documentation/about.md
index 6126097..d558de6 100644
--- a/resources/Documentation/about.md
+++ b/resources/Documentation/about.md
@@ -16,3 +16,55 @@
 for code owners is pretty generic and [configurable](config.html) so that it
 should be suitable for other teams as well.
 
+## <a id="functionality">Functionality
+
+* Support for defining code owners:
+    * Code owners can be specified in `OWNERS` files that can appear in any
+      directory in the source branch.
+    * Default code owners can be specified on repository level by an `OWNERS`
+      file in the `refs/meta/config` branch.
+    * Global code owners across repositories can be configured.
+    * A fallback code owners policy controls who owns files that are not covered
+      by `OWNERS` files.
+    * Code owners can be specified by email (groups are not supported).
+    * Inheritance from parent directories is supported and can be disabled.
+    * Including an `OWNERS` file from other directories / branches / projects is
+      possible (only on the same host).
+    * File globs can be used.
+    * see [code owners documentation](config-guide.html#codeOwners) and
+      [OWNERS syntax](backend-find-owners.html#syntax)
+<br><br>
+* Prevents submitting changes without code owner approvals:
+    * Which votes count as code owner approvals is
+      [configurable](setup-guide.html#configureCodeOwnerApproval).
+    * Implemented as Java submit rule (no Prolog).
+<br><br>
+* Support for overrides:
+    * Privileged users can be allowed to override the code owner submit check.
+    * Overriding is done by voting on a [configured override
+      label](setup-guide.html#configureCodeOwnerOverrides).
+    * see [override setup](config-faqs.html#setupOverrides)
+<br><br>
+* UI extensions on change screen:
+    * Code owner suggestion
+    * Display of the code owners submit requirement
+    * Display of code owner statuses in the file list
+    * Change messages that list the owned paths.
+    * see [UI walkthrough](how-to-use.html) and [user guide](user-guide.html)
+<br><br>
+* Extensible:
+    * Supports multiple [backends](backends.html) which can implement different
+      syntaxes for `OWNERS` files.
+<br><br>
+* Validation:
+    * updates to `OWNERS` files are [validated](validation.html) on commit
+      received and submit
+    * `OWNERS` files can be validated on demand to detect consistency issues
+<br><br>
+* Rich REST API:
+    * see [REST API documentation](rest-api.html)
+<br><br>
+* Highly configurable:
+    * see [setup guide](setup-guide.html), [config-guide](config-guide.html),
+      [config FAQs](config-faqs.html) and [config documentation](config.html)
+
diff --git a/resources/Documentation/alternative-plugins.md b/resources/Documentation/alternative-plugins.md
index b335414..adcd3ab 100644
--- a/resources/Documentation/alternative-plugins.md
+++ b/resources/Documentation/alternative-plugins.md
@@ -1,10 +1,112 @@
 # Alternative Plugins
 
-Similar functionality is provided by the
-[find-owners](https://gerrit-review.googlesource.com/admin/repos/plugins/find-owners)
-plugin and the
-[owners](https://gerrit-review.googlesource.com/admin/repos/plugins/owners)
-plugin.
+Similar functionality is provided by the following plugins:
+
+* [find-owners](#findOwners) plugin
+* [owners](#owners) plugin
+
+## <a id="findOwners">find-owners plugin (deprecated)
+
+**Status:** deprecated, from Gerrit 3.4 on the `code-owners` plugin should be used instead\
+**Repository:** [plugins/find-owners](https://gerrit-review.googlesource.com/admin/repos/plugins/find-owners)\
+**Documentation:** [about](https://gerrit.googlesource.com/plugins/find-owners/+/HEAD/src/main/resources/Documentation/about.md), [syntax](https://gerrit.googlesource.com/plugins/find-owners/+/HEAD/src/main/resources/Documentation/syntax.md), [REST API](https://gerrit.googlesource.com/plugins/find-owners/+/HEAD/src/main/resources/Documentation/rest-api.md), [config](https://gerrit.googlesource.com/plugins/find-owners/+/HEAD/src/main/resources/Documentation/config.md)
+
+### <a id="findOwnersCompatibility">Compatibility with the code-owners plugin
+
+The [find-owners](backend-find-owners.html) backend in the`code-owners` plugin
+supports the same syntax for `OWNERS` files as the `find-owners` plugin.  This
+means that existing `OWNERS` files continue to work with the `code-owners`
+plugin and no migration for the `OWNERS` files is required.
+
+**IMPORTANT:** When migrating to the `code-owners` plugin, make sure that it is
+correctly configured (see [setup guide](setup-guide.html)).
+
+**NOTE:** The REST API of the `code-owners` plugin is completely different than
+the REST API of the `find-owners` plugin. This means callers of the REST API
+must be adapted to the new API.
+
+**NOTE:** The `OWNERS` syntax in the `code-owners` plugin supports some
+additional features. This means that `OWNERS` files that work with the
+`code-owners` plugin may not work with the `find-owners` plugin.
+
+### <a id="findOwnersFunctionality">Functionality
+
+* Basic support for defining code owners:
+    * Code owners can be specified in `OWNERS` files that can appear in any
+      directory in the source branch.
+    * Code owners can be specified by email.
+    * Inheritance from parent directories is supported and can be disabled.
+    * Including an `OWNERS` file from another directory in the same project or
+      from another project on the same host is possible (same branch is assumed).
+    * File globs can be used.
+    * See [documentation](https://gerrit.googlesource.com/plugins/find-owners/+/HEAD/src/main/resources/Documentation/syntax.md) for the supported syntax.
+<br><br>
+* Prolog rule to prevent submitting changes without owner approvals.
+    * A change can be exempted from owners approval by setting a footer in the
+      commit message.
+<br><br>
+* Basic UI:
+    * Supports to discover users that can grant owner approval on a change
+      (weighed suggestion) and add them as reviewer to the change.
+    * Missing owner approvals are visualized on a change.
+    * Owner approval is granted by voting on the `Code-Review` label.
+<br><br>
+* REST endpoints:
+    * [Action](https://gerrit.googlesource.com/plugins/find-owners/+/HEAD/src/main/java/com/googlesource/gerrit/plugins/findowners/Action.java) REST endpoint:
+        * `GET /changes/<change-id>/revisions/<revision-id>/find-owners`
+        * returns a [RestResult](https://gerrit.googlesource.com/plugins/find-owners/+/HEAD/src/main/java/com/googlesource/gerrit/plugins/findowners/RestResult.java) which contains:
+            * a file to list of owners map
+            * a list of owner infos with weight infos
+            * fields for debugging
+            * fields for change, patch set, current reviewers and changed files
+    * [GetOwners](https://gerrit.googlesource.com/plugins/find-owners/+/HEAD/src/main/java/com/googlesource/gerrit/plugins/findowners/GetOwners.java) REST endpoint:
+        * `GET /changes/<change-id>/owners`
+        * Delegates to Action REST endpoint (see above)
+    * Also see [REST endpoint documentation](https://gerrit.googlesource.com/plugins/find-owners/+/HEAD/src/main/resources/Documentation/rest-api.md)
+
+## <a id="owners">owners plugin + owners-autoassign plugin
+
+**Status:** maintained by the Gerrit open source community (no Google involvement)\
+**Repository:** [plugins/owners](https://gerrit-review.googlesource.com/admin/repos/plugins/owners)\
+**Documentation:** [readme](https://gerrit.googlesource.com/plugins/owners/+/HEAD/README.md), [config & syntax](https://gerrit.googlesource.com/plugins/owners/+/HEAD/owners/src/main/resources/Documentation/config.md)
+
+### <a id="ownersCompatibility">Compatibility with the code-owners plugin
+
+The `OWNERS` sytax that is used by the `owners` plugin is **not** compatible
+with the `code-owners` plugin. This means any migration from the `owners` plugin
+to the `code-owners` plugin (and vice versa) requires migrating all existing
+`OWNERS` files.
+
+**NOTE:** It would be feasible to implement a new [backend](backends.html) in
+the `code-owners` plugin that supports the syntax of the `owners` plugin
+(contributions are welcome).
+
+**NOTE:** The `owners` plugin supports groups as code owners, which are not
+supported by the `code-owners` plugin.
+
+### <a id="ownersFunctionality">Functionality
+
+* Basic support for defining code owners in the source branch and globally for
+  a repository:
+    * Code owners can be specified in `OWNERS` files that can appear in any
+      directory in the source branch.
+    * Code owners can be specified on repository level by an `OWNERS` file in
+      the `refs/meta/config` branch.
+    * Code owners can be specified by email or full name.
+    * Groups can be specified as code owners (by group name and group UUID).
+    * Inheritance from parent directories is supported and can be disabled.
+    * Regular expressions can be used.
+    * Syntax is based on YAML.
+    * See [documentation](https://gerrit.googlesource.com/plugins/owners/+/HEAD/owners/src/main/resources/Documentation/config.md) for the supported syntax.
+<br><br>
+* Prolog rule to prevent submitting changes without code owner approvals.
+    * The label on which code owners must vote is configurable.
+<br><br>
+* The UI visualizes whose approval is needed for submit.
+    * Implemented by the `need` description of the Prolog rule, which will be
+      shown in the UI (the plugin doesn't contain any UI code)
+<br><br>
+* Auto-adding of code owners as reviewers.
 
 ---
 
diff --git a/resources/Documentation/backend-find-owners-cookbook.md b/resources/Documentation/backend-find-owners-cookbook.md
index 7b49820..98db7c8 100644
--- a/resources/Documentation/backend-find-owners-cookbook.md
+++ b/resources/Documentation/backend-find-owners-cookbook.md
@@ -40,7 +40,7 @@
 
 ### <a id="ignoreParentCodeOwners">Ignore parent code owners
 
-To ignore code owners that are defined in the `OWNERS` file of the parent
+To ignore code owners that are defined in the `OWNERS` files of the parent
 directories the [set noparent](backend-find-owners.html#setNoparent) file-level
 rule can be used:
 
@@ -51,6 +51,19 @@
   richard.roe@example.com
 ```
 \
+For example, if code owners for the file '/foo/bar/baz.txt' are computed the
+`OWNERS` files are evaluated in this order:
+
+1. `/foo/bar/OWNERS`
+2. `/foo/OWNERS`
+3. `/OWNERS`
+4. `/OWNERS` in `refs/meta/config`
+   (contains [default code owners](backend-find-owners.html#defaultCodeOwnerConfiguration))
+
+If any `set noparent` file-level rule is seen the evaluation is stopped and
+further `OWNERS` files are ignored. E.g. if `/foo/OWNERS` contains
+`set noparent` the `OWNERS` files mentioned at 3. and 4. are ignored.
+
 **NOTE:** When the [set noparent](backend-find-owners.html#setNoparent)
 file-level rule is used you should always define code owners which should be
 used instead of the code owners from the parent directories. Otherwise the files
@@ -59,11 +72,14 @@
 approvals](#exemptFiles), assign the code ownership to [all
 users](backend-find-owners.html#allUsers) instead ([example](#exemptFiles)).
 
+**NOTE:** The usage of `set noparent` has no effect on `OWNERS` files in
+subfolders.
+
 ### <a id="defineCodeOwnersForAFile">Define code owners for a certain file
 
-Using the [per-file](backend-find-owners.html#perFile) restriction prefix it is
-possible to define code owners for a certain file, e.g. for the `BUILD` file in
-the current directory:
+By using the [per-file](backend-find-owners.html#perFile) restriction prefix it
+is possible to define code owners for a certain file (in the current directory
+and all its subdirectories), e.g. for the `BUILD` file:
 
 ```
   per-file BUILD=tina.toe@example.com
@@ -87,8 +103,8 @@
 users that are mentioned in these [per-file](backend-find-owners.html#perFile)
 lines.
 
-If non-restricted code owners are present, the file is also owned by any
-non-restricted code owners:
+If folder code owners are present, the file is also owned by any folder code
+owners:
 
 ```
   jane.roe@example.com
@@ -101,9 +117,9 @@
 parent directories.
 
 #### <a id="perFileWithSetNoparent">
-Ignoring non-restricted code owners and inherited parent code owners for a file
-is possible by using a matching [per-file](backend-find-owners.html#perFile)
-line with [set noparent](backend-find-owners.html#setNoparent).
+Ignoring folder code owners and inherited parent code owners for a file is
+possible by using a matching [per-file](backend-find-owners.html#perFile) line
+with [set noparent](backend-find-owners.html#setNoparent).
 
 ```
   jane.roe@example.com
@@ -113,22 +129,34 @@
   per-file BUILD=tina.toe@example.com,martha.moe@example.com
 ```
 \
+For example, if code owners for the file '/foo/bar/baz.txt' are computed the
+code owners in the `OWNERS` files are evaluated in this order:
+
+1. matching per-file code owners in `/foo/bar/OWNERS`
+2. folder code owners in `/foo/bar/OWNERS`
+3. matching per-file code owners in `/foo/OWNERS`
+4. folder code owners in `/foo/OWNERS`
+5. matching per-file code owners in `/OWNERS`
+6. folder code owners in `/OWNERS`
+7. matching per-file code owners in `/OWNERS` in `refs/meta/config`
+   (contains [default code owners](backend-find-owners.html#defaultCodeOwnerConfiguration))
+8. folder code owners in `/OWNERS` in `refs/meta/config`
+   (contains [default code owners](backend-find-owners.html#defaultCodeOwnerConfiguration))
+
+If any `set noparent` file-level rule is seen the evaluation is stopped and
+code owners on further levels are ignored. E.g. if `/foo/OWNERS` contains a
+matching per-file rule with `set noparent` the code owners mentioned at 4. to 8.
+are ignored.
+
 **NOTE:** When the [set noparent](backend-find-owners.html#setNoparent) rule is
-used you should always define code owners which should be used instead of the
-non-restricted code owners and the code owners from the parent directories.
-Otherwise the matched files stay [without code owners](#noCodeOwners) and nobody
-can grant code owner approval on them. To [exempt matched files from requiring
-code owner approvals](#exemptFiles), assign the code ownership to [all
-users](backend-find-owners.html#allUsers) instead ([example](#exemptFiles)).
+used on a per-file rule you should always define code owners which should be
+used instead of the folder code owners and the code owners from the parent
+directories.  Otherwise the matched files stay [without code
+owners](#noCodeOwners) and nobody can grant code owner approval on them. To
+[exempt matched files from requiring code owner approvals](#exemptFiles), assign
+the code ownership to [all users](backend-find-owners.html#allUsers) instead
+([example](#exemptFiles)).
 
-If files with a given name should be matched in the current directory and all
-its subdirectories, the [per-file](backend-find-owners.html#perFile) line must
-have path expression that matches these file:
-
-```
-  per-file {**/,}BUILD=tina.toe@example.com,martha.moe@example.com
-```
-\
 **NOTE:** The syntax for path expressions / globs is explained
 [here](path-expressions.html#globs).
 
@@ -142,19 +170,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 +192,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
@@ -198,10 +225,10 @@
 ```
 \
 **NOTE:** The `per-file` line from `/OWNERS_BUILD` is not imported, since the
-[file](backend-find-owners.html#fileKeyword) keyword only imports non-restricted
-code owners. Using the [include](backend-find-owners.html#includeKeyword)
-keyword, that would also consider per-file code owners, is not supported for
-`per-file` lines.
+[file](backend-find-owners.html#fileKeyword) keyword only imports folder code
+owners. Using the [include](backend-find-owners.html#includeKeyword) keyword,
+that would also consider per-file code owners, is not supported for `per-file`
+lines.
 
 ### <a id="importOtherOwnersFile">Import code owners from other OWNERS file
 
@@ -232,9 +259,8 @@
 \
 **NOTE:** The `per-file` line from `java/com/example/foo/OWNERS` is not
 imported, since the [file](backend-find-owners.html#fileKeyword) keyword only
-imports non-restricted code owners. If also `per-line` files should be imported
-the [include](backend-find-owners.html#includeKeyword) keyword can be used
-instead:
+imports folder code owners. If also `per-line` lines should be imported the
+[include](backend-find-owners.html#includeKeyword) keyword can be used instead:
 
 `javatests/com/example/foo/OWNERS`:
 ```
@@ -322,7 +348,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
@@ -339,8 +365,8 @@
 
 * an `OWNERS` file with only `set noparent` (ignores code owners from parent
   directories, but doesn't define code owners that should be used instead)
-* a `per-file` line with only `set noparent` (ignores non-restricted code owners
-  and code owners from parent directories, but doesn't define code owners that
+* a `per-file` line with only `set noparent` (ignores folder code owners and
+  code owners from parent directories, but doesn't define code owners that
   should be used instead)
 * no `OWNERS` file at root level
 
diff --git a/resources/Documentation/backend-find-owners.md b/resources/Documentation/backend-find-owners.md
index b0c0af6..a1af71d 100644
--- a/resources/Documentation/backend-find-owners.md
+++ b/resources/Documentation/backend-find-owners.md
@@ -14,7 +14,7 @@
 contains an `OWNERS` file that disables the inheritance of code owners from the
 parent directories via the [set noparent](#setNoparent) keyword).
 
-<a id="defaultCodeOwnerConfiguration">
+### <a id="defaultCodeOwnerConfiguration">
 Default code owners that apply to all branches can be defined in an `OWNERS`
 file in the root directory of the `refs/meta/config` branch. This `OWNERS` file
 is the parent of the root `OWNERS` files in all branches. This means if a root
@@ -88,9 +88,19 @@
 for consistency an alphabetical order is recommended.
 
 User emails that are added to `OWNERS` files must be resolvable in Gerrit. This
-means, there must be an active Gerrit account that has this email assigned,
-which is only the case if the user logged in at least once into the Gerrit
-WebUI (or if an administrator registered the user programatically).
+means:
+
+* there must be an active Gerrit account that has this email assigned,
+  which is only the case if the user logged in at least once into the Gerrit
+  WebUI (or if an administrator registered the user programatically)
+* the email is not ambiguous (the email belongs to exactly one active Gerrit
+  account)
+* the email has an allowed email domain (see [allowed email domain
+  configuration](config.html#pluginCodeOwnersAllowedEmailDomain)).
+
+##### <a id="nonResolvableCodeOwnersAreIgnored">
+**NOTE:** Non-resolvable code owners in submitted code owner configuration files
+are ignored.
 
 **NOTE:** In Gerrit the visibility of users is controlled via the
 [accounts.visibility](../../../Documentation/config-gerrit.html#accounts.visibility)
@@ -227,17 +237,18 @@
 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
   john.doe@example.com
   per-file docs.config,*.md=richard.roe@example.com
 ```
-\
+
 ##### <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 +279,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/build.md b/resources/Documentation/build.md
index a1469f4..38d44ed 100644
--- a/resources/Documentation/build.md
+++ b/resources/Documentation/build.md
@@ -10,18 +10,26 @@
 ```
   bazel build plugins/@PLUGIN@
 ```
-
+\
 The output is created in
 
 ```
   bazel-bin/plugins/@PLUGIN@/@PLUGIN@.jar
 ```
-
+\
 To execute the tests run:
 
 ```
   bazel test //plugins/@PLUGIN@/...
 ```
+\
+To measure the test coverage run:
+
+```
+  bazel coverage --test_output=all plugins/code-owners/... --coverage_report_generator=@bazel_tools//tools/test:coverage_report_generator --combined_report=lcov --instrumentation_filter="^//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/acceptance[/:],^//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/acceptance/testsuite[/:],^//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/api[/:],^//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/api/impl[/:],^//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/backend[/:],^//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/common[/:],,^//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/metrics[/:],^//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/restapi[/:],^//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/testing[/:],^//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/util[/:],^//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/validation[/:]" && genhtml -o . --ignore-errors source bazel-out/_coverage/_coverage_report.dat
+```
+\
+and then open the generated `index.html` in a browser.
 
 ---
 
diff --git a/resources/Documentation/config-faqs.md b/resources/Documentation/config-faqs.md
new file mode 100644
index 0000000..ab88bb7
--- /dev/null
+++ b/resources/Documentation/config-faqs.md
@@ -0,0 +1,230 @@
+# Config FAQ's
+
+* [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)
+* [How to avoid issues with code owner config files](#avoidIssuesWithCodeOwnerConfigs)
+* [How to investigate issues with code owner config files](#investigateIssuesWithCodeOwnerConfigs)
+* [How to investigate issues with the code owner suggestion](#investigateIssuesWithCodeOwnerSuggestion)
+* [How to define default code owners](#defineDefaultCodeOwners)
+* [How to setup code owner overrides](#setupOverrides)
+* [What's the best place to keep the global plugin
+  configuration](#globalPluginConfiguration)
+* [How to make unicode characters in file paths work?](#unicodeCharsInFilePaths)
+
+## <a id="updateCodeOwnersConfig">How to update the code-owners.config file for a project
+
+The project-level configuration of the `code-owners` plugin is done in the
+`code-owners.config` file that is stored in the `refs/meta/config` branch of a
+project. If it is not present, all configuration parameters are inherited from
+the parent projects or the global configuration.
+
+The `code-owners.config` file has the format of a Git config file (same as the
+`project.config` file).
+
+To update the `code-owners.config` file do (requires to be a project owner):
+
+* clone the repository
+* fetch and checkout the `refs/meta/config` branch (e.g. `git fetch origin
+  refs/meta/config && git checkout FETCH_HEAD`)
+* create or edit the `code-owners.config` file
+* commit the changes
+* push the newly created commit back to the `refs/meta/config` branch (e.g. `git
+  push origin HEAD:refs/meta/config`)
+
+Some of the configuration parameters can also be set via the [Update Code Owner
+Project Config REST endpoint](rest-api.html#update-code-owner-project-config).
+
+## <a id="checkIfEnabled">How to check if the code owners functionality is enabled for a project or branch
+
+To check if the code owners functionality is enabled for a single branch, use
+the [Get Code Owner Branch Config](rest-api.html#get-code-owner-branch-config)
+REST endpoint and inspect the
+[disabled](rest-api.html#code-owner-branch-config-info) field in the response
+(if it is not present, the code owners functionality is enabled).
+
+To check if the code owners functionality is enabled for a project or for
+multiple branches, use the [Get Code Owner Project
+Config](rest-api.html#get-code-owner-project-config) REST endpoint and inspect
+the [status](rest-api.html#code-owners-status-info) in the response (an empty
+status means that the code owners functionality is enabled for all branches of
+the project).
+
+You can invoke the REST endpoints via `curl` from the command-line or
+alternatively open the following URLs in a browser:\
+`https://<host>/projects/<project-name>/branches/<branch-name>/code_owners.branch_config`\
+`https://<host>/projects/<project-name>/code_owners.project_config`\
+(remember to URL-encode the project-name and branch-name)
+
+## <a id="avoidIssuesWithCodeOwnerConfigs">How to avoid issues with code owner config files
+
+To avoid issues with code owner config files it's highly recommended to keep the
+[validation](validation.html) of code owner config files that is performed on
+receive commits and submit enabled, as it prevents that issues are newly
+introduced to code owner config files. Whether this validation is enabled and
+whether code owner config files with new issues are rejected is controlled by
+the following configuration parameters:
+
+* [plugin.@PLUGIN@.enableValidationOnCommitReceived](config.html#pluginCodeOwnersEnableValidationOnCommitReceived)
+* [plugin.@PLUGIN@.enableValidationOnSubmit](config.html#pluginCodeOwnersEnableValidationOnSubmit)
+* [plugin.@PLUGIN@.rejectNonResolvableCodeOwners](config.html#pluginCodeOwnersRejectNonResolvableCodeOwners)
+* [plugin.@PLUGIN@.rejectNonResolvableImports](config.html#pluginCodeOwnersRejectNonResolvableImports)
+
+Since code owner config files can also get
+[issues](validation.html#howCodeOwnerConfigsCanGetIssuesAfterSubmit) after they
+have been submitted, host administrators and project owners are also recommended
+to regularly check the existing code owner config files for issues by calling
+the [Check Code Owner Config File REST
+endpoint](rest-api.html#check-code-owner-config-files) (e.g. from a cronjob) and
+then fix the reported issues.
+
+## <a id="investigateIssuesWithCodeOwnerConfigs">How to investigate issues with code owner config files
+
+If code owners config files are not working as expected, this is either caused
+by:
+
+* issues in the code owner config files
+* a bug in the @PLUGIN@ plugin
+
+Since code owner config files are part of the source code, any issues with them
+should be investigated and fixed by the project owners and host administrators.
+
+To do this they can:
+
+* Check the code owner config files for issues by calling the [Check Code Owner
+  Config File REST endpoint](rest-api.html#check-code-owner-config-files)
+* Check the code ownership of a user for a certain path by calling the [Check
+  Code Owner REST endpoint](rest-api.html#check-code-owner) (requires the caller
+  to be host administrator or have the [Check Code Owner
+  capability](rest-api.html#checkCodeOwner)).
+
+Bugs with the @PLUGIN@ plugin should be filed as issues for the Gerrit team, but
+only after issues with the code owner config files have been excluded.
+
+Also see [above](#avoidIssuesWithCodeOwnerConfigs) how to avoid issues with code
+owner config files in the first place.
+
+## <a id="investigateIssuesWithCodeOwnerSuggestion">How to investigate issues with the code owner suggestion
+
+If the code owners config suggestion is not working as expected, this is either
+caused by:
+
+* issues in the code owner config files
+* user permissions
+* account visibility
+* account states
+* a bug in the @PLUGIN@ plugin
+
+Issues with code owner config files, user permissions, account visibility and
+account states should be investigated and fixed by the project owners and host
+administrators.
+
+To do this they can:
+
+* Use the `--debug` option of the [List Code
+  Owners](rest-api.html#list-code-owners-for-path-in-branch) REST endpoints to
+  get debug logs included into the response.
+* Check the code owner config files for issues by calling the [Check Code Owner
+  Config File REST endpoint](rest-api.html#check-code-owner-config-files)
+* Check the code ownership of a user for a certain path by calling the [Check
+  Code Owner REST endpoint](rest-api.html#check-code-owner) (requires the caller
+  to be host administrator or have the [Check Code Owner
+  capability](rest-api.html#checkCodeOwner))
+
+Bugs with the @PLUGIN@ plugin should be filed as issues for the Gerrit team, but
+only after other causes have been excluded.
+
+Also see [above](#avoidIssuesWithCodeOwnerConfigs) how to avoid issues with code
+owner config files in the first place.
+
+## <a id="defineDefaultCodeOwners">How to define default code owners
+
+[Default code owners](backend-find-owners.html#defaultCodeOwnerConfiguration)
+that apply to all branches can be defined in an `OWNERS` file in the root
+directory of the `refs/meta/config` branch.
+
+To add an `OWNERS` file in the `refs/meta/config` branch do (requires to be a
+project owner):
+
+* clone the repository
+* fetch and checkout the `refs/meta/config` branch (e.g. `git fetch origin
+  refs/meta/config && git checkout FETCH_HEAD`)
+* create or edit the `OWNERS` file
+* commit the changes
+* push the newly created commit back to the `refs/meta/config` branch (e.g. `git
+  push origin HEAD:refs/meta/config`)
+
+## <a id="setupOverrides">How to setup code owner overrides
+
+To setup code owner overrides do:
+
+### 1. Define a label that should count as code owner override:
+
+Create a [review label](../../../Documentation/config-labels.html)
+via the [Create Label REST
+endpoint](../../../Documentation/rest-api-projects.html#create-label):
+
+```
+  curl -X PUT -d '{"commit_message": "Create Owners-Override Label", "values": {" 0": "No Override", "+1": "Override"}}' --header "Content-Type: application/json" https://<gerrit-host>/a/projects/<project-name>/labels/Owners-Override
+```
+
+### 2. Configure this label as override approval:
+
+Configure the override label via the [Update Code Owner Project Config REST
+endpoint](rest-api.html#update-code-owner-project-config):
+
+```
+  curl -X PUT -d '{"override_approvals": ["Owners-Override+1"]}' --header "Content-Type: application/json" https://<gerrit-host>/a/projects/<project-name>/code_owners.project_config
+```
+\
+Also see the description of the
+[override_approval](config.html#codeOwnersOverrideApproval) configuration
+parameter.
+
+### 3. Assign permissions to vote on the override approval:
+
+Go to the access screen of your project in the Gerrit web UI and assign
+permissions to vote on the override label.
+
+Alternatively the permissions can also be assigned via the [Set Access REST
+endpoint](../../../Documentation/rest-api-projects.html#set-access).
+
+## <a id="globalPluginConfiguration">What's the best place to keep the global plugin configuration
+
+The global plugin configuration can either be maintained in the
+[gerrit.config](config.html) file or in the
+[code-owners.config](config.html#projectLevelConfigFile) file in the
+`refs/meta/config` branch of the `All-Projects` project. From the perspective of
+the code-owners plugin both places are equally good. However which place is
+preferred can depend on the system setup, e.g. changes to `gerrit.config` may be
+harder to do and require a multi-day rollout, whereas changes of the
+`All-Projects` configuration can be done through the [REST
+API](rest-api.html#update-code-owner-project-config) and are always instant
+(this can also be a disadvantage as it means that also bad config changes are
+effective immediately).
+
+**NOTE:** Any configuration that is done in `All-Projects` overrides the
+corresponding configuration that is inherited from `gerrit.config`.
+
+**NOTE:** There are a few configuration parameters (e.g. for [allowed email
+domains](config.html#pluginCodeOwnersAllowedEmailDomain)) that cannot be set on
+project level and hence must be set in `gerrit.config`.
+
+## <a id="unicodeCharsInFilePaths">How to make unicode characters in file paths work?
+
+The @PLUGIN@ plugin uses the Java NIO API which reads the default character
+encoding from the system language settings. On Unix this means the `LANG` and
+`LC_CTYPE` environment variables (setting one of them is sufficent). To enable
+unicode characters in file paths e.g. set: `LANG=en_US.UTF-8`
+
+If paths are used that are not valid according to the system language setting
+(e.g. if a path contains unicode characters but `LANG` is `en_US.iso88591`)
+the Java NIO API throws a `java.nio.file.InvalidPathException` with the message
+`Malformed input or input contains unmappable characters`. If such an exception
+occurs code-owner requests return `409 Conflict`, telling the user about the
+invalid path.
+
+---
+
+Back to [@PLUGIN@ documentation index](index.html)
+
+Part of [Gerrit Code Review](../../../Documentation/index.html)
diff --git a/resources/Documentation/config-guide.md b/resources/Documentation/config-guide.md
index 8a55a6c..86b447f 100644
--- a/resources/Documentation/config-guide.md
+++ b/resources/Documentation/config-guide.md
@@ -6,6 +6,8 @@
 recommendations for the configuration, but doesn't cover all configuration
 parameters.
 
+Please also check out the [config FAQs](config-faqs.html).
+
 ## <a id="requiredConfiguration">Required Configuration
 
 **Before** installing/enabling the plugin, or enabling the code owners
@@ -31,16 +33,17 @@
 ### <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.
+of code owners on their own changes. If enabled, changes of code owners are
+automatically code owner approved, but only if the last patch set was uploaded
+by the change owner (change owner == last patch set uploader). This implict code
+owner approval covers all files that are owned by the change owner. This means
+if a code owner uploads a change 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.
+is always implicitly approved by the change owner.
 
 **NOTE:** If implicit approvals are disabled, users can still self-approve their
 own changes by voting on the required label.
@@ -104,13 +107,14 @@
    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.\
+   are stored in the code owner config file (e.g. the `OWNERS` file) in the
+   `refs/meta/config` branch and apply for all branches (unless inheritance is
+   ignored).\
+   The same as root code owners, default code owners are experienced developers
+   that can approve changes to all the code base if needed.\
+   However in contrast to root code owners, default code owners 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.
@@ -118,7 +122,8 @@
    [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.\
+   all or multiple projects. Alternatively bots may be configured as exempted
+   users (see further below).\
    Global code owners still apply if parent code owners are ignored.
 5. Fallback code owners:
    [Fallback code owners](config.html#pluginCodeOwnersFallbackCodeOwners) is a
@@ -127,6 +132,13 @@
    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).
+6. Exempted users:
+   [Exempted users](config.html#pluginCodeOwnersExemptedUser) are exempted from
+   requiring code owner approvals.\
+   If a user is exempted from requiring code owner approvals changes that are
+   uploaded by this user are automatically code-owner approved.\
+   Exempted users are intended to be used for bots that need to create changes
+   on all or multiple projects that should not require code owner approvals.
 
 In addition users can be allowed to [override the code owner submit
 check](user-guide.html#codeOwnerOverride). This permission is normally granted
@@ -176,11 +188,16 @@
 ### <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
+code owners are aware of their implicit approval when they upload new changes
+for other users.
+
+Example:
+
+* 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 change owner.
+
+To avoid situations like this it is recommended to not enable implicit
 approvals.
 
 ### <a id="securityMergeCommits">Required code owner approvals on merge commits
diff --git a/resources/Documentation/config.md b/resources/Documentation/config.md
index 28c65b9..90e4e5c 100644
--- a/resources/Documentation/config.md
+++ b/resources/Documentation/config.md
@@ -1,17 +1,109 @@
 # Configuration
 
-The global configuration of the @PLUGIN@ plugin is stored in the `gerrit.config`
-file in the `plugin.@PLUGIN@` subsection.
+The @PLUGIN@ plugin can be configured on host and on project-level:
+
+* host-level:\
+  in the `gerrit.config` file in the `plugin.@PLUGIN@` subsection
+* project-level:\
+  in `@PLUGIN@.config` files that are stored in the `refs/meta/config` branches
+  of the projects
 
 This page describes all available configuration parameters. For configuration
-recommendations please consult the [config guide](#config-guide.html).
+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
-the projects.
+**NOTE**: This is the configuration that controls the behavior of the @PLUGIN@
+plugin. Code owners (except global code owners) are not defined here, but in
+[code owner config files](user-guide.html#codeOwnerConfigFiles) (e.g. `OWNERS`
+files) that are stored in the source tree of the repository.
 
-Parameters that are not set for a project are inherited from the parent project.
+## <a id="inheritance">Inheritance</a>
+
+Projects inherit the configuration of their parent projects, following the chain
+of parent projects until the `All-Projects` root project is reached which
+inherits the configuration from `gerrit.config`.
+
+Setting a single-value configuration parameter (single string, boolean, enum,
+int, long) for a project overrides any inherited value for this configuration
+parameter.
+
+Example for single-value configuration parameters:
+
+parent `code-owners.config`:
+```
+  [codeOwners]
+    readOnly = true
+    overrideInfoUrl = https://owners-overrides.example.com
+    exemptPureReverts = true
+```
+\
+project `code-owners.config`:
+```
+  [codeOwners]
+    readOnly = false
+    overrideInfoUrl = https://foo.example.com/owners-overrides
+    fileExtension = fork
+```
+\
+effective configuration:
+```
+  [code-owners]
+    readOnly = false
+    overrideInfoUrl = https://foo.example.com/owners-overrides
+    fileExtension = fork
+    exemptPureReverts = true
+```
+\
+In contrast to this, if a value for a multi-value / list configuration parameter
+is set, the value is added to the inherited value list (the inherited value list
+is extended, not overridden). Overriding/unsetting an inherited value list is
+not possible.
+
+Example for multi-value / list configuration parameters:
+
+parent `code-owners.config`:
+```
+  [codeOwners]
+    globalCodeOwner = bot-foo@example.com
+    globalCodeOwner = bot-bar@example.com
+    exemptedUser = bot-abc@example.com
+    exemptedUser = bot-xyz@example.com
+    disabledBranch = refs/meta/config
+```
+\
+project `code-owners.config`:
+```
+  [codeOwners]
+    globalCodeOwner = bot-baz@example.com
+    disabledBranch =
+```
+\
+effective configuration:
+```
+  [code-owners]
+    globalCodeOwner = bot-foo@example.com
+    globalCodeOwner = bot-bar@example.com
+    globalCodeOwner = bot-baz@example.com
+    exemptedUser = bot-abc@example.com
+    exemptedUser = bot-xyz@example.com
+    disabledBranch = refs/meta/config
+```
+
+## <a id="staleIndexOnConfigChanges">
+**NOTE:** Some configuration changes can lead to changes becoming stale in the
+change index. E.g. if an additional branch is newly exempted in `gerrit.config`
+or in the `code-owners.config` of a parent project, the submittability of
+changes for that branch in child projects may change (since they no longer
+require code owner approvals), but it's not feasible to reindex all affected
+changes when this config change is done (as config changes can potentially
+affect all open changes on the host and reindexing that many changes would be
+too expensive). In this case the affected changes will be become stale in the
+change index (e.g. the change index contains outdated submit records) and as a
+result of this you may not observe the effects of the config change on all
+changes immediately, but only when they have been reindexed (which happens on
+any modification of the changes). If needed, you may force the reindexing of a
+change by calling the [Index
+Changes](../../../Documentation/rest-api-changes.html#index-change) REST
+endpoint or by touching the change (e.g. by adding a comment).
 
 # <a id="globalConfiguration">Global configuration in gerrit.config</a>
 
@@ -35,8 +127,8 @@
         approvals.\
         This allows branches to opt-out of the code owners functionality.\
         Can be set multiple times.\
-        Can be overridden per project by setting
-        [codeOwners.disabledBrancg](#codeOwnersDisabledBranch) in
+        The configured value list can be extended on project-level by setting
+        [codeOwners.disabledBranch](#codeOwnersDisabledBranch) in
         `@PLUGIN@.config`.\
         By default unset.
 
@@ -74,29 +166,55 @@
         The frontend displays a link to this page on the change screen so that
         users can discover the override instructions easily.\
         Can be overridden per project by setting
-        [codeOwners.overrideInfoUrl](#codeOwnersFileExtension) in
+        [codeOwners.overrideInfoUrl](#codeOwnersOverrideInfoUrl) in
         `@PLUGIN@.config`.\
         By default unset (no override info URL).
 
-<a id="pluginCodeOwnersEnableImplicitApprovals">plugin.@PLUGIN@.enableImplictApprovals</a>
-:       Whether an implicit code owner approval from the last uploader is
+<a id="pluginCodeOwnersInvalidCodeOwnerConfigInfoUrl">plugin.@PLUGIN@.invalidCodeOwnerConfigInfoUrl</a>
+:       A URL for a page that provides host-specific information about how to
+        deal with invalid code owner config files.\
+        This URL is included into error messages that indicate invalid code
+        owner config files.\
+        Can be overridden per project by setting
+        [codeOwners.invalidCodeOwnerConfigInfoUrl](#codeOwnersInvalidCodeOwnerConfigInfoUrl)
+        in `@PLUGIN@.config`.\
+        By default unset (no invalid code owner config info URL).
+
+<a id="pluginCodeOwnersEnableImplicitApprovals">plugin.@PLUGIN@.enableImplicitApprovals</a>
+:       Whether an implicit code owner approval from the change owner 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
+        Implicit code owner approvals are only applied on patch sets that are
+        uploaded by the change owner (change owner == last patch set uploader).\
+        \
+        Can be `FALSE`, `TRUE` or `FORCED`.\
+        \
+        `FALSE`:\
+        Implicit code-owner approvals of change owners are disabled.\
+        \
+        `TRUE`:\
+        Implicit code-owner approvals of change owners are enabled, but only if
+        the configured [required label](#pluginCodeOwnersRequiredApproval) is
+        not 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
-        onto the correct branch, the rebased change has implicit approvals from
-        the code owner, since the code owner is the uploader).\
+        \
+        `FORCED`:\
+        Implicit code-owner approvals of change owners are enabled, even if the
+        configured [required label](#pluginCodeOwnersRequiredApproval) disallows
+        self approvals.\
+        \
+        If enabled/enforced, code owners need to be aware of their implicit
+        approval when they upload new changes 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
+        change owner).\
         If implicit code owner approvals are disabled, code owners can still
         self-approve their own changes by voting on the change.\
         Can be overridden per project by setting
-        [codeOwners.enableImplictApprovals](#codeOwnersEnableImplicitApprovals)
+        [codeOwners.enableImplicitApprovals](#codeOwnersEnableImplicitApprovals)
         in `@PLUGIN@.config`.\
-        By default `false`.
+        By default `FALSE`.
 
 <a id="pluginCodeOwnersGlobalCodeOwner">plugin.@PLUGIN@.globalCodeOwner</a>
 :       The email of a user that should be a code owner globally across all
@@ -105,12 +223,23 @@
         (e.g. because they are bots and cannot react to review requests), they
         can be added to the `Service Users` group (since members of this group
         are not suggested as code owners).\
-        Can be specified multiple time to set multiple global code owners.\
-        Can be overridden per project by setting
+        Can be specified multiple times to set multiple global code owners.\
+        The configured value list can be extended on project-level by setting
         [codeOwners.globalCodeOwner](#codeOwnersGlobalCodeOwner) in
         `@PLUGIN@.config`.\
         By default unset (no global code owners).
 
+<a id="pluginCodeOwnersExemptedUser">plugin.@PLUGIN@.exemptedUser</a>
+:       The email of a user that should be exempted from requiring code owner
+        approvals.\
+        If a user is exempted from requiring code owner approvals changes that
+        are uploaded by this user are automatically code-owner approved.\
+        Can be specified multiple times to exempt multiple users.\
+        The configured value list can be extended on project-level by setting
+        [codeOwners.exemptedUser](#codeOwnersExemptedUser) in
+        `@PLUGIN@.config`.\
+        By default unset (no exempted users).
+
 <a id="pluginCodeOwnersReadOnly">plugin.@PLUGIN@.readOnly</a>
 :       Whether code owner config files are read-only.\
         Can be overridden per project by setting
@@ -118,14 +247,52 @@
         `@PLUGIN@.config`.\
         By default `false`.
 
+<a id="pluginCodeOwnersExemptPureReverts">plugin.@PLUGIN@.exemptPureReverts</a>
+:       Whether pure revert changes are exempted from needing code owner
+        approvals for submit.\
+        Only works for pure reverts which have been created through the Gerrit
+        [REST API](../../../Documentation/rest-api-change.html#revert-change)
+        (but not for pure reverts which were done locally and then pushed to
+        Gerrit).\
+        Can be overridden per project by setting
+        [codeOwners.exemptPureReverts](#codeOwnersExemptPureReverts) in
+        `@PLUGIN@.config`.\
+        By default `false`.
+
 <a id="pluginCodeOwnersEnableValidationOnCommitReceived">plugin.@PLUGIN@.enableValidationOnCommitReceived</a>
 :       Policy for validating code owner config files when a commit is received.
-        Allowed values are `true` (the code owner config file validation is
-        enabled and the upload 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).\
+        \
+        Can be `TRUE`, `FALSE`, `DRY_RUN`, `FORCED` or `FORCED_DRY_RUN`.\
+        \
+        `TRUE`:\
+        The code owner config file validation is enabled and the upload of
+        invalid code owner config files is rejected.\
+        If the code owners functionality is disabled, no validation is
+        performed.\
+        \
+        `FALSE`:\
+        The code owner config file validation is disabled, invalid code owner
+        config files are not rejected.\
+        \
+        `DRY_RUN`:\
+        Code owner config files are validated, but invalid code owner config
+        files are not rejected.\
+        If the code owners functionality is disabled, no dry-run validation is
+        performed.\
+        \
+        `FORCED`:\
+        Code owner config files are validated even if the code owners
+        functionality is disabled.\
+        This option is useful when the code owner config validation should be
+        enabled as preparation to enabling the code owners functionality.\
+        \
+        `FORCED_DRY_RUN`:\
+        Code owner config files are validated even if the code owners
+        functionality is disabled, but invalid code owner config files are not
+        rejected.\
+        This option is useful when the code owner config validation should be
+        enabled as preparation to enabling the code owners functionality.\
+        \
         Should only be disabled if there is bot that validates the code owner
         config files in open changes as part of a pre-submit validation.\
         Can be overridden per project by setting
@@ -135,16 +302,74 @@
 
 <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).\
+        submitted.
+        \
+        Can be `TRUE`, `FALSE`, `DRY_RUN`, `FORCED` or `FORCED_DRY_RUN`.\
+        \
+        `TRUE`:\
+        The code owner config file validation is enabled and the submission of
+        invalid code owner config files is rejected.\
+        If the code owners functionality is disabled, no validation is
+        performed.\
+        \
+        `FALSE`:\
+        The code owner config file validation is disabled, invalid code owner
+        config files are not rejected.\
+        \
+        `DRY_RUN`:\
+        Code owner config files are validated, but invalid code owner config
+        files are not rejected.\
+        If the code owners functionality is disabled, no dry-run validation is
+        performed.\
+        \
+        `FORCED`:\
+        Code owner config files are validated even if the code owners
+        functionality is disabled.\
+        This option is useful when the code owner config validation should be
+        enabled as preparation to enabling the code owners functionality.\
+        \
+        `FORCED_DRY_RUN`:\
+        Code owner config files are validated even if the code owners
+        functionality is disabled, but invalid code owner config files are not
+        rejected.\
+        This option is useful when the code owner config validation should be
+        enabled as preparation to enabling the code owners functionality.\
+        \
         Disabling the submit validation is not recommended.\
         Can be overridden per project by setting
         [codeOwners.enableValidationOnSubmit](#codeOwnersEnableValidationOnSubmit)
         in `@PLUGIN@.config`.\
+        By default `false`.
+
+<a id="pluginCodeOwnersRejectNonResolvableCodeOwners">plugin.@PLUGIN@.rejectNonResolvableCodeOwners</a>
+:       Whether modifications of code owner config files that newly add
+        non-resolvable code owners should be rejected on commit received and
+        submit.\
+        If `true` newly added non-resolveable code owners are reported as errors
+        and the commit is rejected.\
+        If `false` newly added non-resolvable code owners are only reported as
+        warnings and the commit is not rejected.\
+        This setting has no effect if the validation is disabled via
+        [enableValidationOnCommitReceived](#pluginCodeOwnersEnableValidationOnCommitReceived)
+        or [enableValidationOnSubmit](#pluginCodeOwnersEnableValidationOnSubmit).
+        Can be overridden per project by setting
+        [codeOwners.rejectNonResolvableCodeOwners](#codeOwnersRejectNonResolvableCodeOwners)
+        in `@PLUGIN@.config`.\
+        By default `true`.
+
+<a id="pluginCodeOwnersRejectNonResolvableImports">plugin.@PLUGIN@.rejectNonResolvableImports</a>
+:       Whether modifications of code owner config files that newly add
+        non-resolvable imports should be rejected on commit received an submit.\
+        If `true` newly added non-resolveable imports are reported as errors and
+        the commit is rejected.\
+        If `false` newly added non-resolvable imports are only reported as
+        warnings and the commit is not rejected.\
+        This setting has no effect if the validation is disabled via
+        [enableValidationOnCommitReceived](#pluginCodeOwnersEnableValidationOnCommitReceived)
+        or [enableValidationOnSubmit](#pluginCodeOwnersEnableValidationOnSubmit).
+        Can be overridden per project by setting
+        [codeOwners.rejectNonResolvableImports](#codeOwnersRejectNonResolvableImports)
+        in `@PLUGIN@.config`.\
         By default `true`.
 
 <a id="pluginCodeOwnersAllowedEmailDomain">plugin.@PLUGIN@.allowedEmailDomain</a>
@@ -198,7 +423,7 @@
         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
+        The configured value list can be extended on project-level by setting
         [codeOwners.overrideApproval](#codeOwnersOverrideApproval) in
         `@PLUGIN@.config`.\
         By default unset which means that the override functionality is
@@ -260,13 +485,18 @@
         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`.\
+        Can be `NONE`, `PROJECT_OWNERS` 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.\
         \
+        `PROJECT_OWNERS`:\
+        Paths for which no code owners are defined are owned by the project
+        owners. This means changes to these paths can be approved by the project
+        owners.\
+        \
         `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
@@ -278,11 +508,44 @@
         this option is intended to be only used if requiring code owner
         approvals should not be enforced.\
         \
+        Please note that fallback code owners are not suggested as code owners
+        in the UI.
+        \
         Can be overridden per project by setting
         [codeOwners.fallbackCodeOwners](#codeOwnersFallbackCodeOwners) in
         `@PLUGIN@.config`.\
         By default `NONE`.
 
+<a id="pluginCodeOwnersMaxPathsInChangeMessages">plugin.@PLUGIN@.maxPathsInChangeMessages</a>
+:       The @PLUGIN@ plugin lists owned paths in change messages when:
+        \
+        1. A code owner 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.\
+        \
+        2. A code owner is added as reviewer:\
+        The paths that are owned by the reviewer are posted as a change
+        message.\
+        \
+        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 owned paths into change
+        messages.\
+        Can be overridden per project by setting
+        [codeOwners.maxPathsInChangeMessages](#codeOwnersMaxPathsInChangeMessages)
+        in `@PLUGIN@.config`.\
+        By default `50`.
+
+<a id="pluginCodeOwnersMaxCodeOwnerConfigCacheSize">plugin.@PLUGIN@.maxCodeOwnerConfigCacheSize</a>
+:       When computing code owner file statuses for a change (e.g. to compute
+        the results for the code owners submit rule) parsed code owner config
+        files are cached in memory for the time of the request.\
+        This configuration parameter allows to set a limit for the number of
+        code owner config files that are cached per request.\
+        By default `10000`.
+
 # <a id="projectConfiguration">Project configuration in @PLUGIN@.config</a>
 
 <a id="codeOwnersDisabled">codeOwners.disabled</a>
@@ -291,7 +554,8 @@
         This allows projects to opt-out of the code owners functionality.\
         Overrides the global setting
         [plugin.@PLUGIN@.disabled](#pluginCodeOwnersDisabled) in
-        `gerrit.config`.\
+        `gerrit.config` and the `codeOwners.disabled` setting from parent
+        projects.\
         By default `false`.
 
 <a id="codeOwnersDisabledBranch">codeOwners.disabledBranch</a>
@@ -305,12 +569,17 @@
         approvals.\
         This allows branches to opt-out of the code owners functionality.\
         Can be set multiple times.\
+        Extends the global setting
+        [plugin.@PLUGIN@.disabledBranch](#pluginCodeOwnersDisabledBranch) in
+        `gerrit.config` and the `codeOwners.disabledBranch` setting from parent
+        projects.\
         By default unset.
 
 <a id="codeOwnersBackend">codeOwners.backend</a>
 :       The code owners backend that should be used for the project.\
         Overrides the global setting
-        [plugin.@PLUGIN@.backend](#pluginCodeOwnersBackend) in `gerrit.config`.\
+        [plugin.@PLUGIN@.backend](#pluginCodeOwnersBackend) in `gerrit.config`
+        and the `codeOwners.backend` setting from parent projects.\
         Can be overridden per branch by setting
         [codeOwners.\<branch\>.backend](#codeOwnersBranchBackend).\
         The supported code owner backends are listed at the
@@ -331,7 +600,8 @@
         The branch can be the short or full name. If both configurations exist
         the one for the full name takes precedence.\
         Overrides the per repository setting
-        [codeOwners.backend](#codeOwnersBackend).\
+        [codeOwners.backend](#codeOwnersBackend) and the
+        `codeOwners.\<branch\>.backend` setting from parent projects.\
         The supported code owner backends are listed at the
         [Backends](backends.html) page.\
         If not set, the project level configuration
@@ -354,7 +624,8 @@
         files are ignored.\
         Overrides the global setting
         [plugin.@PLUGIN@.fileExtension](#pluginCodeOwnersFileExtension) in
-        `gerrit.config`.\
+        `gerrit.config` and the `codeOwners.fileExtension` setting from parent
+        projects.\
         If not set, the global setting
         [plugin.@PLUGIN@.fileExtension](#pluginCodeOwnersFileExtension) in
         `gerrit.config` is used.
@@ -366,29 +637,45 @@
         users can discover the override instructions easily.\
         Overrides the global setting
         [plugin.@PLUGIN@.overrideInfoUrl](#pluginCodeOwnersOverrideInfoUrl) in
-        `gerrit.config`.\
+        `gerrit.config` and the `codeOwners.overrideInfoUrl` setting from parent
+        projects.\
         If not set, the global setting
         [plugin.@PLUGIN@.overrideInfoUrl](#pluginCodeOwnersOverrideInfoUrl) in
         `gerrit.config` is used.
 
+<a id="codeOwnersInvalidCodeOwnerConfigInfoUrl">codeOwners.invalidCodeOwnerConfigInfoUrl</a>
+:       A URL for a page that provides project-specific information about how
+        to deal with invalid code owner config files.\
+        This URL is included into error messages that indicate invalid code
+        owner config files.\
+        Overrides the global setting
+        [plugin.@PLUGIN@.invalidCodeOwnerConfigInfoUrl](#pluginCodeOwnersInvalidCodeOwnerConfigInfoUrl)
+        in `gerrit.config` and the `codeOwners.invalidCodeOwnerConfigInfoUrl`
+        setting from parent projects.\
+        If not set, the global setting
+        [plugin.@PLUGIN@.invalidCodeOwnerConfigInfoUrl](#pluginCodeOwnersInvalidCodeOwnerConfigInfoUrl)
+        in `gerrit.config` is used.
+
 <a id="codeOwnersEnableImplicitApprovals">codeOwners.enableImplicitApprovals</a>
-:       Whether an implicit code owner approval from the last uploader is
+:       Whether an implicit code owner approval from the change owner 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
-        onto the correct branch, the rebased change has implicit approvals from
-        the code owner, since the code owner is the uploader).\
+        Implicit code owner approvals are only applied on patch sets that are
+        uploaded by the change owner (change owner == last patch set uploader).\
+        Can be `FALSE`, `TRUE` or `FORCED` (see
+        [plugin.@PLUGIN@.enableImplicitApprovals](#pluginCodeOwnersEnableImplicitApprovals)
+        for an explanation of these values).\
+        If enabled/enforced, code owners need to be aware of their implicit
+        approval when they upload new changes 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
+        change owner).\
         If implicit code owner approvals are disabled, code owners can still
         self-approve their own changes by voting on the change.\
         Overrides the global setting
         [plugin.@PLUGIN@.enableImplicitApprovals](#pluginCodeOwnersenableImplicitApprovals)
-        in `gerrit.config`.\
+        in `gerrit.config` and the `codeOwners.enableImplicitApprovals` setting
+        from parent projects.\
         If not set, the global setting
         [plugin.@PLUGIN@.enableImplicitApprovals](#pluginCodeOwnersenableImplicitApprovals)
         in `gerrit.config` is used.
@@ -400,56 +687,188 @@
         (e.g. because they are bots and cannot react to review requests), they
         can be added to the `Service Users` group (since members of this group
         are not suggested as code owners).\
-        Can be specified multiple time to set multiple global code owners.\
-        Overrides the global setting
+        Can be specified multiple times to set multiple global code owners.\
+        Extends the global setting
         [plugin.@PLUGIN@.globalCodeOwner](#pluginCodeOwnersGlobalCodeOwner) in
-        `gerrit.config`.\
+        `gerrit.config` and the `codeOwners.globalCodeOwner` setting from parent
+        projects.\
         If not set, the global setting
         [plugin.@PLUGIN@.globalCodeOwner](#pluginCodeOwnersGlobalCodeOwner) in
         `gerrit.config` is used.
 
+<a id="codeOwnersExemptedUser">codeOwners.exemptedUser</a>
+:       The email of a user that should be exempted from requiring code owner
+        approvals.\
+        If a user is exempted from requiring code owner approvals changes that
+        are uploaded by this user are automatically code-owner approved.\
+        Can be specified multiple times to exempt multiple users.\
+        Extends the global setting
+        [plugin.@PLUGIN@.exemptedUser](#pluginCodeOwnersExemptedUser) in
+        `gerrit.config` and the `codeOwners.exemptedUser` setting from parent
+        projects.\
+        If not set, the global setting
+        [plugin.@PLUGIN@.exemptedUser](#pluginCodeOwnersExemptedUser) in
+        `gerrit.config` is used.
+
 <a id="codeOwnersReadOnly">codeOwners.readOnly</a>
 :       Whether code owner config files are read-only.\
         Overrides the global setting
         [plugin.@PLUGIN@.readOnly](#pluginCodeOwnersReadOnly) in
-        `gerrit.config`.\
+        `gerrit.config` and the `codeOwners.readOnly` setting from parent
+        projects.\
         If not set, the global setting
         [plugin.@PLUGIN@.readOnly](#pluginCodeOwnersReadOnly) in
         `gerrit.config` is used.
 
+<a id="codeOwnersExemptPureReverts">codeOwners.exemptPureReverts</a>
+:       Whether pure revert changes are exempted from needing code owner
+        approvals for submit.\
+        Only works for pure reverts which have been created through the Gerrit
+        [REST API](../../../Documentation/rest-api-change.html#revert-change)
+        (but not for pure reverts which were done locally and then pushed to
+        Gerrit).\
+        Overrides the global setting
+        [plugin.@PLUGIN@.exemptPureReverts](#pluginCodeOwnersExemptPureReverts)
+        in `gerrit.config` and the `codeOwners.exemptPureReverts` setting from
+        parent projects.\
+        If not set, the global setting
+        [plugin.@PLUGIN@.exemptPureReverts](#pluginCodeOwnersExemptPureReverts)
+        in `gerrit.config` is used.
+
 <a id="codeOwnersEnableValidationOnCommitReceived">codeOwners.enableValidationOnCommitReceived</a>
-:       Policy for validating code owner config files when a commit is received.
-        Allowed values are `true` (the code owner config file validation is
-        enabled and the upload 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).\
+:       Policy for validating code owner config files when a commit is
+        received.\
+        Can be `TRUE`, `FALSE`, `DRY_RUN`, `FORCED` or `FORCED_DRY_RUN`. For a
+        description of the values see
+        [plugin.@PLUGIN@.enableValidationOnCommitReceived](#pluginCodeOwnersEnableValidationOnCommitReceived).\
         Should only be disabled if there is bot that validates the code owner
         config files in open changes as part of a pre-submit validation.\
         Overrides the global setting
         [plugin.@PLUGIN@.enableValidationOnCommitReceived](#pluginCodeOwnersEnableValidationOnCommitReceived)
-        in `gerrit.config`.\
+        in `gerrit.config` and the `codeOwners.enableValidationOnCommitReceived`
+        setting from parent projects.\
+        Can be overriden on branch-level by setting
+        [validation.\<branch\>.enableValidationOnCommitReceived](#validationBranchEnableValidationOnCommitReceived).\
         If not set, the global setting
         [plugin.@PLUGIN@.enableValidationOnCommitReceived](#pluginCodeOwnersEnableValidationOnCommitReceived)
         in `gerrit.config` is used.
 
+<a id="validationBranchEnableValidationOnCommitReceived">validation.\<branch\>.enableValidationOnCommitReceived</a>
+:       Branch-level policy for validating code owner config files when a commit
+        is received.\
+        Applies to all branches that are matched by `<branch>`, which can be
+        an exact ref name (e.g. `refs/heads/master`), a ref pattern (e.g.
+        `refs/heads/*`) or a regular expression (e.g. `^refs/heads/stable-.*`).\
+        If a branches matches multiple validation subsections it is undefined
+        which of the subsections takes precedence.\
+        Overrides the project-level configuration for validating code owner
+        config files when a commit is received that is configured by
+        [codeOwners.enableValidationOnCommitReceived](#codeOwnersEnableValidationOnCommitReceived).\
+        For further details see the description of
+        [codeOwners.enableValidationOnCommitReceived](#codeOwnersEnableValidationOnCommitReceived).
+
 <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).\
+        submitted.\
+        Can be `TRUE`, `FALSE`, `DRY_RUN`, `FORCED` or `FORCED_DRY_RUN`. For a
+        description of the values see
+        [plugin.@PLUGIN@.enableValidationOnSubmit](#pluginCodeOwnersEnableValidationOnSubmit).\
         Disabling the submit validation is not recommended.\
         Overrides the global setting
         [plugin.@PLUGIN@.enableValidationOnSubmit](#pluginCodeOwnersEnableValidationOnSubmit)
-        in `gerrit.config`.\
+        in `gerrit.config` and the `codeOwners.enableValidationOnSubmit` setting
+        from parent projects.\
+        Can be overriden on branch-level by setting
+        [validation.\<branch\>.enableValidationOnSubmit](#validationBranchEnableValidationOnSubmit).\
         If not set, the global setting
         [plugin.@PLUGIN@.enableValidationOnSubmit](#pluginCodeOwnersEnableValidationOnSubmit)
         in `gerrit.config` is used.
 
+<a id="validationBranchEnableValidationOnSubmit">validation.\<branch\>.enableValidationOnSubmit</a>
+:       Branch-level policy for validating code owner config files when a change
+        is submitted.\
+        Applies to all branches that are matched by `<branch>`, which can be
+        an exact ref name (e.g. `refs/heads/master`), a ref pattern (e.g.
+        `refs/heads/*`) or a regular expression (e.g. `^refs/heads/stable-.*`).\
+        If a branches matches multiple validation subsections it is undefined
+        which of the subsections takes precedence.\
+        Overrides the project-level configuration for validating code owner
+        config files when a change is submitted that is configured by
+        [codeOwners.enableValidationOnSubmit](#codeOwnersEnableValidationOnSubmit).\
+        For further details see the description of
+        [codeOwners.enableValidationOnSubmit](#codeOwnersEnableValidationOnSubmit).
+
+<a id="codeOwnersRejectNonResolvableCodeOwners">codeOwners.rejectNonResolvableCodeOwners</a>
+:       Whether modifications of code owner config files that newly add
+        non-resolvable code owners should be rejected on commit received and
+        submit.\
+        If `true` newly added non-resolveable code owners are reported as errors
+        and the commit is rejected.\
+        If `false` newly added non-resolvable code owners are only reported as
+        warnings and the commit is not rejected.\
+        This setting has no effect if the validation is disabled via
+        [enableValidationOnCommitReceived](#codeOwnersEnableValidationOnCommitReceived)
+        or [enableValidationOnSubmit](#codeOwnersEnableValidationOnSubmit).
+        Overrides the global setting
+        [plugin.@PLUGIN@.rejectNonResolvableCodeOwners](#pluginCodeOwnersRejectNonResolvableCodeOwners)
+        in `gerrit.config` and the `codeOwners.rejectNonResolvableCodeOwners`
+        setting from parent projects.\
+        Can be overriden on branch-level by setting
+        [validation.\<branch\>.rejectNonResolvableCodeOwners](#validationBranchRejectNonResolvableCodeOwners).\
+        If not set, the global setting
+        [plugin.@PLUGIN@.rejectNonResolvableCodeOwners](#pluginCodeOwnersRejectNonResolvableCodeOwners)
+        in `gerrit.config` is used.
+
+<a id="validationBranchRejectNonResolvableCodeOwners">validation.\<branch\>.rejectNonResolvableCodeOwners</a>
+:       Branch-level configuration to control whether modifications of code
+        owner config files that newly add non-resolvable code owners should be
+        rejected on commit received and submit.\
+        Applies to all branches that are matched by `<branch>`, which can be
+        an exact ref name (e.g. `refs/heads/master`), a ref pattern (e.g.
+        `refs/heads/*`) or a regular expression (e.g. `^refs/heads/stable-.*`).\
+        If a branches matches multiple validation subsections it is undefined
+        which of the subsections takes precedence.\
+        Overrides the project-level configuration for rejecting non-resolvable
+        code owners that is configured by
+        [codeOwners.rejectNonResolvableCodeOwners](#codeOwnersRejectNonResolvableCodeOwners).\
+        For further details see the description of
+        [codeOwners.rejectNonResolvableCodeOwners](#codeOwnersRejectNonResolvableCodeOwners).
+
+<a id="codeOwnersRejectNonResolvableImports">codeOwners.rejectNonResolvableImports</a>
+:       Whether modifications of code owner config files that newly add
+        non-resolvable imports should be rejected on commit received an submit.\
+        If `true` newly added non-resolveable imports are reported as errors and
+        the commit is rejected.\
+        If `false` newly added non-resolvable imports are only reported as
+        warnings and the commit is not rejected.\
+        This setting has no effect if the validation is disabled via
+        [enableValidationOnCommitReceived](#codeOwnersEnableValidationOnCommitReceived)
+        or [enableValidationOnSubmit](#codeOwnersEnableValidationOnSubmit).
+        Overrides the global setting
+        [plugin.@PLUGIN@.rejectNonResolvableImports](#pluginCodeOwnersRejectNonResolvableImports)
+        in `gerrit.config` and the `codeOwners.rejectNonResolvableImports`
+        setting from parent projects.\
+        Can be overriden on branch-level by setting
+        [validation.\<branch\>.rejectNonResolvableImports](#validationBranchRejectNonResolvableImports).\
+        If not set, the global setting
+        [plugin.@PLUGIN@.rejectNonResolvableImports](#pluginCodeOwnersRejectNonResolvableImports)
+        in `gerrit.config` is used.
+
+<a id="validationBranchRejectNonResolvableImports">validation.\<branch\>.rejectNonResolvableImports</a>
+:       Branch-level configuration to control whether modifications of code
+        owner config files that newly add non-resolvable imports should be
+        rejected on commit received and submit.\
+        Applies to all branches that are matched by `<branch>`, which can be
+        an exact ref name (e.g. `refs/heads/master`), a ref pattern (e.g.
+        `refs/heads/*`) or a regular expression (e.g. `^refs/heads/stable-.*`).\
+        If a branches matches multiple validation subsections it is undefined
+        which of the subsections takes precedence.\
+        Overrides the project-level configuration for rejecting non-resolvable
+        imports that is configured by
+        [codeOwners.rejectNonResolvableImports](#codeOwnersRejectNonResolvableImports).\
+        For further details see the description of
+        [codeOwners.rejectNonResolvableImports](#codeOwnersRejectNonResolvableImports).
+
 <a id="codeOwnersRequiredApproval">codeOwners.requiredApproval</a>
 :       Approval that is required from code owners to approve the files in a
         change.\
@@ -470,7 +889,8 @@
         owners check.\
         Overrides the global setting
         [plugin.@PLUGIN@.requiredApproval](#pluginCodeOwnersRequiredApproval) in
-        `gerrit.config`.\
+        `gerrit.config` and the `codeOwners.requiredApproval` setting from
+        parent projects.\
         If not set, the global setting
         [plugin.@PLUGIN@.requiredApproval](#pluginCodeOwnersRequiredApproval) in
         `gerrit.config` is used.
@@ -495,9 +915,10 @@
         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
+        Extends the global setting
         [plugin.@PLUGIN@.overrideApproval](#pluginCodeOwnersOverrideApproval) in
-        `gerrit.config`.\
+        `gerrit.config` and the `codeOwners.overrideApproval` setting from
+        parent projects.\
         If not set, the global setting
         [plugin.@PLUGIN@.overrideApproval](#pluginCodeOwnersOverrideApproval) in
         `gerrit.config` is used.
@@ -510,7 +931,8 @@
         for an explanation of these values).\
         Overrides the global setting
         [plugin.@PLUGIN@.mergeCommitStrategy](#pluginCodeOwnersMergeCommitStrategy)
-        in `gerrit.config`.\
+        in `gerrit.config` and the `codeOwners.mergeCommitStrategy` setting from
+        parent projects.\
         If not set, the global setting
         [plugin.@PLUGIN@.mergeCommitStrategy](#pluginCodeOwnersMergeCommitStrategy)
         in `gerrit.config` is used.
@@ -520,16 +942,42 @@
         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
+        Can be `NONE`, `PROJECT_OWNERS` 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`.\
+        in `gerrit.config` and the `codeOwners.fallbackCodeOwners` setting from
+        parent projects.\
         If not set, the global setting
         [plugin.@PLUGIN@.fallbackCodeOwners](#pluginCodeOwnersFallbackCodeOwners)
         in `gerrit.config` is used.
 
+<a id="codeOwnersMaxPathsInChangeMessages">codeOwners.maxPathsInChangeMessages</a>
+:       The @PLUGIN@ plugin lists owned paths in change messages when:
+        \
+        1. A code owner 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.\
+        \
+        2. A code owner is added as reviewer:\
+        The paths that are owned by the reviewer are posted as a change
+        message.\
+        \
+        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 owned paths into change
+        messages.\
+        Overrides the global setting
+        [plugin.@PLUGIN@.maxPathsInChangeMessages](#pluginCodeOwnersMaxPathsInChangeMessages)
+        in `gerrit.config` and the `codeOwners.maxPathsInChangeMessages` setting
+        from parent projects.\
+        If not set, the global setting
+        [plugin.@PLUGIN@.maxPathsInChangeMessages](#pluginCodeOwnersMaxPathsInChangeMessages)
+        in `gerrit.config` is used.
+
 ---
 
 Back to [@PLUGIN@ documentation index](index.html)
diff --git a/resources/Documentation/how-to-use.md b/resources/Documentation/how-to-use.md
index d412c2c..23337be 100644
--- a/resources/Documentation/how-to-use.md
+++ b/resources/Documentation/how-to-use.md
@@ -151,20 +151,25 @@
 
 ![no owners found](./no-owners-found.png "no owners found")
 
-- No owners were defined for these files.
+- No code owners were 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 code owners accounts are not visible to you.
 
+- None of the code owners can see the change.
+  Reason: The code owners have no read permission on the target branch of the
+  change and hence cannot approve the change.
+
 - Code owners defined for these files are invalid.
   Reason: The emails cannot be resolved.
 
-For these 3 cases, we advise you to:
+For these cases, we advise you to:
 
 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.
+2. Contact the project owner to ask them to fix the code owner definitions, or
+   permissions if needed.
 
 ### Renamed files
 
diff --git a/resources/Documentation/metrics.md b/resources/Documentation/metrics.md
new file mode 100644
index 0000000..0c4ccf5
--- /dev/null
+++ b/resources/Documentation/metrics.md
@@ -0,0 +1,101 @@
+# Metrics
+
+The @PLUGIN@ plugin exports several metrics which can give insights into the
+usage and performance of the code owners functionality.
+
+All metrics have the following prefix: `plugins/@PLUGIN@/`
+
+## <a id="latencyMetrics"> Latency Metrics
+
+* `add_change_message_on_add_reviewer`:
+  Latency for adding a change message with the owned path when a code owner is
+  added as a reviewer.
+* `compute_changed_files`:
+  Latency for computing changed files.
+* `compute_file_status`:
+  Latency for computing the file status for one file.
+* `compute_file_statuses`:
+  Latency for computing file statuses for all files in a change.
+* `compute_owned_paths`:
+  Latency for computing file statuses.
+* `compute_owned_paths`:
+  Latency for computing the files in a change that are owned by a user.
+* `compute_patch_set_approvals`:
+  Latency for computing the approvals of the current patch set.
+* `extend_change_message_on_post_review`:
+  Latency for extending the change message with the owned path when a code owner
+  approval is applied.
+* `get_auto_merge`:
+  Latency for getting the auto merge commit of a merge commit.
+* `get_changed_files`:
+  Latency for getting changed files from diff cache.
+* `prepare_file_status_computation`:
+  Latency for preparing the file status computation.
+* `prepare_file_status_computation_for_account`:
+  Latency for preparing the file status computation for an account.
+* `resolve_code_owner_config`:
+  Latency for resolving a code owner config file.
+* `resolve_code_owner_config_import`:
+  Latency for resolving an import of a code owner config file.
+* `resolve_code_owner_config_imports`:
+  Latency for resolving all imports of a code owner config file.
+* `resolve_code_owner_references`:
+  Latency for resolving the code owner references.
+* `resolve_path_code_owners`:
+  Latency for resolving the code owners of a path.
+* `run_code_owner_submit_rule`:
+  Latency for running the code owner submit rule.
+
+## <a id="codeOwnerConfigMetrics"> Code Owner Config Metrics
+
+* `code_owner_config_cache_reads_per_change`:
+  Number of code owner config cache reads per change.
+* `code_owner_config_backend_reads_per_change`:
+  Number of code owner config backend reads per change.
+* `load_code_owner_config`:
+  Latency for loading a code owner config file (read + parse).
+* `parse_code_owner_config`:
+  Latency for parsing a code owner config file.
+* `read_code_owner_config`:
+  Latency for reading a code owner config file.
+
+## <a id="counterMetrics"> Counter Metrics
+
+* `count_code_owner_config_reads`:
+  Total number of code owner config reads from backend.
+* `count_code_owner_config_cache_reads`:
+  Total number of code owner config reads from cache.
+* `count_code_owner_config_validations`:
+  Total number of code owner config validations.
+    * `trigger`:
+      The trigger of the validation.
+    * `result`:
+      The result of the validation.
+    * `dry_run`:
+      Whether the validation was a dry run.
+* `count_code_owner_submit_rule_errors`:
+  Total number of code owner submit rule errors.
+    * `cause`:
+      The cause of the submit rule error.
+* `count_code_owner_submit_rule_runs`:
+  Total number of code owner submit rule runs.
+* `count_code_owner_suggestions`:
+  Total number of code owner suggestions.
+    * `resolve_all_users`:
+      Whether code ownerships that are assigned to all users are resolved to
+      random users.
+* `count_invalid_code_owner_config_files`:
+  Total number of failed requests caused by an invalid / non-parsable code owner
+  config file.
+    * `project`:
+      The name of the project that contains the invalid code owner config file.
+    * `branch`:
+      The name of the branch that contains the invalid code owner config file.
+    * `path`:
+      The path of the invalid code owner config file.
+
+---
+
+Back to [@PLUGIN@ documentation index](index.html)
+
+Part of [Gerrit Code Review](../../../Documentation/index.html)
diff --git a/resources/Documentation/path-expressions.md b/resources/Documentation/path-expressions.md
index 7137d4c..e5c2dd8 100644
--- a/resources/Documentation/path-expressions.md
+++ b/resources/Documentation/path-expressions.md
@@ -7,16 +7,18 @@
 
 Which syntax is used depends on the used code owner backend:
 
-* [find-owners](backend-find-owners.html) backend: uses [globs](#globs)
-* [proto](backend-proto.html) backend: uses
-  [simple path expressions](#simplePathExpressions)
+* [find-owners](backend-find-owners.html) backend:
+  uses [globs](#globs), but each glob is automatically prefixed with `{**/,}`
+  so that subfolders are always matched (e.g. `*.md` matches all md files in all
+  subfolders, rather then only md files in the current folder)
+* [proto](backend-proto.html) backend:
+  uses [simple path expressions](#simplePathExpressions)
 
 ## <a id="globs">Globs
 
 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
@@ -35,15 +37,15 @@
 
 ## <a id="examples">Examples
 
-| To Match | Glob | Simple Path Expression |
-| -------- | ---- | ---------------------- |
-| Concrete file in current folder | `BUILD` | `BUILD` |
-| File type in current folder | `*.md` | `*.md` |
-| Concrete file in the current folder and in all subfolders | `{**/,}BUILD` | needs 2 expressions: `BUILD` + `.../BUILD` |
-| File type in the current folder and in all subfolder | `**.md` | `....md` |
-| All files in a subfolder | `my-folder/**` | `my-folder/...` |
-| All “foo-<1-digit-number>.txt” files in all subfolders | `{**/,}foo-[0-9].txt` | not possible |
-| All “foo-<n-digit-number>.txt” files in all subfolders | not possible | not possible |
+| To Match | Glob | find-owners | Simple Path Expression |
+| -------- | ---- | ----------- | ---------------------- |
+| Concrete file in current folder | `BUILD` | not possible | `BUILD` |
+| File type in current folder | `*.md` | not possible | `*.md` |
+| Concrete file in the current folder and in all subfolders | `{**/,}BUILD` | `BUILD` | needs 2 expressions: `BUILD` + `.../BUILD` |
+| File type in the current folder and in all subfolders | `**.md` | `*.md` or `**.md` | `....md` |
+| All files in a subfolder | `my-folder/**` | not possible, but you can add a `my-folder/OWNERS` file instead of using a glob | `my-folder/...` |
+| All “foo-<1-digit-number>.txt” files in all subfolders | `{**/,}foo-[0-9].txt` | `foo-[0-9].txt` |not possible |
+| All “foo-<n-digit-number>.txt” files in all subfolders | not possible | not possible | not possible
 
 ---
 
diff --git a/resources/Documentation/rest-api.md b/resources/Documentation/rest-api.md
index bd25b37..461232e 100644
--- a/resources/Documentation/rest-api.md
+++ b/resources/Documentation/rest-api.md
@@ -63,6 +63,44 @@
   }
 ```
 
+### <a id="update-code-owner-project-config">Update Code Owner Project Config
+_'PUT /projects/[\{project-name\}](../../../Documentation/rest-api-projects.html#project-name)/code_owners.project_config'_
+
+Updates the code owner project configuration.
+
+The configuration parameters that should be updated must be specified in the
+request body in a [CodeOwnerProjectConfigInput](#code-owner-project-config-info)
+entity.
+
+#### Request
+
+```
+  PUT /projects/foo%2Fbar/code_owners.project_config HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "disabled": true
+  }
+```
+
+#### Response
+
+As a response the updated code owner project config is returned as
+[CodeOwnerProjectConfigInfo](#code-owner-project-config-info) entity.
+
+```
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "status": {
+      "disabled": true
+    }
+  }
+```
+
 ### <a id="check-code-owner-config-files">Check Code Owner Config Files
 _'POST /projects/[\{project-name\}](../../../Documentation/rest-api-projects.html#project-name)/code_owners.check_config'_
 
@@ -219,6 +257,69 @@
   ]
 ```
 
+### <a id="check-code-owner">Check Code Owner
+_'GET /projects/[\{project-name\}](../../../Documentation/rest-api-projects.html#project-name)/branches/[\{branch-id\}](../../../Documentation/rest-api-projects.html#branch-id)/code_owners.check/'_
+
+Checks the code ownership of a user for a path in a branch.
+
+The following request parameters can be specified:
+
+| Field Name  |           | Description |
+| ----------- | --------- | ----------- |
+| `email`     | mandatory | Email for which the code ownership should be checked.
+| `path`      | mandatory | Path for which the code ownership should be checked.
+| `change`    | optional  | Change for which permissions should be checked. If not specified change permissions are not checked.
+| `user`      | optional  | User for which the code owner visibility should be checked. If not specified the code owner visibility is not checked. Can be used to investigate why a code owner is not shown/suggested to this user.
+
+Requires that the caller has the [Check Code Owner](#checkCodeOwner) or the
+[Administrate Server](../../../Documentation/access-control.html#capability_administrateServer)
+global capability.
+
+This REST endpoint is intended to investigate code owner configurations that do
+not work as intended. The response contains debug logs that may point out issues
+with the code owner configuration. For example, with this REST endpoint it is
+possible to find out why a certain email that is listed as code owner in a code
+owner config file is ignored (e.g. because it is ambiguous or because it belongs
+to an inactive account).
+
+#### Request
+
+```
+  GET /projects/foo%2Fbar/branches/master/code_owners.check?email=xyz@example.com&path=/foo/bar/baz.md HTTP/1.0
+```
+
+#### Response
+
+As response a [CodeOwnerCheckInfo](#code-owner-check-info) entity is returned.
+
+```
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "is_code_owner": false,
+    "is_resolvable": false,
+    "can_read_ref": true,
+    "code_owner_config_file_paths": [
+      "/OWNERS",
+    ],
+    "is_fallback_code_owner": false,
+    "is_default_code_owner": false,
+    "is_global_code_owner": false,
+    "debug_logs": [
+      "checking code owner config file foo/bar:master:/OWNERS",
+      "found email xyz@example.com as code owner in /OWNERS",
+      "trying to resolve email xyz@example.com",
+      "resolving code owner reference CodeOwnerReference{email=xyz@example.com}",
+      "all domains are allowed",
+      "cannot resolve code owner email xyz@example.com: email is ambiguous",
+      "email xyz@example.com is not a code owner of path '/foo/bar/baz.md'"
+    ]
+  }
+```
+
 ### <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/'_
 
@@ -353,18 +454,98 @@
 
 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.
+| `resolve-all-users` | optional | Whether code ownerships that are assigned to all users should be resolved to random users. If not set, `false` by default. Also see the [sorting example](#sortingExample) below to see how this parameter affects the returned code owners.
+| `highest-score-only` | optional | Whether only code owners with the highest score should be returned. If not set, `false` by default.
+| `debug`      | optional | Whether debug logs should be included into the response. Requires the [Check Code Owner](#checkCodeOwner) global capability.
+| `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.
+As a response a [CodeOwnersInfo](#code-owners-info) entity is returned that
+contains a list of code owners as [CodeOwnerInfo](#code-owner-info) entities.
 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 '*') and `resolve-all-users` is set to `true` a random set of
+(visible) users is returned, as many as are needed to fill up the requested
+limit.
+
+#### <a id="scoringFactors">Scoring Factors
+
+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)
+* whether the code owner is a reviewer of the change (only when listing code
+  owners for a change)
+
+The distance score has a lower weight than the is-reviewer score, hence when
+listing code owners for a change, code owners that are reviewers are always
+returned first.
+
+Other factors like OOO state, recent review activity or code authorship are not
+considered.
+
+**NOTE:** The scores for the code owners are not exposed via the REST API but
+only influence the sort order.
+
+#### <a id="sortingExample">Sorting Example
+
+The returned code owners are sorted by an internal score that is computed from
+multiple [scoring factors](#scoringFactors) (the higher the score the better).
+Code owners that have the same score are ordered randomly.
+
+E.g. lets’s say there are the following code owners and scores:
+
+- User A -> score 0
+- User B -> score 0
+- User C -> score 1
+- `*` (aka all users) -> score 1
+- User D -> score 2
+- User E -> score 3
+- User F -> score 4
+
+If the request is done with `resolve-all-users=true` and `limit=5` the following
+code owners are returned in this order:
+
+1\. + 2. [score=0] User A and User B (random order since they have the same score)\
+3\. [score=1] User C\
+4\. + 5. [score=1] 2 Random Users (because `*` is resolved to random users since `resolve-all-users` is `true`)\
+* `owned_by_all_users` in the response is `true`
+
+If the request is done with `resolve-all-users=false` and `limit=5` the following
+code owners are returned in this order:
+
+1\. + 2. [score=0] User A and User B (random order since they have the same score)\
+3\. [score=1] User C\
+4\. [score=2] User D\
+5\. [score=3] User E\
+* `owned_by_all_users` in the response is `true`
+
+#### <a id="rootOwnersFaq">Why are root code owners not suggested first?
+
+Root code owners can normally approve all files in a repository. Due to this
+change owners often want to add them as reviewers to their changes, since they
+find it desirable to add as few code owners as possible. This is problematic
+since it means that the root code owners would receive all reviews which likely
+overloads them.
+
+To avoid that the root code owners become the bottleneck, the @PLUGIN@ plugin
+prefers local code owners and suggests them first (also see distant score
+[above](#scoringFactors)). This means that root code owners are ranked lower and
+often don't appear amongst the top suggestions.
+
+Local code owners are also preferred because it is more likely that they are
+experts of the modified code.
+
+The same applies for [default code owners](config-guide.html#codeOwners).
 
 #### Request
 
@@ -380,20 +561,39 @@
   Content-Type: application/json; charset=UTF-8
 
   )]}'
-  [
-    {
-      "account": {
-        "_account_id": 1000096
-      }
-    },
-    {
-      "account": {
-        "_account_id": 1001439
+  {
+    "code_owners": [
+      {
+        "account": {
+          "_account_id": 1000096
+        }
       },
-    }
-  ]
+      {
+        "account": {
+          "_account_id": 1001439
+        },
+      }
+    ]
+  }
 ```
 
+#### <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
@@ -404,6 +604,13 @@
 The code owner statuses are always listed for the files in the current revision
 of the change (latest patch set).
 
+The following request parameters can be specified:
+
+| Field Name   |           | Description |
+| ------------ | --------- | ----------- |
+| `start`\|`S` | optional  | Number of file code owner statuses to skip. Allows to page over the file code owner statuses. By default 0.
+| `limit`\|`n` | optional  | Limit defining how many file code owner statuses should be returned at most. By default 0 (= unlimited).
+
 The code owner statuses are returned as a
 [CodeOwnerStatusInfo](#code-owner-status-info) entity.
 
@@ -437,17 +644,6 @@
           "path": "docs/todo.txt",
           "status": "PENDING"
         }
-      },
-      {
-        "change_type": "RENAMED",
-        "old_path_status" {
-          "path": "user-introduction.txt",
-          "status": "INSUFFICIENT_REVIEWERS"
-        },
-        "new_path_status" {
-          "path": "docs/user-intro.md",
-          "status": "APPROVED"
-        }
       }
     ]
   }
@@ -475,9 +671,51 @@
 
 The following code owners are filtered out additionally:
 
-* service users (members of the `Service Users` group)
+* [service users](#serviceUsers) (members of the `Service Users` group)
 * the change owner (since the change owner cannot be added as reviewer)
 
+In addition, by default the change number is used as seed if none was specified.
+This way the sort order on a change is always the same for files that have the
+exact same code owners (requires that the limit is the same on all requests).
+
+### <a id="get-owned-files">Get Owned Files
+_'GET /changes/[\{change-id}](../../../Documentation/rest-api-changes.html#change-id)/revisions/[\{revison-id\}](../../../Documentation/rest-api-changes.html#revision-id)/owned_paths'_
+
+Lists the files of the revision that are owned by the specified user (see `user`
+request parameter below).
+
+The following request parameters can be specified:
+
+| Field Name   |           | Description |
+| ------------ | --------- | ----------- |
+| `start`\|`S` | optional  | Number of owned paths to skip. Allows to page over the owned files. By default 0.
+| `limit`\|`n` | optional  | Limit defining how many owned files should be returned at most. By default 50.
+| `user`       | mandatory | user for which the owned paths should be returned
+
+#### Request
+
+```
+  GET /changes/20187/revisions/current/owned_paths?user=foo.bar@example.com HTTP/1.0
+```
+
+#### Response
+
+As a response a [OwnedPathsInfo](#owned-paths-info) entity is returned.
+
+```
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "owned_paths": [
+      "/foo/bar/baz.md",
+      "/foo/baz/bar.md",
+    ]
+  }
+```
+
 ### <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'_
 
@@ -587,11 +825,31 @@
 
 ---
 
+### <a id="code-owner-check-info"> CodeOwnerCheckInfo
+The `CodeOwnerCheckInfo` entity contains the result of checking the code
+ownership of a user for a path in a branch.
+
+| Field Name      | Description |
+| --------------- | ----------- |
+| `is_code_owner` | Whether the given email owns the specified path in the branch. True if: a) the given email is resolvable (see field `is_resolvable') and b) any code owner config file assigns codeownership to the email for the path (see field `code_owner_config_file_paths`) or the email is configured as default code owner (see field `is_default_code_owner` or the email is configured as global code owner (see field `is_global_code_owner`) or the user is a fallback code owner (see field `is_fallback_code_owner`).
+| `is_resolvable` | Whether the given email is resolvable for the specified user or the calling user if no user was specified.
+| `can_read_ref` | Whether the user to which the given email was resolved has read permissions on the branch. Not set if the given email is not resolvable or if the given email is the all users wildcard (aka '*').
+| `can_see_change`| Whether the user to which the given email was resolved can see the specified change. Not set if the given email is not resolvable, if the given email is the all users wildcard (aka '*') or if no change was specified.
+| `can_approve_change`| Whether the user to which the given email was resolved can code-owner approve the specified change. Being able to code-owner approve the change means that the user has permissions to vote on the label that is [required as code owner approval](config.html#pluginCodeOwnersRequiredApproval). Other permissions are not considered for computing this flag. In particular missing read permissions on the change don't have any effect on this flag. Whether the user misses read permissions on the change (and hence cannot apply the code owner approval) can be seen from the `can_see_change` flag. Not set if the given email is not resolvable, if the given email is the all users wildcard (aka '*') or if no change was specified.
+| `code_owner_config_file_paths` | Paths of the code owner config files that assign code ownership to the specified email and path as a list. Note that if code ownership is assigned to the email via a code owner config files, but the email is not resolvable (see field `is_resolvable` field), the user is not a code owner.
+| `is_fallback_code_owner` | Whether the given email is a fallback code owner of the specified path in the branch. True if: a) the given email is resolvable (see field `is_resolvable') and b) no code owners are defined for the specified path in the branch and c) parent code owners are not ignored and d) the user is a fallback code owner according to the [configured fallback code owner policy](config.html#pluginCodeOwnersFallbackCodeOwners)
+| `is_default_code_owner` | Whether the given email is configured as a default code owner in the code owner config file in `refs/meta/config`. Note that if the email is configured as default code owner, but the email is not resolvable (see `is_resolvable` field), the user is not a code owner.
+| `is_global_code_owner` | Whether the given email is configured as a global
+code owner. Note that if the email is configured as global code owner, but the email is not resolvable (see `is_resolvable` field), the user is not a code owner.
+| `is_owned_by_all_users` | Whether the the specified path in the branch is owned by all users (aka `*`).
+| `debug_logs` | List of debug logs that may help to understand why the user is or isn't a code owner.
+
+---
+
 ### <a id="code-owner-config-info"> CodeOwnerConfigInfo
 The `CodeOwnerConfigInfo` entity contains information about a code owner config
 for a path.
 
-
 | Field Name  |          | Description |
 | ----------- | -------- | ----------- |
 | `ignore_parent_code_owners` | optional, not set if `false` | Whether code owners from parent code owner configs (code owner configs in parent folders) should be ignored.
@@ -619,7 +877,6 @@
 | `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. 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`.
 
 ---
 
@@ -637,6 +894,37 @@
 
 ---
 
+### <a id="code-owner-project-config-input"> CodeOwnerProjectConfigInput
+The `CodeOwnerProjectConfigInput` entity specifies which parameters in the
+`code-owner.project` file in `refs/meta/config` should be updated.
+
+If a field in this input is not set, the corresponding parameter in the
+`code-owners.config` file is not updated.
+
+| Field Name |          | Description |
+| ---------- | -------- | ----------- |
+| `disabled` | optional | Whether the code owners functionality should be disabled/enabled for the project.
+| `disabled_branch` | optional | List of branches for which the code owners functionality is disabled. Can be exact refs, ref patterns or regular expressions. Overrides any existing disabled branch configuration.
+| `file_extension` | optional | The file extension that should be used for code owner config files in this project.
+| `required_approval` | optional | The approval that is required from code owners. Must be specified in the format "\<label-name\>+\<label-value\>". If an empty string is provided the required approval configuration is unset. Unsetting the required approval means that the inherited required approval configuration or the default required approval (`Code-Review+1`) will apply. In contrast to providing an empty string, providing `null` (or not setting the value) means that the required approval configuration is not updated.
+| `override_approvals` | optional | The approvals that count as override for the code owners submit check. Must be specified in the format "\<label-name\>+\<label-value\>".
+| `fallback_code_owners` | optional | 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. `PROJECT_OWNERS`: Paths for which no code owners are defined are owned by the project owners. `ALL_USERS`: Paths for which no code owners are defined are owned by all users.
+| `global_code_owners` | optional | List of emails of users that should be code owners globally across all branches.
+| `exempted_users` | optional | List of emails of users that should be exempted from requiring code owners approvals.
+| `merge_commit_strategy` | optional | 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.
+| `override_info_url` | optional | URL for a page that provides project/host-specific information about how to request a code owner override.
+| `invalid_code_owner_config_info_url` | optional | URL for a page that provides project/host-specific information about how to deal with invalid code owner config files.
+| `read_only` | optional | Whether code owner config files are read-only.
+| `exempt_pure_reverts` | optional | Whether pure revert changes are exempted from needing code owner approvals for submit.
+| `enable_validation_on_commit_received` | optional | Policy for validating code owner config files when a commit is received. Allowed values are `true` (the code owner config file validation is enabled and the upload 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).
+| `enable_validation_on_submit` | optional | 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 submission 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).
+| `reject_non_resolvable_code_owners` | optional | Whether modifications of code owner config files that newly add non-resolvable code owners should be rejected on commit received and submit.
+| `reject_non_resolvable_imports` | optional | Whether modifications of code owner config files that newly add non-resolvable imports should be rejected on commit received an submit.
+| `max_paths_in_change_messages` | optional | The maximum number of paths that are included in change messages. Setting the value to `0` disables including owned paths into change messages.
+
+---
+
 ### <a id="code-owner-reference-info"> CodeOwnerReferenceInfo
 The `CodeOwnerReferenceInfo` entity contains information about a code owner
 reference in a code owner config.
@@ -658,10 +946,11 @@
 The `CodeOwnerStatusInfo` entity describes the code owner statuses for the files
 in a change.
 
-| Field Name         | Description |
-| ------------------ | ----------- |
-| `patch_set_number` | The number of the patch set for which the code owner statuses are returned.
-| `file_code_owner_statuses` | List of the code owner statuses for the files in the change as [FileCodeOwnerStatusInfo](#file-code-owner-status-info) entities, sorted by new path, then old path.
+| Field Name         |          | Description |
+| ------------------ | -------- | ----------- |
+| `patch_set_number` |          | The number of the patch set for which the code owner statuses are returned.
+| `file_code_owner_statuses` |  | List of the code owner statuses for the files in the change as [FileCodeOwnerStatusInfo](#file-code-owner-status-info) entities, sorted by new path, then old path.
+| `more`             | optional | Whether the request would deliver more results if not limited. Not set if `false`.
 
 ### <a id="code-owners-status-info"> CodeOwnersStatusInfo
 The `CodeOwnersStatusInfo` contains information about whether the code owners
@@ -672,13 +961,22 @@
 | `disabled` | optional | Whether the code owners functionality is disabled for the project. If `true` the code owners API is disabled and submitting changes doesn't require code owner approvals. Not set if `false`.
 | `disabled_branches` | optional | Branches for which the code owners functionality is disabled. Configurations for non-existing and non-visible branches are omitted. Not set if the `disabled` field is `true` or if no branch specific status configuration is returned.
 
+### <a id="code-owners-info"> CodeOwnersInfo
+The `CodeOwnersInfo` entity contains information about a list of code owners.
+
+| Field Name    |          | Description |
+| ------------- | -------- | ----------- |
+| `code_owners` |          | List of code owners as [CodeOwnerInfo](#code-owner-info) entities. The code owners are sorted by a score that is computed from mutliple [scoring factors](#scoringFactors).
+| `owned_by_all_users` | optional | Whether the path is owned by all users. Not set if `false`.
+| `debug_logs`  | optional | Debug logs that may help to understand why a user is or isn't suggested as a code owner. Only set if requested via `--debug`.
+
 ### <a id="file-code-owner-status-info"> FileCodeOwnerStatusInfo
 The `FileCodeOwnerStatusInfo` entity describes the code owner statuses for a
 file in a change.
 
 | Field Name    |          | Description |
 | ------------- | -------- | ----------- |
-| `change_type` | optional | The type of the file modification. Can be `ADDED`, `MODIFIED`, `DELETED`, `RENAMED` or `COPIED`. Not set if `MODIFIED`.
+| `change_type` | optional | The type of the file modification. Can be `ADDED`, `MODIFIED`, `DELETED`, `RENAMED` or `COPIED`. Not set if `MODIFIED`. Renamed files might appear as separate addition and deletion or with type=RENAMED. Copied files might appear as addition or with type=COPIED.
 | `old_path_status` | optional | The code owner status for the old path as [PathCodeOwnerStatusInfo](#path-code-owner-status-info) entity. Only set if `change_type` is `DELETED` or `RENAMED`.
 | `new_path_status` | optional | The code owner status for the new path as [PathCodeOwnerStatusInfo](#path-code-owner-status-info) entity. Not set if `change_type` is `DELETED`.
 
@@ -691,7 +989,17 @@
 | `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.
+| `invalid_code_owner_config_info_url` | optional | Optional URL for a page that provides project/host-specific information about how to deal with invalid code owner config files.
+|`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. `PROJECT_OWNERS`: Paths for which no code owners are defined are owned by the project owners. `ALL_USERS`: Paths for which no code owners are defined are owned by all users.
+
+### <a id="owned-paths-info"> OwnedPathsInfo
+The `OwnedPathsInfo` entity contains paths that are owned by a user.
+
+
+| Field Name    |          | Description |
+| ------------- | -------- | ----------- |
+| `owned_paths` |          |The owned paths as absolute paths, sorted alphabetically.
+| `more`        | optional | Whether the request would deliver more results if not limited. Not set if `false`.
 
 ### <a id="path-code-owner-status-info"> PathCodeOwnerStatusInfo
 The `PathCodeOwnerStatusInfo` entity describes the code owner status for a path
@@ -736,6 +1044,57 @@
 
 ---
 
+## <a id="capabilities">Capabilities
+
+### <a id="checkCodeOwner">Check Code Owner
+
+Global capability that allows a user to call the [Check Code
+Owner](#check-code-owner) REST endpoint and use the `--debug` option of the
+[List Code Owners](#list-code-owners-for-path-in-branch) REST endpoints.
+
+Assigning this capability allows users to inspect code ownerships. This may
+reveal accounts and secondary emails to the user that the user cannot see
+otherwise. Hence this capability should only ge granted to trusted users.
+
+Administrators have this capability implicitly assigned.
+
+The same as all global capabilities, the `Check Code Owner` global capability is
+assigned on the `All-Project` project in the `Global Capabilities` access
+section.
+
+---
+
+## <a id="serviceUsers">Service Users
+
+Some of the @PLUGIN@ REST endpoints have special handling of code owners that
+are service users:
+
+* The [Suggest Code Owners for path in change](#list-code-owners-for-path-in-change)
+  REST endpoint filters out code owners that are service users.
+
+To detect service users the @PLUGIN@ plugin relies on the `Service Users` group.
+This group should contain all service users, such as bots, and is maintained by
+the host admins.
+
+If you are a host admin, please make sure all bots that run against your host
+are part of the `Service Users` group.
+
+If you are a bot owner, please make sure your bot is part of the `Service Users`
+group on all hosts it runs on.
+
+To add users to the "Service Users" group, first ensure that the group exists on
+your host. If it doesn't, create it. The name must exactly be `Service Users`.
+
+To create a group, use the Gerrit UI: `BROWSE` -> `Groups` -> `CREATE NEW`.
+
+Then, add the bots as members in this group. Alternatively, add an existing
+group that only contains bots as a subgroup of the `Service Users` group.
+
+To add members or subgroups, use the Gerrit UI: `BROWSE` -> `Groups` ->
+search for `Service Users` -> `Members`.
+
+---
+
 Back to [@PLUGIN@ documentation index](index.html)
 
 Part of [Gerrit Code Review](../../../Documentation/index.html)
diff --git a/resources/Documentation/setup-guide.md b/resources/Documentation/setup-guide.md
index 7449164..d912ea4 100644
--- a/resources/Documentation/setup-guide.md
+++ b/resources/Documentation/setup-guide.md
@@ -16,20 +16,18 @@
 3. [Opt-out branches that should not use code owners](#optOutBranches)
 4. [Configure the label vote that should count as code owner approval](#configureCodeOwnerApproval)
 5. [Grant code owners permission to vote on the label that counts as code owner approval](#grantCodeOwnerPermissions)
-6. [Configure code owner overrides](#configureCodeOwnerOverrides)
+6. [Configure code owner overrides & fallback code owners](#configureCodeOwnerOverridesAndFallbackCodeOwners)
 7. [Configure allowed email domains](#configureAllowedEmailDomains)
 8. [Optional Configuration](#optionalConfiguration)
 9. [Stop using the find-owners Prolog submit rule](#stopUsingFindOwners)
 10. [Add an initial code owner configuration at root level](#configureCodeOwners)
-
-FAQ's:
-
-* [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)
+11. [Disable/uninstall the find-owners plugin](#disableFindOwnersPlugin)
 
 Recommendations about further configuration parameters can be found in the
 [config guide](config-guide.html).
 
+Please also heck out the [config FAQs](config-faqs.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)
@@ -56,8 +54,8 @@
 \
 To configure a backend on project-level
 [codeOwners.backend](config.html#codeOwnersBackend) in the
-[code-owners.config](#updateCodeOwnersConfig) file in the `refs/meta/config`
-branch can be set (requires to be a project owner).
+[code-owners.config](config-faqs.html#updateCodeOwnersConfig) file in the
+`refs/meta/config` branch can be set (requires to be a project owner).
 
 Example per-project configuration in `code-owners.config` that configures the
 [proto](backend-proto.html) backend:
@@ -69,8 +67,8 @@
 \
 It's also possible to configure backends per branch by setting
 [codeOwners.\<branch\>.backend](config.html#codeOwnersBranchBackend) in the
-[code-owners.config](#updateCodeOwnersConfig) file in the `refs/meta/config`
-branch (requires to be a project owner).
+[code-owners.config](config-faqs.html#updateCodeOwnersConfig) file in the
+`refs/meta/config` branch (requires to be a project owner).
 
 Example per-branch configuration in `code-owners.config` that configures the
 [proto](backend-proto.html) backend:
@@ -106,7 +104,7 @@
 ```
 \
 Alternatively you may set [codeOwners.disabled](config.html#codeOwnersDisabled)
-in the [code-owners.config](#updateCodeOwnersConfig) file in the
+in the [code-owners.config](config-faqs.html#updateCodeOwnersConfig) file in the
 `refs/meta/config` branch of the `All-Projects` project to `true` (requires to
 be a host admin).
 
@@ -120,8 +118,8 @@
 
 Enable the code owners functionality on project-level by setting
 [codeOwners.disabled](config.html#codeOwnersDisabled) in the
-[code-owners.config](#updateCodeOwnersConfig) file in the `refs/meta/config`
-branch to `false` (requires to be a project owner).
+[code-owners.config](config-faqs.html#updateCodeOwnersConfig) file in the
+`refs/meta/config` branch to `false` (requires to be a project owner).
 
 `code-owners.config`:
 ```
@@ -141,15 +139,15 @@
 If needed, unset
 [plugin.code-owners.disabled](config.html#pluginCodeOwnersDisabled) in
 `gerrit.config` and [codeOwners.disabled](config.html#codeOwnersDisabled) in the
-[code-owners.config](#updateCodeOwnersConfig) file in the `refs/meta/config`
-branch of the `All-Projects` project).
+[code-owners.config](config-faqs.html#updateCodeOwnersConfig) file in the
+`refs/meta/config` branch of the `All-Projects` project).
 
 ###### b) Disable the code owners functionality in the projects/repositories that should not use code owners
 
 Disable the code owners functionality on project-level by setting
 [codeOwners.disabled](config.html#codeOwnersDisabled) in the
-[code-owners.config](#updateCodeOwnersConfig) file in the `refs/meta/config`
-branch to `true` (requires to be a project owner).
+[code-owners.config](config-faqs.html#updateCodeOwnersConfig) file in the
+`refs/meta/config` branch to `true` (requires to be a project owner).
 
 `code-owners.config`:
 ```
@@ -167,9 +165,9 @@
 
 To opt-out branches from using code owners set
 [codeOwners.disabledBranch](config.html#codeOwnersDisabledBranch) in the
-[code-owners.config](#updateCodeOwnersConfig) file in the `refs/meta/config`
-branch to a regular expression that matches the branches that should be
-opted-out (requires to be a project owner).
+[code-owners.config](config.faqs.html#updateCodeOwnersConfig) file in the
+`refs/meta/config` branch to a regular expression that matches the branches that
+should be opted-out (requires to be a project owner).
 
 `code-owners.config`:
 ```
@@ -186,8 +184,8 @@
 [plugin.code-owners.requiredApproval](config.html#pluginCodeOwnersRequiredApproval)
 in `gerrit.config` (requires to be a host admin) or per project by setting
 [codeOwners.requiredApproval](config.html#codeOwnersRequiredApproval) in the
-[code-owners.config](#updateCodeOwnersConfig) file in the `refs/meta/config`
-branch (requires to be a project owner).
+[code-owners.config](config-faqs.html#updateCodeOwnersConfig) file in the
+`refs/meta/config` branch (requires to be a project owner).
 
 Example global configuration in `gerrit.config` that requires `Code-Review+2` as
 code owner approval:
@@ -239,21 +237,30 @@
 needs to be granted via the access screen of the project or a parent project (at
 `https://<host>/admin/repos/<project-name>,access`).
 
-### <a id="configureCodeOwnerOverrides">6. Configure code owner overrides
+### <a id="configureCodeOwnerOverridesAndFallbackCodeOwners">6. Configure code owner overrides & fallback code owners
+
+It's possible that some files have no code owners defined (e.g. missing root
+code owner config file). In this case changes for these files cannot be code
+owner approved and hence cannot be submitted.
+
+To avoid that this leads to unsubmittable changes it is recommended to configure
+code owner overrides and/or fallback code owners.
+
+#### <a id="configureCodeOwnerOverrides">Configure code owner overrides
 
 It's possible to configure code owner overrides that allow privileged users to
 override code owner approvals. This means they can approve changes without being
 a code owner.
 
-Configuring code owner overrides is optional.
+Configuring code owner overrides is optional, but recommended.
 
 To enable code owner overrides, you must define which label vote is required for
 an override. This can be done globally by setting
 [plugin.code-owners.overrideApproval](config.html#pluginCodeOwnersOverrideApproval)
 in `gerrit.config` (requires to be a host admin) or per project by setting
 [codeOwners.overrideApproval](config.html#codeOwnersOverrideApproval) in the
-[code-owners.config](#updateCodeOwnersConfig) file in the `refs/meta/config`
-branch (requires to be a project owner).
+[code-owners.config](config-faqs.html#updateCodeOwnersConfig) file in the
+`refs/meta/config` branch (requires to be a project owner).
 
 Example global configuration in `gerrit.config` that requires
 `Owners-Override+1` for a code owner override:
@@ -289,6 +296,18 @@
 add both configurations in one commit is a known issue that still needs to be
 fixed).
 
+#### <a id="configureFallbackCodeOwners">Configure fallback code owners
+
+It is possible to configure a policy for [fallback code
+owners](config.html#pluginCodeOwnersFallbackCodeOwners) that controls who should
+own files for which no code owners have been defined, e.g. project owners, all
+users or no one (default).
+
+Configuring fallback code owners is optional. For the initial rollout of the
+code-owners plugin it is highly recommended to allow fallback code owners so
+that projects that do not have any code owner config files yet are not
+disrupted.
+
 ### <a id="configureAllowedEmailDomains">7. Configure allowed email domains
 
 By default, the emails in code owner config files that make users code owners
@@ -316,9 +335,10 @@
 
 Examples (not an exhaustive list):
 
-* [Global code owners](config.html#codeOwnersGlobalCodeOwner)
-* Whether [an implicit code owner approval from the last uploader is
-  assumed](config.html#codeOwnersEnableImplicitApprovals)
+* [Global code owners](config.html#pluginCodeOwnersGlobalCodeOwner)
+* Whether [an implicit code owner approval from the change owner is
+  assumed](config.html#codeOwnersEnableImplicitApprovals) (only for patch sets
+  that are uploaded by the change owner)
 * [Merge commit strategy](config.html#codeOwnersMergeCommitStrategy) that
   decides which files of merge commits require code owner approvals
 * [File extension](config.html#codeOwnersFileExtension) that should be used for
@@ -340,13 +360,12 @@
 By enabling the code owners functionality, a code owner approval from code
 owners will be required for submitting changes.
 
-While a branch doesn't contain any code owner config files yet, the project
-owners (users with the
-[Owner](../../../Documentation/access-control.html#category_owner) access right
-on `refs/*`) are considered as code owners. This allows you to add an initial
-code owner config file, with approval from the project owners, that defines the
-inital code owners. Once the first code owner config file has been submitted,
-project owners are no longer considered as code owners.
+If code owners are not defined yet, changes can only be submitted
+
+* with a code owner override (if override labels have been configured, see
+  [above](#configureCodeOwnerOverrides))
+* with an approval from a fallback code owner (if fallback code owners have been
+  configured, see [above](#configureFallbackCodeOwners)).
 
 Right after the code owners functionality got enabled for a project/branch, it
 is recommended to add an initial code owner configuration at the root level that
@@ -356,55 +375,32 @@
 after enabling the code owners functionality so that the code owner
 configuration is [validated](validation.html) on upload, which prevents
 submitting an invalid code owner config that may block the submission of all
-changes (e.g. if it is not parseable).
+changes (e.g. if it is not parseable). Submitting the initial code owner
+configuration requires an override or an approval from a fallback code owner
+(see above).
 
 **NOTE** If the repository contains pre-existing code owner config files, it is
 recommended to validate them via the [Check code owners files REST
 endpoint](rest-api.html#check-code-owner-config-files) and fix the reported
 issues.
 
-### <a id="faq">FAQ's
+**NOTE:** If neither code owner overrides nor fallback code owners are
+configured an initial code owner configuration must be added before enabling the
+code owners functionality as otherwise changes can become unsubmittable (they
+require code-owner approvals, but noone can provide nor override them).
 
-##### <a id="updateCodeOwnersConfig">How to update the code-owners.config file for a project
+### <a id="disableFindOwnersPlugin">11. Disable/uninstall the find-owners plugin
 
-The project-level configuration of the `code-owners` plugin is done in the
-`code-owners.config` file that is stored in the `refs/meta/config` branch of a
-project. If it is not present, all configuration parameters are inherited from
-the parent projects or the global configuration.
-
-The `code-owners.config` file has the format of a Git config file (same as the
-`project.config` file).
-
-To update the `code-owners.config` file do (requires to be a project owner):
-
-* clone the repository
-* fetch and checkout the `refs/meta/config` branch (e.g. `git fetch origin
-  refs/meta/config && git checkout FETCH_HEAD`)
-* create or edit the `code-owners.config` file
-* commit the changes
-* push the newly created commit back to the `refs/meta/config` branch (e.g. `git
-  push origin HEAD:refs/meta/config`)
-
-##### <a id="checkIfEnabled">How to check if the code owners functionality is enabled for a project or branch
-
-To check if the code owners functionality is enabled for a single branch, use
-the [Get Code Owner Branch Config](rest-api.html#get-code-owner-branch-config)
-REST endpoint and inspect the
-[disabled](rest-api.html#code-owner-branch-config-info) field in the response
-(if it is not present, the code owners functionality is enabled).
-
-To check if the code owners functionality is enabled for a project or for
-multiple branches, use the [Get Code Owner Project
-Config](rest-api.html#get-code-owner-project-config) REST endpoint and inspect
-the [status](rest-api.html#code-owners-status-info) in the response (an empty
-status means that the code owners functionality is enabled for all branches of
-the project).
-
-You can invoke the REST endpoints via `curl` from the command-line or
-alternatively open the following URLs in a browser:\
-`https://<host>/projects/<project-name>/branches/<branch-name>/code_owners.branch_config`\
-`https://<host>/projects/<project-name>/code_owners.project_config`\
-(remember to URL-encode the project-name and branch-name)
+If the `find-owners` plugin has been used so far, you likely want to
+disable/uninstall it after the `code-owners` plugin has been set up. Before
+doing this it is important to remove all usages of the [find-owners Prolog
+predicates](https://gerrit.googlesource.com/plugins/find-owners/+/HEAD/src/main/resources/Documentation/config.md#submit-rules-and-filters)
+from all Prolog submit rules and filters. If the find-owners Prolog predicates
+are still used when the `find-owners` plugin is disabled/uninstalled, they can
+no longer be resolved which breaks the submit rules using them. If submit rules
+are broken, changes cannot be submitted, which most users would consider an
+outage. Hence before disabling/uninstalling the `find-owners` plugin you want
+to be sure that the find-owners Prolog predicates are no longer used.
 
 ---
 
diff --git a/resources/Documentation/user-guide.md b/resources/Documentation/user-guide.md
index 70dfaeb..5a665cb 100644
--- a/resources/Documentation/user-guide.md
+++ b/resources/Documentation/user-guide.md
@@ -64,14 +64,20 @@
 block the submission (unless it's a veto vote which is configured independently
 of the `@PLUGIN@` plugin).
 
-It's possible to configure that for changes/patch-sets that are uploaded by a
-code owner an implict code owner approval from the uploader is assumed. In this
-case, if a code owner only touches files that they own, no approval from other
-code owners is required. If this is configured, 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).
+It's possible to [configure implicit
+approvals](config.html#codeOwnersEnableImplicitApprovals) for changes/patch-sets
+that are owned and uploaded by a code owner. In this case, if a code owner only
+touches files that they own, no approval from other code owners is required. If
+this is configured, it is important that code owners are aware of their implicit
+approval when they upload new changes 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, i.e. the code owner performs a cherry-pick, the rebased
+change has implicit approvals from the code owner, since the code owner is the
+change owner and uploader).
+
+**NOTE:** Implicit approvals are applied on changes that are owned by a code
+owner, but only if the current patch set was uploader by the change owner
+(change owner == last patch set uploader).
 
 For files that are [renamed/moved](#renames) Gerrit requires a code owner
 approval for the old and the new path of the files.
@@ -102,6 +108,14 @@
 [ignoreSelfApproval](../../../Documentation/config-labels.html#label_ignoreSelfApproval)
 enabled, code owner approvals of the patch set uploader are ignored.
 
+**NOTE:** Code owner approvals are always applied on the whole change / patch
+set and count for all files in the change / patch set. It is not possible to
+approve individual files only. This means code owners should always review all
+files in the change / patch set before applying their approval. E.g. it is
+discouraged to only review the owned files, since the set of owned files can
+change if `OWNERS` files in the destination branch are changed after the
+approval has been applied.
+
 ## <a id="codeOwnerOverride">Code owner override
 
 Usually some privileged users, such as sheriffs, are allowed to override the
@@ -161,16 +175,6 @@
 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
-[default code owner config file](backend-find-owners.html#defaultCodeOwnerConfiguration),
-the project owners are considered as code owners and can grant [code owner
-approvals](#codeOwnerApproval) for all files. This is to allow bootstrapping
-code owners and should be only a temporary state until the first [code owner
-config file](#codeOwnerConfigFiles) is added.  Please note that the [code owner
-suggestion](#codeOwnerSuggestion) isn't working if no code owners are defined
-yet (project owners will not be suggested in this case).
-
 ## <a id="renames">Renames
 
 A rename is treated as a deletion at the old path and a creation at the new
@@ -221,6 +225,39 @@
 are trusted, as it prevents that code owners need to approve the same changes
 multiple times, but for different branches.
 
+## <a id="codeOwnersSubmitRule">Code Owners Submit Rule
+
+The logic that checks whether a change has sufficient [code owner
+approvals](#codeOwnerApproval) to be submitted is implemented in the code owners
+submit rule. If the code owners submit rule finds that code owner approvals are
+missing the submission of the change is blocked. In this case it's possible to
+use a [code owner override](#codeOwnerOverride) to unblock the change
+submission.
+
+**NOTE:** Besides the code owners submit rule there may be further submit rules
+that block the change submission for other reasons that are not related to code
+owners. E.g. configured [label
+functions](../../../Documentation/config-labels.html#label_function) are
+completely orthogonal to code owner approvals. If, for example, `Code-Review+1`
+votes are required as code owner approval, but the `Code-Review` label has the
+function `MaxWithBlock` the change submission is still blocked if a max approval
+(aka `Code-Review+2`) is missing or if a veto vote (aka `Code-Review-2`) is
+present.
+
+**NOTE:** Gerrit submit rules are executed on submit and when change details are
+loaded, e.g. when loading the change screen (to know whether the submit button
+should be enabled). In addition, submit rules are executed on every change
+update because the result of running submit rules is stored as submit records in
+the change index. This makes the submit records available when querying changes
+(without needing to run the submit rules for every change in the result which
+would be too expensive). For code owners the submit records that are stored in
+the index can become stale for 2 reasons: 1. [code owner config
+files](#codeOwnerConfigFiles) are changed after the change has been indexed
+(e.g. new code owners are added), 2. [if the code owners plugin configuration
+was changed in a way that affected the result of the code owners submit
+rule](config.html#staleIndexOnConfigChanges). Callers of change queries should
+be aware of this.
+
 ---
 
 Back to [@PLUGIN@ documentation index](index.html)
diff --git a/resources/Documentation/validation.md b/resources/Documentation/validation.md
index ef1b4ce..1c660a5 100644
--- a/resources/Documentation/validation.md
+++ b/resources/Documentation/validation.md
@@ -41,6 +41,8 @@
   [enableValidationOnCommitReceived](config.html#codeOwnersEnableValidationOnCommitReceived)
   or [enableValidationOnSubmit](config.html#codeOwnersEnableValidationOnSubmit)
   config options
+* the [--code-owners~skip-validation](#skipCodeOwnerConfigValidationOnDemand)
+  push option was specified on push
 
 In addition for [code owner config files](user-guide.html#codeOwnerConfigFiles)
 no validation is done when:
@@ -53,6 +55,23 @@
   blocking all uploads, to reduce the risk of breaking the plugin configuration
   `code-owner.config` files are validated too)
 
+## <a id="skipCodeOwnerConfigValidationOnDemand">Skip code owner config validation on demand
+
+By setting the `--code-owners~skip-validation` push option it is possible to
+skip the code owner config validation on push.
+
+Using this push option requires the calling user to have to
+`Can Skip Code Owner Config Validation` global capability. Host administrators
+have this capability implicitly assigned via the `Administrate Server` global
+capability.
+
+**NOTE:** Using this option only makes sense if the [code owner config validation
+on submit](config.html#pluginCodeOwnersEnableValidationOnSubmit) is disabled, as
+otherwise it's not possible to submit the created change (using the push option
+only skips the validation for the push, but not for the submission of the
+change).
+
+## <a id="howCodeOwnerConfigsCanGetIssuesAfterSubmit">
 In addition it is possible that [code owner config
 files](user-guide.hmtl#codeOwnerConfigFiles) get issues after they have been
 submitted:
@@ -61,8 +80,9 @@
   (e.g. the [accounts.visibility](../../../Documentation/config-gerrit.html#accounts.visibility)
   setting is changed, [another code owners backend is
   configured](setup-guide.html#configureCodeOwnersBackend) which now uses a
-  different syntax or different names for code owner config files, or the [file
-  extension for code owner config file is set/changed](config.html#codeOwnersFileExtension))
+  different syntax or different names for code owner config files, the [file
+  extension for code owner config file is set/changed](config.html#codeOwnersFileExtension),
+  or the [allowed email domains are changed](config.html#pluginCodeOwnersAllowedEmailDomain))
 * emails of users may change so that emails in code owner configs can no longer
   be resolved
 * imported code owner config files may get deleted or renamed so that import
@@ -80,11 +100,14 @@
 * the file was non-parseable and with the update it is still non-parseable
 
 For [code owner config files](user-guide.html#codeOwnerConfigFiles) the
-validation is also performed on submit (in addition to the validation that is
-performed on upload of the change). This is because relevant configuration can
-change between the time a change is uploaded and the time a change is submitted.
-On submit we repeat the exact same validation that was done on upload. This
-means, all visibility checks will be done from the perspective of the uploader.
+validation may also be performed on submit (in addition to the validation that
+is performed on upload of the change, see
+[enableValidationOnSubmit](config.html#codeOwnersEnableValidationOnSubmit)
+config setting). Repeating the validation on submit can make sense because
+relevant configuration can change between the time a change is uploaded and the
+time a change is submitted. If enabled, on submit we repeat the exact same
+validation that was done on upload. This means, all visibility checks will be
+done from the perspective of the uploader.
 
 ### <a id="codeOwnerConfigFileChecks">Validation checks for code owner config files
 
@@ -92,7 +115,8 @@
 following checks are performed:
 
 * the code owner config files are parseable
-* the code owner emails are resolveable:\
+* the code owner emails are resolveable (unless this check is
+  [disabled](config.html#codeOwnersRejectNonResolvableCodeOwners)):\
   a code owners email is not resolveable if:
     * the account that owns it is inactive
     * the account that owns it is not visible to the uploader (according to
@@ -100,11 +124,12 @@
       setting)
     * it is a non-visible secondary email
     * there is no account that has this email assigned
-    * it is ambiguous (the same email is assigned to multiple accounts)
+    * it is ambiguous (the same email is assigned to multiple active accounts)
     * it has an email domain that is disallowed (see
       [allowedEmailDomain](config.html#pluginCodeOwnersAllowedEmailDomain))
       configuration
-* the imports are resolveable:\
+* the imports are resolveable (unless this check is
+  [disabled](config.html#codeOwnersRejectNonResolvableImports)):\
   an import is not resolveable if:
     * the imported file is not a code owner config file
     * the imported file is not parseable
@@ -116,6 +141,13 @@
     * the project from which the file should be imported doesn't permit reads
       (e.g. has the state `HIDDEN`)
 
+**NOTE:** Whether commits that newly add non-resolvable code owners and
+non-resolvable imports are rejected on commit received and on submit is
+controlled by the
+[rejectNonResolvableCodeOwners](config.html#pluginCodeOwnersRejectNonResolvableCodeOwners)
+and [rejectNonResolvableImports](config.html#pluginCodeOwnersRejectNonResolvableImports)
+config settings.
+
 The following things are **not** checked (not an exhaustive list):
 
 * Cycles in imports of owner config files:\
@@ -154,6 +186,8 @@
   configuration is valid
 * the [codeOwners.fallbackCodeOwners](config.html#codeOwnersFallbackCodeOwners)
   configuration is valid
+* the [codeOwners.maxPathsInChangeMessages](config.html#codeOwnersMaxPathsInChangeMessages)
+  configuration is valid
 
 ---
 
diff --git a/ui/BUILD b/ui/BUILD
new file mode 100644
index 0000000..274676e
--- /dev/null
+++ b/ui/BUILD
@@ -0,0 +1,14 @@
+load("//tools/bzl:js.bzl", "gerrit_js_bundle")
+
+package_group(
+    name = "visibility",
+    packages = ["//plugins/code-owners/..."],
+)
+
+package(default_visibility = [":visibility"])
+
+gerrit_js_bundle(
+    name = "code-owners",
+    srcs = glob(["*.js"]),
+    entry_point = "plugin.js",
+)
diff --git a/ui/code-owners-api.js b/ui/code-owners-api.js
new file mode 100644
index 0000000..e186ede
--- /dev/null
+++ b/ui/code-owners-api.js
@@ -0,0 +1,205 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// TODO: Try to remove it. The ResponseError and getErrorMessage duplicates
+// code from the gr-plugin-rest-api.ts. This code is required because
+// we want custom error processing in some functions. For details see
+// the original gr-plugin-rest-api.ts file/
+class ResponseError extends Error {
+  constructor(response) {
+    super();
+    this.response = response;
+  }
+}
+
+export class ServerConfigurationError extends Error {
+  constructor(msg) {
+    super(msg);
+  }
+}
+
+async function getErrorMessage(response) {
+  const text = await response.text();
+  return text ?
+    `${response.status}: ${text}` :
+    `${response.status}`;
+}
+
+/**
+ * Responsible for communicating with the rest-api
+ *
+ * @see resources/Documentation/rest-api.md
+ */
+export class CodeOwnersApi {
+  constructor(restApi) {
+    this.restApi = restApi;
+  }
+
+  /**
+   * Send a get request and provides custom response-code handling
+   */
+  async _get(url) {
+    const errFn = (response, error) => {
+      if (error) throw error;
+      if (response) throw new ResponseError(response);
+      throw new Error('Generic REST API error');
+    };
+    try {
+      return await this.restApi.send(
+          'GET',
+          url,
+          undefined,
+          errFn
+      );
+    } catch (err) {
+      if (err instanceof ResponseError && err.response.status === 409) {
+        return getErrorMessage(err.response).then(msg => {
+          throw new ServerConfigurationError(msg);
+        });
+      }
+      throw err;
+    }
+  }
+
+  /**
+   * Returns a promise fetching the owner statuses for all files within the change.
+   *
+   * @doc https://gerrit.googlesource.com/plugins/code-owners/+/HEAD/resources/Documentation/rest-api.md#change-endpoints
+   * @param {string} changeId
+   */
+  listOwnerStatus(changeId) {
+    return this._get(`/changes/${changeId}/code_owners.status`);
+  }
+
+  /**
+   * Returns a promise fetching the owners for a given path.
+   *
+   * @doc https://gerrit.googlesource.com/plugins/code-owners/+/HEAD/resources/Documentation/rest-api.md#list-code-owners-for-path-in-branch
+   * @param {string} changeId
+   * @param {string} path
+   */
+  listOwnersForPath(changeId, path, limit) {
+    return this._get(
+        `/changes/${changeId}/revisions/current/code_owners` +
+        `/${encodeURIComponent(path)}?limit=${limit}&o=DETAILS`
+    );
+  }
+
+  /**
+   * Returns a promise fetching the owners config for a given path.
+   *
+   * @doc https://gerrit.googlesource.com/plugins/code-owners/+/HEAD/resources/Documentation/rest-api.md#branch-endpoints
+   * @param {string} project
+   * @param {string} branch
+   * @param {string} path
+   */
+  getConfigForPath(project, branch, path) {
+    return this._get(
+        `/projects/${encodeURIComponent(project)}/` +
+        `branches/${encodeURIComponent(branch)}/` +
+        `code_owners.config/${encodeURIComponent(path)}`
+    );
+  }
+
+  /**
+   * Returns a promise fetching the owners config for a given branch.
+   *
+   * @doc https://gerrit.googlesource.com/plugins/code-owners/+/HEAD/resources/Documentation/rest-api.md#branch-endpoints
+   * @param {string} project
+   * @param {string} branch
+   */
+  async getBranchConfig(project, branch) {
+    try {
+      const config = await this._get(
+          `/projects/${encodeURIComponent(project)}/` +
+          `branches/${encodeURIComponent(branch)}/` +
+          `code_owners.branch_config`
+      );
+      if (config.override_approval &&
+          !(config.override_approval instanceof Array)) {
+        // In the upcoming backend changes, the override_approval will be changed
+        // to array with (possible) multiple items.
+        // While this transition is in progress, the frontend supports both API -
+        // the old one and the new one.
+        return {...config, override_approval: [config.override_approval]};
+      }
+      return config;
+    } catch (err) {
+      if (err instanceof ResponseError) {
+        if (err.response.status === 404) {
+          // The 404 error means that the branch doesn't exist and
+          // the plugin should be disabled.
+          return {disabled: true};
+        }
+        return getErrorMessage(err.response).then(msg => {
+          throw new Error(msg);
+        });
+      }
+      throw err;
+    }
+  }
+}
+
+/**
+ * Wrapper around codeOwnerApi, sends each requests only once and then cache
+ * the response. A new CodeOwnersCacheApi instance is created every time when a
+ * new change object is assigned.
+ * Gerrit never updates existing change object, but instead always assigns a new
+ * change object. Particularly, a new change object is assigned when a change
+ * is updated and user clicks reload toasts to see the updated change.
+ * As a result, the lifetime of a cache is the same as a lifetime of an assigned
+ * change object.
+ * Periodical cache invalidation can lead to inconsistency in UI, i.e.
+ * user can see the old reviewers list (reflects a state when a change was
+ * loaded) and code-owners status for the current reviewer list. To avoid
+ * this inconsistency, the cache doesn't invalidate.
+ */
+export class CodeOwnersCacheApi {
+  constructor(codeOwnerApi, change) {
+    this.codeOwnerApi = codeOwnerApi;
+    this.change = change;
+    this.promises = {};
+  }
+
+  _fetchOnce(cacheKey, asyncFn) {
+    if (!this.promises[cacheKey]) {
+      this.promises[cacheKey] = asyncFn();
+    }
+    return this.promises[cacheKey];
+  }
+
+  getAccount() {
+    return this._fetchOnce('getAccount', () => this._getAccount());
+  }
+
+  async _getAccount() {
+    const loggedIn = await this.codeOwnerApi.restApi.getLoggedIn();
+    if (!loggedIn) return undefined;
+    return await this.codeOwnerApi.restApi.getAccount();
+  }
+
+  listOwnerStatus() {
+    return this._fetchOnce('listOwnerStatus',
+        () => this.codeOwnerApi.listOwnerStatus(this.change._number));
+  }
+
+  getBranchConfig() {
+    return this._fetchOnce('getBranchConfig',
+        () => this.codeOwnerApi.getBranchConfig(this.change.project,
+            this.change.branch));
+  }
+}
diff --git a/ui/code-owners-banner.js b/ui/code-owners-banner.js
index a44e258..1aa9e58 100644
--- a/ui/code-owners-banner.js
+++ b/ui/code-owners-banner.js
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import {CodeOwnersModelMixin} from './code-owners-model-mixin.js';
-import {PluginState} from './code-owners-model.js';
+import {PluginState, isPluginErrorState} from './code-owners-model.js';
 
 // There are 2 elements in this file:
 // CodeOwnersBanner - visual elements. This element is shown at the top
@@ -79,7 +79,7 @@
           margin-left: var(--spacing-l);
         }
       </style>
-      <span class="text">Error: Code-owners plugin has failed</span>
+      <span class="text">[[_getErrorText(pluginStatus)]]</span>
       <gr-button link on-click="_showFailDetails">
         Details
       </gr-button>
@@ -112,7 +112,14 @@
   }
 
   _computeHidden(pluginStatus) {
-    return !pluginStatus || pluginStatus.state !== PluginState.Failed;
+    return !pluginStatus || !isPluginErrorState(pluginStatus.state);
+  }
+
+  _getErrorText(pluginStatus) {
+    return !pluginStatus || pluginStatus.state === PluginState.Failed ?
+      'Error: Code-owners plugin has failed' :
+      'The code-owners plugin has configuration issue. ' +
+      'Please contact the project owner or the host admin.';
   }
 
   _showFailDetails() {
@@ -133,6 +140,26 @@
       banner: {
         type: Object,
       },
+      /**
+       * This is a temporary property for interop with find-owner plugin.
+       * It stores information about the change and code-owners status for this
+       * change. The possible values for branchState are:
+       * LOADING - branch config are requesting
+       * FAILED - plugin are in failed state (server returned error, etc...)
+       * ENABLED - code-owners is enabled for the change
+       * DISABLED - code-owners is disable for the change
+       *
+       * ENABLED/DISABLED values are different from the pluginStatus.
+       * For merged and abandoned changes the model.pluginStatus value is
+       * always DISABLED, even if code-owners is enabled for a project.
+       */
+      _stateForFindOwnersPlugin: {
+        type: Object,
+        notify: true,
+        computed:
+            '_getStateForFindOwners(model.pluginStatus, model.branchConfig,' +
+              ' change)',
+      },
     };
   }
 
@@ -159,6 +186,26 @@
 
   _loadDataAfterStateChanged() {
     this.modelLoader.loadPluginStatus();
+    this.modelLoader.loadBranchConfig();
+  }
+
+  _getStateForFindOwners(pluginStatus, branchConfig, change) {
+    if (pluginStatus === undefined || branchConfig === undefined ||
+        change == undefined) {
+      return {
+        branchState: 'LOADING',
+      };
+    }
+    if (isPluginErrorState(pluginStatus.state)) {
+      return {
+        change,
+        branchState: 'FAILED',
+      };
+    }
+    return {
+      change,
+      branchState: branchConfig.disabled ? 'DISABLED' : 'ENABLED',
+    };
   }
 }
 
diff --git a/ui/code-owners-fetcher.js b/ui/code-owners-fetcher.js
new file mode 100644
index 0000000..6fd5b35
--- /dev/null
+++ b/ui/code-owners-fetcher.js
@@ -0,0 +1,205 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {CodeOwnersApi} from './code-owners-api.js';
+
+/**
+ * All statuses returned for owner status.
+ *
+ * @enum
+ */
+export const OwnerStatus = {
+  INSUFFICIENT_REVIEWERS: 'INSUFFICIENT_REVIEWERS',
+  PENDING: 'PENDING',
+  APPROVED: 'APPROVED',
+};
+
+/**
+ * @enum
+ */
+export const FetchStatus = {
+  /** Fetch hasn't been started */
+  NOT_STARTED: 0,
+  /**
+   * Fetch has been started, but not all files has been finished.
+   * Pausing during fetching doesn't change state.
+   */
+  FETCHING: 1,
+  /**
+   * All owners has been loaded. resume/pause call doesn't change state.
+   */
+  FINISHED: 2,
+};
+
+/**
+ * Fetch owners for files. The class fetches owners in parallel and allows to
+ * pause/resume fetch.
+ */
+class OwnersFetcher {
+  /**
+   * Creates a fetcher in paused state. Actual fetching starts after resume()
+   * is called.
+   *
+   * @param {Array<string>} filesToFetch - Files paths for loading owners.
+   * @param {number} ownersLimit - number of requested owners per file.
+   * @param {number} maxConcurrentRequest - max number of concurrent requests to server.
+   */
+  constructor(codeOwnerApi, changeId, filesToFetch, ownersLimit,
+      maxConcurrentRequest) {
+    this._fetchedOwners = new Map();
+    this._ownersLimit = ownersLimit;
+    this._paused = true;
+    this._pausedFilesFetcher = [];
+    this._filesToFetch = filesToFetch;
+    this._fetchFilesPromises = [];
+    this._codeOwnerApi = codeOwnerApi;
+    this._changeId = changeId;
+
+    for (let i = 0; i < maxConcurrentRequest; i++) {
+      this._fetchFilesPromises.push(this._fetchFiles());
+    }
+  }
+
+  async _fetchFiles() {
+    for (;;) {
+      const filePath = await this._getNextFilePath();
+      if (!filePath) return;
+      try {
+        this._fetchedOwners.set(filePath, {
+          owners: await this._codeOwnerApi.listOwnersForPath(this._changeId,
+              filePath, this._ownersLimit),
+        });
+      } catch (error) {
+        this._fetchedOwners.set(filePath, {error});
+      }
+    }
+  }
+
+  async _getNextFilePath() {
+    if (this._paused) {
+      await new Promise(resolve => this._pausedFilesFetcher.push(resolve));
+    }
+    if (this._filesToFetch.length === 0) return null;
+    return this._filesToFetch.splice(0, 1)[0];
+  }
+
+  async waitFetchComplete() {
+    await Promise.allSettled(this._fetchFilesPromises);
+  }
+
+  resume() {
+    if (!this._paused) return;
+    this._paused = false;
+    for (const fetcher of this._pausedFilesFetcher.splice(0,
+        this._pausedFilesFetcher.length)) {
+      fetcher();
+    }
+  }
+
+  pause() {
+    this._paused = true;
+  }
+
+  getFetchedOwners() {
+    return this._fetchedOwners;
+  }
+
+  getFiles() {
+    const result = [];
+    for (const [path, info] of this._fetchedOwners.entries()) {
+      result.push({path, info});
+    }
+    return result;
+  }
+}
+
+export class OwnersProvider {
+  constructor(restApi, change, options) {
+    this.change = change;
+    this.options = options;
+    this._totalFetchCount = 0;
+    this._status = FetchStatus.NOT_STARTED;
+    this._codeOwnerApi = new CodeOwnersApi(restApi);
+  }
+
+  getStatus() {
+    return this._status;
+  }
+
+  getProgressString() {
+    return !this._ownersFetcher || this._totalFetchCount === 0 ?
+      `Loading suggested owners ...` :
+      `${this._ownersFetcher.getFetchedOwners().size} out of ` +
+      `${this._totalFetchCount} files have returned suggested owners.`;
+  }
+
+  getFiles() {
+    if (!this._ownersFetcher) return [];
+    return this._ownersFetcher.getFiles();
+  }
+
+  async fetchSuggestedOwners(codeOwnerStatusMap) {
+    if (this._status !== FetchStatus.NOT_STARTED) {
+      await this._ownersFetcher.waitFetchComplete();
+      return;
+    }
+    const filesToFetch = this._getFilesToFetch(codeOwnerStatusMap);
+    this._totalFetchCount = filesToFetch.length;
+    this._ownersFetcher = new OwnersFetcher(this._codeOwnerApi, this.change.id,
+        filesToFetch,
+        this.options.ownersLimit, this.options.maxConcurrentRequests);
+    this._status = FetchStatus.FETCHING;
+    this._ownersFetcher.resume();
+    await this._ownersFetcher.waitFetchComplete(filesToFetch);
+    this._status = FetchStatus.FINISHED;
+  }
+
+  _getFilesToFetch(codeOwnerStatusMap) {
+    // only fetch those not approved yet
+    const filesGroupByStatus = [...codeOwnerStatusMap.entries()].reduce(
+        (list, [file, fileInfo]) => {
+          if (list[fileInfo.status]) list[fileInfo.status].push(file);
+          return list;
+        }
+        , {
+          [OwnerStatus.PENDING]: [],
+          [OwnerStatus.INSUFFICIENT_REVIEWERS]: [],
+          [OwnerStatus.APPROVED]: [],
+        }
+    );
+    // always fetch INSUFFICIENT_REVIEWERS first, then pending and then approved
+    return filesGroupByStatus[OwnerStatus.INSUFFICIENT_REVIEWERS]
+        .concat(filesGroupByStatus[OwnerStatus.PENDING])
+        .concat(filesGroupByStatus[OwnerStatus.APPROVED]);
+  }
+
+  pause() {
+    if (!this._ownersFetcher) return;
+    this._ownersFetcher.pause();
+  }
+
+  resume() {
+    if (!this._ownersFetcher) return;
+    this._ownersFetcher.resume();
+  }
+
+  reset() {
+    this._totalFetchCount = 0;
+    this.ownersFetcher = null;
+    this._status = FetchStatus.NOT_STARTED;
+  }
+}
diff --git a/ui/code-owners-model-loader.js b/ui/code-owners-model-loader.js
index 2cd9095..cdc04dd 100644
--- a/ui/code-owners-model-loader.js
+++ b/ui/code-owners-model-loader.js
@@ -16,6 +16,7 @@
  */
 
 import {SuggestionsState} from './code-owners-model.js';
+import {ServerConfigurationError} from './code-owners-api.js';
 
 /**
  * ModelLoader provides a method for loading data into the model.
@@ -39,6 +40,11 @@
     try {
       newValue = await propertyLoader();
     } catch (e) {
+      if (e instanceof ServerConfigurationError) {
+        this.ownersModel.setServerConfigurationError(e.message);
+        return;
+      }
+      console.error(e);
       this.ownersModel.setPluginFailed(e.message);
       return;
     }
@@ -84,33 +90,62 @@
     );
   }
 
-  async loadSuggestions() {
-    // If a loading has been started already, do nothing
-    if (this.ownersModel.suggestionsState
-        !== SuggestionsState.NotLoaded) return;
-
-    this.ownersModel.setSuggestionsState(SuggestionsState.Loading);
-    let suggestedOwners;
-    try {
-      suggestedOwners = await this.ownersService.getSuggestedOwners();
-    } catch (e) {
-      this.ownersModel.setSuggestionsState(SuggestionsState.LoadFailed);
-      this.ownersModel.setPluginFailed(e.message);
+  async loadSuggestions(suggestionsType) {
+    this.pauseActiveSuggestedOwnersLoading();
+    this.activeLoadSuggestionType = suggestionsType;
+    if (this.ownersModel.suggestionsByTypes[suggestionsType].state ===
+        SuggestionsState.Loading) {
+      this.ownersService.resumeSuggestedOwnersLoading(suggestionsType);
       return;
     }
-    this.ownersModel.setSuggestions(suggestedOwners.suggestions);
-    this.ownersModel.setSuggestionsState(SuggestionsState.Loaded);
-  }
 
-  async updateLoadSuggestionsProgress() {
+    // If a loading has been started already, do nothing
+    if (this.ownersModel.suggestionsByTypes[suggestionsType].state !==
+        SuggestionsState.NotLoaded) return;
+
+    this.ownersModel.setSuggestionsState(suggestionsType,
+        SuggestionsState.Loading);
     let suggestedOwners;
     try {
-      suggestedOwners = await this.ownersService.getSuggestedOwnersProgress();
+      suggestedOwners =
+          await this.ownersService.getSuggestedOwners(suggestionsType);
+    } catch (e) {
+      console.error(e);
+      this.ownersModel.setSuggestionsState(suggestionsType,
+          SuggestionsState.LoadFailed);
+      // The selectedSuggestionsType can be changed while getSuggestedOwners
+      // is executed. The plugin should fail only if the selectedSuggestionsType
+      // is the same.
+      if (this.ownersModel.selectedSuggestionsType === suggestionsType) {
+        this.ownersModel.setPluginFailed(e.message);
+      }
+      return;
+    }
+    this.ownersModel.setSuggestionsFiles(suggestionsType,
+        suggestedOwners.files);
+    this.ownersModel.setSuggestionsState(suggestionsType,
+        SuggestionsState.Loaded);
+  }
+
+  pauseActiveSuggestedOwnersLoading() {
+    if (!this.activeLoadSuggestionType) return;
+    this.ownersService.pauseSuggestedOwnersLoading(
+        this.activeLoadSuggestionType);
+  }
+
+  async updateLoadSelectedSuggestionsProgress() {
+    const suggestionsType = this.ownersModel.selectedSuggestionsType;
+    let suggestedOwners;
+    try {
+      suggestedOwners =
+          await this.ownersService.getSuggestedOwnersProgress(suggestionsType);
     } catch {
       // Ignore any error, keep progress unchanged.
       return;
     }
-    this.ownersModel.setSuggestionsLoadProgress(suggestedOwners.progress);
-    this.ownersModel.setSuggestions(suggestedOwners.suggestions);
+    this.ownersModel.setSuggestionsLoadProgress(suggestionsType,
+        suggestedOwners.progress);
+    this.ownersModel.setSuggestionsFiles(suggestionsType,
+        suggestedOwners.files);
   }
 }
diff --git a/ui/code-owners-model-mixin.js b/ui/code-owners-model-mixin.js
index 722af6f..80c8ffb 100644
--- a/ui/code-owners-model-mixin.js
+++ b/ui/code-owners-model-mixin.js
@@ -37,6 +37,7 @@
        */
       this.modelLoader = undefined;
     }
+
     static get properties() {
       return {
         /* The following 3 properties (change, reporting, restApi) have to be
diff --git a/ui/code-owners-model.js b/ui/code-owners-model.js
index cbd9bd2..ad536b4 100644
--- a/ui/code-owners-model.js
+++ b/ui/code-owners-model.js
@@ -25,9 +25,23 @@
 export const PluginState = {
   Enabled: 'Enabled',
   Disabled: 'Disabled',
+  ServerConfigurationError: 'ServerConfigurationError',
   Failed: 'Failed',
 };
 
+export function isPluginErrorState(state) {
+  return state === PluginState.ServerConfigurationError ||
+      state === PluginState.Failed;
+}
+
+export const SuggestionsType = {
+  BEST_SUGGESTIONS: 'BEST_SUGGESTIONS',
+  ALL_SUGGESTIONS: 'ALL_SUGGESTIONS',
+};
+
+export const BestSuggestionsLimit = 5;
+export const AllSuggestionsLimit = 1000;
+
 /**
  * Maintain the state of code-owners.
  * Raises 'model-property-changed' event when a property is changed.
@@ -54,13 +68,23 @@
     this.userRole = undefined;
     this.isCodeOwnerEnabled = undefined;
     this.areAllFilesApproved = undefined;
-    this.suggestions = undefined;
-    this.suggestionsState = SuggestionsState.NotLoaded;
-    this.suggestionsLoadProgress = undefined;
+    this.suggestionsByTypes = {};
+    for (const suggestionType of Object.values(SuggestionsType)) {
+      this.suggestionsByTypes[suggestionType] = {
+        files: undefined,
+        state: SuggestionsState.NotLoaded,
+        loadProgress: undefined,
+      };
+    }
+    this.selectedSuggestionsType = SuggestionsType.BEST_SUGGESTIONS;
     this.showSuggestions = false;
     this.pluginStatus = undefined;
   }
 
+  get selectedSuggestions() {
+    return this.suggestionsByTypes[this.selectedSuggestionsType];
+  }
+
   setBranchConfig(config) {
     if (this.branchConfig === config) return;
     this.branchConfig = config;
@@ -91,22 +115,32 @@
     this._firePropertyChanged('areAllFilesApproved');
   }
 
-  setSuggestions(suggestions) {
-    if (this.suggestions === suggestions) return;
-    this.suggestions = suggestions;
-    this._firePropertyChanged('suggestions');
+  setSuggestionsFiles(suggestionsType, files) {
+    const suggestions = this.suggestionsByTypes[suggestionsType];
+    if (suggestions.files === files) return;
+    suggestions.files = files;
+    this._fireSuggestionsChanged(suggestionsType, 'files');
   }
 
-  setSuggestionsState(state) {
-    if (this.suggestionsState === state) return;
-    this.suggestionsState = state;
-    this._firePropertyChanged('suggestionsState');
+  setSuggestionsState(suggestionsType, state) {
+    const suggestions = this.suggestionsByTypes[suggestionsType];
+    if (suggestions.state === state) return;
+    suggestions.state = state;
+    this._fireSuggestionsChanged(suggestionsType, 'state');
   }
 
-  setSuggestionsLoadProgress(progress) {
-    if (this.suggestionsLoadProgress === progress) return;
-    this.suggestionsLoadProgress = progress;
-    this._firePropertyChanged('suggestionsLoadProgress');
+  setSuggestionsLoadProgress(suggestionsType, progress) {
+    const suggestions = this.suggestionsByTypes[suggestionsType];
+    if (suggestions.loadProgress === progress) return;
+    suggestions.loadProgress = progress;
+    this._fireSuggestionsChanged(suggestionsType, 'loadProgress');
+  }
+
+  setSelectedSuggestionType(suggestionsType) {
+    if (this.selectedSuggestionsType === suggestionsType) return;
+    this.selectedSuggestionsType = suggestionsType;
+    this._firePropertyChanged('selectedSuggestionsType');
+    this._firePropertyChanged('selectedSuggestions');
   }
 
   setShowSuggestions(show) {
@@ -120,6 +154,11 @@
       PluginState.Enabled : PluginState.Disabled});
   }
 
+  setServerConfigurationError(failedMessage) {
+    this._setPluginStatus({state: PluginState.ServerConfigurationError,
+      failedMessage});
+  }
+
   setPluginFailed(failedMessage) {
     this._setPluginStatus({state: PluginState.Failed, failedMessage});
   }
@@ -135,7 +174,7 @@
       return status1 === status2;
     }
     if (status1.state !== status2.state) return false;
-    return status1.state === PluginState.Failed ?
+    return isPluginErrorState(status1.state)?
       status1.failedMessage === status2.failedMessage :
       true;
   }
@@ -148,6 +187,14 @@
     }));
   }
 
+  _fireSuggestionsChanged(suggestionsType, propertyName) {
+    this._firePropertyChanged(
+        `suggestionsByTypes.${suggestionsType}.${propertyName}`);
+    if (suggestionsType === this.selectedSuggestionsType) {
+      this._firePropertyChanged(`selectedSuggestions.${propertyName}`);
+    }
+  }
+
   static getModel(change) {
     if (!this.model || this.model.change !== change) {
       this.model = new CodeOwnersModel(change);
diff --git a/ui/code-owners-service.js b/ui/code-owners-service.js
index 8bb14d1..f7ee6d2 100644
--- a/ui/code-owners-service.js
+++ b/ui/code-owners-service.js
@@ -15,26 +15,9 @@
  * limitations under the License.
  */
 
-/**
- * All statuses returned for owner status.
- *
- * @enum
- */
-export const OwnerStatus = {
-  INSUFFICIENT_REVIEWERS: 'INSUFFICIENT_REVIEWERS',
-  PENDING: 'PENDING',
-  APPROVED: 'APPROVED',
-};
-
-/**
- * @enum
- */
-const FetchStatus = {
-  NOT_STARTED: 0,
-  FETCHING: 1,
-  FINISHED: 2,
-  ABORT: 3,
-};
+import {SuggestionsType, BestSuggestionsLimit, AllSuggestionsLimit} from './code-owners-model.js';
+import {OwnersProvider, OwnerStatus, FetchStatus} from './code-owners-fetcher.js';
+import {CodeOwnersApi, CodeOwnersCacheApi} from './code-owners-api.js';
 
 /**
  * Specifies status for a change. The same as ChangeStatus enum in gerrit
@@ -61,249 +44,28 @@
 };
 
 /**
- * Responsible for communicating with the rest-api
- *
- * @see resources/Documentation/rest-api.md
- */
-class CodeOwnerApi {
-  constructor(restApi) {
-    this.restApi = restApi;
-  }
-
-  /**
-   * Returns a promise fetching the owner statuses for all files within the change.
-   *
-   * @doc https://gerrit.googlesource.com/plugins/code-owners/+/refs/heads/master/resources/Documentation/rest-api.md#change-endpoints
-   * @param {string} changeId
-   */
-  listOwnerStatus(changeId) {
-    return this.restApi.get(`/changes/${changeId}/code_owners.status`);
-  }
-
-  /**
-   * Returns a promise fetching the owners for a given path.
-   *
-   * @doc https://gerrit.googlesource.com/plugins/code-owners/+/refs/heads/master/resources/Documentation/rest-api.md#list-code-owners-for-path-in-branch
-   * @param {string} changeId
-   * @param {string} path
-   */
-  listOwnersForPath(changeId, path) {
-    return this.restApi.get(
-        `/changes/${changeId}/revisions/current/code_owners` +
-        `/${encodeURIComponent(path)}?limit=5&o=DETAILS`
-    );
-  }
-
-  /**
-   * Returns a promise fetching the owners config for a given path.
-   *
-   * @doc https://gerrit.googlesource.com/plugins/code-owners/+/refs/heads/master/resources/Documentation/rest-api.md#branch-endpoints
-   * @param {string} project
-   * @param {string} branch
-   * @param {string} path
-   */
-  getConfigForPath(project, branch, path) {
-    return this.restApi.get(
-        `/projects/${encodeURIComponent(project)}/` +
-        `branches/${encodeURIComponent(branch)}/` +
-        `code_owners.config/${encodeURIComponent(path)}`
-    );
-  }
-
-  /**
-   * Returns a promise fetching the owners config for a given branch.
-   *
-   * @doc https://gerrit.googlesource.com/plugins/code-owners/+/refs/heads/master/resources/Documentation/rest-api.md#branch-endpoints
-   * @param {string} project
-   * @param {string} branch
-   */
-  async getBranchConfig(project, branch) {
-    const config = await this.restApi.get(
-        `/projects/${encodeURIComponent(project)}/` +
-        `branches/${encodeURIComponent(branch)}/` +
-        `code_owners.branch_config`
-    );
-    if (config.override_approval && !(config.override_approval instanceof Array)) {
-      // In the upcoming backend changes, the override_approval will be changed
-      // to array with (possible) multiple items.
-      // While this transition is in progress, the frontend supports both API -
-      // the old one and the new one.
-      return {...config, override_approval: [config.override_approval]};
-    }
-    return config;
-  }
-}
-
-/**
- * Wrapper around codeOwnerApi, sends each requests only once and then cache
- * the response. A new CodeOwnersCacheApi instance is created every time when a
- * new change object is assigned.
- * Gerrit never updates existing change object, but instead always assigns a new
- * change object. Particularly, a new change object is assigned when a change
- * is updated and user clicks reload toasts to see the updated change.
- * As a result, the lifetime of a cache is the same as a lifetime of an assigned
- * change object.
- * Periodical cache invalidation can lead to inconsistency in UI, i.e.
- * user can see the old reviewers list (reflects a state when a change was
- * loaded) and code-owners status for the current reviewer list. To avoid
- * this inconsistency, the cache doesn't invalidate.
- */
-export class CodeOwnersCacheApi {
-  constructor(codeOwnerApi, change) {
-    this.codeOwnerApi = codeOwnerApi;
-    this.change = change;
-    this.promises = {};
-  }
-
-  _fetchOnce(cacheKey, asyncFn) {
-    if (!this.promises[cacheKey]) {
-      this.promises[cacheKey] = asyncFn();
-    }
-    return this.promises[cacheKey];
-  }
-
-  getAccount() {
-    return this._fetchOnce('getAccount', () => this._getAccount());
-  }
-
-  async _getAccount() {
-    const loggedIn = await this.codeOwnerApi.restApi.getLoggedIn();
-    if (!loggedIn) return undefined;
-    return await this.codeOwnerApi.restApi.getAccount();
-  }
-
-  listOwnerStatus() {
-    return this._fetchOnce('listOwnerStatus',
-        () => this.codeOwnerApi.listOwnerStatus(this.change._number));
-  }
-
-  getBranchConfig() {
-    return this._fetchOnce('getBranchConfig',
-        () => this.codeOwnerApi.getBranchConfig(this.change.project,
-            this.change.branch));
-  }
-
-  listOwnersForPath(path) {
-    return this._fetchOnce(`listOwnersForPath:${path}`,
-        () => this.codeOwnerApi.listOwnersForPath(this.change.id, path));
-  }
-}
-
-export class OwnersFetcher {
-  constructor(restApi, change, options) {
-    // fetched files and fetching status
-    this._fetchedOwners = new Map();
-    this._fetchStatus = FetchStatus.NOT_STARTED;
-    this._totalFetchCount = 0;
-    this.change = change;
-    this.options = options;
-    this.codeOwnerApi = new CodeOwnerApi(restApi);
-  }
-
-  getStatus() {
-    return this._fetchStatus;
-  }
-
-  getProgressString() {
-    return this._totalFetchCount === 0 ?
-      `Loading suggested owners ...` :
-      `${this._fetchedOwners.size} out of ${this._totalFetchCount} files have returned suggested owners.`;
-  }
-
-  getFiles() {
-    const result = [];
-    for (const [path, info] of this._fetchedOwners.entries()) {
-      result.push({path, info});
-    }
-    return result;
-  }
-
-  async fetchSuggestedOwners(codeOwnerStatusMap) {
-    // reset existing temporary storage
-    this._fetchedOwners = new Map();
-    this._fetchStatus = FetchStatus.FETCHING;
-    this._totalFetchCount = 0;
-
-    // only fetch those not approved yet
-    const filesGroupByStatus = [...codeOwnerStatusMap.keys()].reduce(
-        (list, file) => {
-          const status = codeOwnerStatusMap
-              .get(file).status;
-          if (status === OwnerStatus.INSUFFICIENT_REVIEWERS) {
-            list.missing.push(file);
-          } else if (status === OwnerStatus.PENDING) {
-            list.pending.push(file);
-          }
-          return list;
-        }
-        , {pending: [], missing: []});
-    // always fetch INSUFFICIENT_REVIEWERS first and then pending
-    const filesToFetch = filesGroupByStatus.missing
-        .concat(filesGroupByStatus.pending);
-    this._totalFetchCount = filesToFetch.length;
-    await this._batchFetchCodeOwners(filesToFetch);
-    this._fetchStatus = FetchStatus.FINISHED;
-  }
-
-  /**
-   * Recursively fetches code owners for all files until finished.
-   *
-   * @param {!Array<string>} files
-   */
-  async _batchFetchCodeOwners(files) {
-    if (this._fetchStatus === FetchStatus.ABORT) {
-      return this._fetchedOwners;
-    }
-
-    const batchRequests = [];
-    const maxConcurrentRequests = this.options.maxConcurrentRequests;
-    for (let i = 0; i < maxConcurrentRequests; i++) {
-      const filePath = files[i];
-      if (filePath) {
-        this._fetchedOwners.set(filePath, {});
-        batchRequests.push(this._fetchOwnersForPath(this.change.id, filePath));
-      }
-    }
-    const resPromise = Promise.all(batchRequests);
-    await resPromise;
-    if (files.length > maxConcurrentRequests) {
-      return await this._batchFetchCodeOwners(
-          files.slice(maxConcurrentRequests));
-    }
-    return this._fetchedOwners;
-  }
-
-  async _fetchOwnersForPath(changeId, filePath) {
-    try {
-      const owners = await this.codeOwnerApi.listOwnersForPath(changeId,
-          filePath);
-      this._fetchedOwners.get(filePath).owners = new Set(owners);
-    } catch (e) {
-      this._fetchedOwners.get(filePath).error = e;
-    }
-  }
-
-  abort() {
-    this._fetchStatus = FetchStatus.ABORT;
-    this._fetchedOwners = new Map();
-    this._totalFetchCount = 0;
-  }
-}
-
-/**
  * Service for the data layer used in the plugin UI.
  */
 export class CodeOwnerService {
   constructor(restApi, change, options = {}) {
     this.restApi = restApi;
     this.change = change;
-    const codeOwnerApi = new CodeOwnerApi(restApi);
-    this.codeOwnerCacheApi = new CodeOwnersCacheApi(codeOwnerApi, change);
+    const codeOwnersApi = new CodeOwnersApi(restApi);
+    this.codeOwnersCacheApi = new CodeOwnersCacheApi(codeOwnersApi, change);
 
-    const fetcherOptions = {
+    const providerOptions = {
       maxConcurrentRequests: options.maxConcurrentRequests || 10,
     };
-    this.ownersFetcher = new OwnersFetcher(restApi, change, fetcherOptions);
+    this.ownersProviders = {
+      [SuggestionsType.BEST_SUGGESTIONS]: new OwnersProvider(restApi, change, {
+        ...providerOptions,
+        ownersLimit: BestSuggestionsLimit,
+      }),
+      [SuggestionsType.ALL_SUGGESTIONS]: new OwnersProvider(restApi, change, {
+        ...providerOptions,
+        ownersLimit: AllSuggestionsLimit,
+      }),
+    };
   }
 
   /**
@@ -312,7 +74,7 @@
   async prefetch() {
     try {
       await Promise.all([
-        this.codeOwnerCacheApi.getAccount(),
+        this.codeOwnersCacheApi.getAccount(),
         this.getStatus(),
       ]);
     } catch {
@@ -329,7 +91,7 @@
    * role 'REVIEWER' remains unchanged until the change view is reloaded.
    */
   async getLoggedInUserInitialRole() {
-    const account = await this.codeOwnerCacheApi.getAccount();
+    const account = await this.codeOwnersCacheApi.getAccount();
     if (!account) {
       return UserRole.ANONYMOUS;
     }
@@ -374,11 +136,13 @@
 
   async getStatus() {
     const status = await this._getStatus();
-    if (status.enabled && !this.isOnLatestPatchset(status.patchsetNumber)) {
-      // status is outdated, abort and re-init
-      this.abort();
+    if (status.enabled && this._isOnOlderPatchset(status.patchsetNumber)) {
+      // status is returned for an older patchset. Abort, re-init and refetch
+      // new status - it is expected, that after several retry a status
+      // for the newest patchset is returned
+      this.reset();
       this.prefetch();
-      return await this.codeOwnerCacheApi.getStatus();
+      return await this.getStatus();
     }
     return status;
   }
@@ -391,18 +155,21 @@
         enabled: false,
         codeOwnerStatusMap: new Map(),
         rawStatuses: [],
+        newerPatchsetUploaded: false,
       };
     }
 
-    const onwerStatus = await this.codeOwnerCacheApi.listOwnerStatus();
+    const ownerStatus = await this.codeOwnersCacheApi.listOwnerStatus();
 
     return {
       enabled: true,
-      patchsetNumber: onwerStatus.patch_set_number,
+      patchsetNumber: ownerStatus.patch_set_number,
       codeOwnerStatusMap: this._formatStatuses(
-          onwerStatus.file_code_owner_statuses
+          ownerStatus.file_code_owner_statuses
       ),
-      rawStatuses: onwerStatus.file_code_owner_statuses,
+      rawStatuses: ownerStatus.file_code_owner_statuses,
+      newerPatchsetUploaded:
+        this._isOnNewerPatchset(ownerStatus.patch_set_number),
     };
   }
 
@@ -412,8 +179,8 @@
       const oldPathStatus = status.old_path_status;
       const newPathStatus = status.new_path_status;
       // For deleted files, no new_path_status exists
-      return (newPathStatus && newPathStatus.status !== OwnerStatus.APPROVED)
-        || (oldPathStatus && oldPathStatus.status !== OwnerStatus.APPROVED);
+      return (newPathStatus && newPathStatus.status !== OwnerStatus.APPROVED) ||
+        (oldPathStatus && oldPathStatus.status !== OwnerStatus.APPROVED);
     });
   }
 
@@ -434,39 +201,41 @@
    *  }>
    * }}
    */
-  async getSuggestedOwners() {
+  async getSuggestedOwners(suggestionsType) {
     const {codeOwnerStatusMap} = await this.getStatus();
+    const ownersProvider = this.ownersProviders[suggestionsType];
 
-    // In case its aborted due to outdated patches
-    // should kick start the fetching again
-    // Note: we currently are not reusing the instance when switching changes,
-    // so if its `abort` due to different changes, the whole instance will be
-    // outdated and not used.
-    if (this.ownersFetcher.getStatus() === FetchStatus.NOT_STARTED
-      || this.ownersFetcher.getStatus() === FetchStatus.ABORT) {
-      await this.ownersFetcher.fetchSuggestedOwners(codeOwnerStatusMap);
-    }
+    await ownersProvider.fetchSuggestedOwners(codeOwnerStatusMap);
 
     return {
-      finished: this.ownersFetcher.getStatus() === FetchStatus.FINISHED,
-      status: this.ownersFetcher.getStatus(),
-      progress: this.ownersFetcher.getProgressString(),
-      suggestions: this._groupFilesByOwners(codeOwnerStatusMap,
-          this.ownersFetcher.getFiles()),
+      finished: ownersProvider.getStatus() === FetchStatus.FINISHED,
+      status: ownersProvider.getStatus(),
+      progress: ownersProvider.getProgressString(),
+      files: this._getFilesWithStatuses(codeOwnerStatusMap,
+          ownersProvider.getFiles()),
     };
   }
 
-  async getSuggestedOwnersProgress() {
+  async getSuggestedOwnersProgress(suggestionsType) {
     const {codeOwnerStatusMap} = await this.getStatus();
+    const ownersProvider = this.ownersProviders[suggestionsType];
     return {
-      finished: this.ownersFetcher.getStatus() === FetchStatus.FINISHED,
-      status: this.ownersFetcher.getStatus(),
-      progress: this.ownersFetcher.getProgressString(),
-      suggestions: this._groupFilesByOwners(codeOwnerStatusMap,
-          this.ownersFetcher.getFiles()),
+      finished: ownersProvider.getStatus() === FetchStatus.FINISHED,
+      status: ownersProvider.getStatus(),
+      progress: ownersProvider.getProgressString(),
+      files: this._getFilesWithStatuses(codeOwnerStatusMap,
+          ownersProvider.getFiles()),
     };
   }
 
+  pauseSuggestedOwnersLoading(suggestionsType) {
+    this.ownersProviders[suggestionsType].pause();
+  }
+
+  resumeSuggestedOwnersLoading(suggestionsType) {
+    this.ownersProviders[suggestionsType].resume();
+  }
+
   _formatStatuses(statuses) {
     // convert the array of statuses to map between file path -> status
     return statuses.reduce((prev, cur) => {
@@ -500,81 +269,37 @@
     return;
   }
 
-  _groupFilesByOwners(codeOwnerStatusMap, files) {
-    // Note: for renamed or moved files, they will have two entries in the map
-    // we will treat them as two entries when group as well
-    const ownersFilesMap = new Map();
-    const failedToFetchFiles = new Set();
-    for (const file of files) {
-      const fileInfo = {
+  _getFilesWithStatuses(codeOwnerStatusMap, files) {
+    return files.map(file => {
+      return {
         path: file.path,
+        info: file.info,
         status: this._computeFileStatus(codeOwnerStatusMap, file.path),
       };
-      // for files failed to fetch, add them to the special group
-      if (file.info.error) {
-        failedToFetchFiles.add(fileInfo);
-        continue;
-      }
-
-      // do not include files still in fetching
-      if (!file.info.owners) {
-        continue;
-      }
-      const owners = [...file.info.owners];
-      const ownersKey = owners
-          .map(owner => owner.account._account_id)
-          .sort()
-          .join(',');
-      ownersFilesMap.set(
-          ownersKey,
-          ownersFilesMap.get(ownersKey) || {files: [], owners}
-      );
-      ownersFilesMap.get(ownersKey).files.push(fileInfo);
-    }
-    const groupedItems = [];
-    for (const ownersKey of ownersFilesMap.keys()) {
-      const groupName = this.getGroupName(ownersFilesMap.get(ownersKey).files);
-      groupedItems.push({
-        groupName,
-        files: ownersFilesMap.get(ownersKey).files,
-        owners: ownersFilesMap.get(ownersKey).owners,
-      });
-    }
-
-    if (failedToFetchFiles.size > 0) {
-      const failedFiles = [...failedToFetchFiles];
-      groupedItems.push({
-        groupName: this.getGroupName(failedFiles),
-        files: failedFiles,
-        error: new Error(
-            'Failed to fetch code owner info. Try to refresh the page.'),
-      });
-    }
-
-    return groupedItems;
+    });
   }
 
-  getGroupName(files) {
-    const fileName = files[0].path.split('/').pop();
-    return {
-      name: fileName,
-      prefix: files.length > 1 ? `+ ${files.length - 1} more` : '',
-    };
-  }
-
-  isOnLatestPatchset(patchsetId) {
+  _isOnNewerPatchset(patchsetId) {
     const latestRevision = this.change.revisions[this.change.current_revision];
-    return `${latestRevision._number}` === `${patchsetId}`;
+    return patchsetId > latestRevision._number;
   }
 
-  abort() {
-    this.ownersFetcher.abort();
-    const codeOwnerApi = new CodeOwnerApi(this.restApi);
-    this.codeOwnerCacheApi = new CodeOwnersCacheApi(codeOwnerApi, change);
+  _isOnOlderPatchset(patchsetId) {
+    const latestRevision = this.change.revisions[this.change.current_revision];
+    return patchsetId < latestRevision._number;
+  }
+
+  reset() {
+    for (const provider of Object.values(this.ownersProviders)) {
+      provider.reset();
+    }
+    const codeOwnersApi = new CodeOwnersApi(this.restApi);
+    this.codeOwnersCacheApi =
+        new CodeOwnersCacheApi(codeOwnersApi, this.change);
   }
 
   async getBranchConfig() {
-    return this.codeOwnerCacheApi.getBranchConfig();
+    return this.codeOwnersCacheApi.getBranchConfig();
   }
 
   async isCodeOwnerEnabled() {
@@ -582,7 +307,7 @@
         this.change.status === ChangeStatus.MERGED) {
       return false;
     }
-    const config = await this.codeOwnerCacheApi.getBranchConfig();
+    const config = await this.codeOwnersCacheApi.getBranchConfig();
     return config && !config.disabled;
   }
 
diff --git a/ui/owner-requirement.js b/ui/owner-requirement.js
index 1ad5b4b..6ba53b6 100644
--- a/ui/owner-requirement.js
+++ b/ui/owner-requirement.js
@@ -15,10 +15,10 @@
  * limitations under the License.
  */
 
-import {OwnerStatus} from './code-owners-service.js';
+import {OwnerStatus} from './code-owners-fetcher.js';
 import {CodeOwnersModelMixin} from './code-owners-model-mixin.js';
 import {showPluginFailedMessage} from './code-owners-banner.js';
-import {PluginState} from './code-owners-model.js';
+import {isPluginErrorState} from './code-owners-model.js';
 
 /**
  * Owner requirement control for `submit-requirement-item-code-owners` endpoint.
@@ -65,24 +65,27 @@
         </p>
         <template is="dom-if" if="[[!_isLoading]]">
           <template is="dom-if" if="[[!_pluginFailed(model.pluginStatus)]]">
-            <template is="dom-if" if="[[!model.branchConfig.no_code_owners_defined]]">              
-              <span>[[_computeStatusText(_statusCount, _isOverriden)]]</span>
-              <template is="dom-if" if="[[_overrideInfoUrl]]">
-                <a on-click="_reportDocClick" href="[[_overrideInfoUrl]]"
-                  target="_blank">
-                  <iron-icon icon="gr-icons:help-outline"
-                    title="Documentation for overriding code owners"></iron-icon>
-                </a>
-              </template>
-              <template is="dom-if" if="[[!_allApproved]]">
+            <template is="dom-if" if="[[!model.branchConfig.no_code_owners_defined]]">
+              <template is="dom-if" if="[[!_newerPatchsetUploaded]]">
+                <span>[[_computeStatusText(_statusCount, _isOverriden)]]</span>
+                <template is="dom-if" if="[[_overrideInfoUrl]]">
+                  <a on-click="_reportDocClick" href="[[_overrideInfoUrl]]"
+                    target="_blank">
+                    <iron-icon icon="gr-icons:help-outline"
+                      title="Documentation for overriding code owners"></iron-icon>
+                  </a>
+                </template>
                 <gr-button link on-click="_openReplyDialog">
-                Suggest owners
-              </gr-button>
+                  [[_getSuggestOwnersText(_statusCount)]]
+                </gr-button>
+              </template>
+              <template is="dom-if" if="[[_newerPatchsetUploaded]]">
+                <span>A newer patch set has been uploaded.</span>
               </template>
             </template>
-            <template is="dom-if" if="[[model.branchConfig.no_code_owners_defined]]">                
+            <template is="dom-if" if="[[model.branchConfig.no_code_owners_defined]]">
               <span>No code-owners file</span>
-              <a href="https://gerrit.googlesource.com/plugins/code-owners/+/master/resources/Documentation/user-guide.md#how-to-submit-changes-with-files-that-have-no-code-owners" target="_blank">
+              <a href="https://gerrit.googlesource.com/plugins/code-owners/+/HEAD/resources/Documentation/user-guide.md#how-to-submit-changes-with-files-that-have-no-code-owners" target="_blank">
                 <iron-icon icon="gr-icons:help-outline"
                   title="Documentation about submitting changes with files that have no code owners?"></iron-icon>
               </a>
@@ -101,14 +104,11 @@
   static get properties() {
     return {
       _statusCount: Object,
+      _newerPatchsetUploaded: Boolean,
       _isLoading: {
         type: Boolean,
-        computed: '_computeIsLoading(model.branchConfig, model.status, '
-            + 'model.userRole, model.pluginStatus)',
-      },
-      _allApproved: {
-        type: Boolean,
-        computed: '_computeAllApproved(_statusCount)',
+        computed: '_computeIsLoading(model.branchConfig, model.status, ' +
+            'model.userRole, model.pluginStatus)',
       },
       _isOverriden: {
         type: Boolean,
@@ -143,16 +143,18 @@
   }
 
   _pluginFailed(pluginStatus) {
-    return pluginStatus && pluginStatus.state === PluginState.Failed;
+    return pluginStatus && isPluginErrorState(pluginStatus.state);
   }
 
   _onStatusChanged(status, userRole) {
     if (!status || !userRole) {
       this._statusCount = undefined;
+      this._newerPatchsetUploaded = undefined;
       return;
     }
     const rawStatuses = status.rawStatuses;
     this._statusCount = this._getStatusCount(rawStatuses);
+    this._newerPatchsetUploaded = status.newerPatchsetUploaded;
     this.reporting.reportLifeCycle('owners-submit-requirement-summary-shown',
         {...this._statusCount, user_role: userRole});
   }
@@ -161,8 +163,8 @@
     if (!branchConfig) {
       return '';
     }
-    return branchConfig.general && branchConfig.general.override_info_url
-      ? branchConfig.general.override_info_url : '';
+    return branchConfig.general && branchConfig.general.override_info_url ?
+      branchConfig.general.override_info_url : '';
   }
 
   _computeIsOverriden(change, branchConfig) {
@@ -171,8 +173,7 @@
       return false;
     }
 
-
-    for(const requiredApprovalInfo of branchConfig['override_approval']) {
+    for (const requiredApprovalInfo of branchConfig['override_approval']) {
       const overridenLabel = requiredApprovalInfo.label;
       const overridenValue = Number(requiredApprovalInfo.value);
       if (isNaN(overridenValue)) continue;
@@ -189,9 +190,9 @@
     return false;
   }
 
-  _computeAllApproved(statusCount) {
-    return statusCount && statusCount.missing === 0
-            && statusCount.pending === 0;
+  _getSuggestOwnersText(statusCount) {
+    return statusCount && statusCount.missing === 0 ?
+      'Add owners' : 'Suggest owners';
   }
 
   _getStatusCount(rawStatuses) {
diff --git a/ui/owner-status-column.js b/ui/owner-status-column.js
index 31ad654..ae56d36 100644
--- a/ui/owner-status-column.js
+++ b/ui/owner-status-column.js
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import {OwnerStatus} from './code-owners-service.js';
+import {OwnerStatus} from './code-owners-fetcher.js';
 import {CodeOwnersModelMixin} from './code-owners-model-mixin.js';
 
 const MAGIC_FILES = ['/COMMIT_MSG', '/MERGE_LIST', '/PATCHSET_LEVEL'];
@@ -48,13 +48,17 @@
 };
 
 class BaseEl extends CodeOwnersModelMixin(Polymer.Element) {
-  computeHidden(change, patchRange) {
-    if ([change, patchRange].includes(undefined)) return true;
-    // if code-owners is not a submit requirement, don't show status column
-    if (change.requirements
-        && !change.requirements.find(r => r.type === 'code-owners')) {
+  computeHidden(change, patchRange, newerPatchsetUploaded) {
+    if ([change, patchRange, newerPatchsetUploaded].includes(undefined)) {
       return true;
     }
+    // if code-owners is not a submit requirement, don't show status column
+    if (change.requirements &&
+        !change.requirements.find(r => r.type === 'code-owners')) {
+      return true;
+    }
+
+    if (newerPatchsetUploaded) return true;
 
     const latestPatchset = change.revisions[change.current_revision];
     // only show if its comparing against base
@@ -97,7 +101,8 @@
       hidden: {
         type: Boolean,
         reflectToAttribute: true,
-        computed: 'computeHidden(change, patchRange)',
+        computed: 'computeHidden(change, patchRange, ' +
+          'model.status.newerPatchsetUploaded)',
       },
     };
   }
@@ -116,6 +121,7 @@
   static get properties() {
     return {
       path: String,
+      oldPath: String,
       patchRange: Object,
       hidden: {
         type: Boolean,
@@ -169,7 +175,7 @@
 
   static get observers() {
     return [
-      'computeStatusIcon(model.status, path)',
+      'computeStatusIcon(model.status, path, oldPath)',
     ];
   }
 
@@ -178,8 +184,8 @@
     this.modelLoader.loadStatus();
   }
 
-  computeStatusIcon(modelStatus, path) {
-    if ([modelStatus, path].includes(undefined)) return;
+  computeStatusIcon(modelStatus, path, oldPath) {
+    if ([modelStatus, path, oldPath].includes(undefined)) return;
     if (MAGIC_FILES.includes(path)) return;
 
     const codeOwnerStatusMap = modelStatus.codeOwnerStatusMap;
@@ -191,10 +197,10 @@
 
     const status = statusItem.status;
     let oldPathStatus = null;
-    if (statusItem.oldPath) {
-      const oldStatusItem = codeOwnerStatusMap.get(statusItem.oldPath);
+    if (oldPath !== path) {
+      const oldStatusItem = codeOwnerStatusMap.get(oldPath);
       if (!oldStatusItem) {
-        // should not happen
+        this.status = STATUS_CODE.ERROR;
       } else {
         oldPathStatus = oldStatusItem.status;
       }
@@ -204,9 +210,9 @@
     if (!oldPathStatus) {
       this.status = newPathStatus;
     } else {
-      this.status = newPathStatus === STATUS_CODE.APPROVED
-        ? this._computeStatus(oldPathStatus, /* oldPath= */ true)
-        : newPathStatus;
+      this.status = newPathStatus === STATUS_CODE.APPROVED ?
+        this._computeStatus(oldPathStatus, /* oldPath= */ true) :
+        newPathStatus;
     }
   }
 
diff --git a/ui/plugin.js b/ui/plugin.js
index 40bc4e5..124d986 100644
--- a/ui/plugin.js
+++ b/ui/plugin.js
@@ -21,17 +21,41 @@
 import {SuggestOwnersTrigger} from './suggest-owners-trigger.js';
 import {CodeOwnersBanner, CodeOwnersPluginStatusNotifier} from './code-owners-banner.js';
 
+// A temporary property for interop with find-owners plugin.
+// It should be set as earlier as possible, so find-owners plugin can check
+// it in Gerrit.install callback.
+window.__gerrit_code_owners_plugin = {
+  state: {
+    // See CodeOwnersPluginStatusNotifier._stateForFindOwnersPlugin for
+    // a list of possible values.
+    branchState: 'LOADING',
+  },
+  stateChanged: new EventTarget(),
+};
+
 Gerrit.install(plugin => {
   const restApi = plugin.restApi();
   const reporting = plugin.reporting();
 
   plugin.registerCustomComponent('banner', CodeOwnersBanner.is);
 
+  const stateForFindOwnerPluginChanged = evt => {
+    window.__gerrit_code_owners_plugin.state = evt.detail.value;
+    window.__gerrit_code_owners_plugin.stateChanged
+        .dispatchEvent(new CustomEvent('state-changed'));
+  };
+
   plugin.registerCustomComponent(
       'change-view-integration', CodeOwnersPluginStatusNotifier.is)
       .onAttached(view => {
         view.restApi = restApi;
         view.reporting = reporting;
+        view.addEventListener('_state-for-find-owners-plugin-changed',
+            stateForFindOwnerPluginChanged);
+      })
+      .onDetached(view => {
+        view.removeEventListener('_state-for-find-owners-plugin-changed',
+            stateForFindOwnerPluginChanged);
       });
 
   // owner status column / rows for file list
diff --git a/ui/suggest-owners-trigger.js b/ui/suggest-owners-trigger.js
index b2273ee..3b48b44 100644
--- a/ui/suggest-owners-trigger.js
+++ b/ui/suggest-owners-trigger.js
@@ -27,8 +27,7 @@
     return {
       hidden: {
         type: Boolean,
-        computed: '_computeHidden(model.pluginStatus,' +
-            'model.areAllFilesApproved, model.userRole, model.branchConfig)',
+        computed: '_computeHidden(model.pluginStatus, model.branchConfig)',
         reflectToAttribute: true,
       },
     };
@@ -56,7 +55,7 @@
           [[computeButtonText(model.showSuggestions)]]
         </gr-button>
         <span>
-          <a on-click="_reportDocClick" href="https://gerrit.googlesource.com/plugins/code-owners/+/master/resources/Documentation/how-to-use.md" target="_blank">
+          <a on-click="_reportDocClick" href="https://gerrit.googlesource.com/plugins/code-owners/+/HEAD/resources/Documentation/how-to-use.md" target="_blank">
             <iron-icon icon="gr-icons:help-outline" title="read documentation"></iron-icon>
           </a>
         </span>
@@ -71,19 +70,12 @@
     this.modelLoader.loadBranchConfig();
   }
 
-  _computeHidden(pluginStatus, allFilesApproved, userRole, branchConfig) {
-    if (pluginStatus === undefined ||
-        allFilesApproved === undefined ||
-        userRole === undefined ||
-        branchConfig === undefined) {
+  _computeHidden(pluginStatus, branchConfig) {
+    if (pluginStatus === undefined || branchConfig === undefined) {
       return true;
     }
-    if (branchConfig.no_code_owners_defined) return true;
-    if (pluginStatus.state === PluginState.Enabled) {
-      return allFilesApproved;
-    } else {
-      return true;
-    }
+    return !!branchConfig.no_code_owners_defined ||
+        pluginStatus.state !== PluginState.Enabled;
   }
 
   toggleControlContent() {
diff --git a/ui/suggest-owners-util.js b/ui/suggest-owners-util.js
new file mode 100644
index 0000000..fb18a0f
--- /dev/null
+++ b/ui/suggest-owners-util.js
@@ -0,0 +1,123 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {BestSuggestionsLimit} from './code-owners-model.js';
+
+/**
+ * For each file calculates owners to display and group all files by those
+ * owners. The function creates "fake" groups when one or more
+ * reviewers are included in all owners of a file, but none of reviewers is
+ * included in best reviewers for the file.
+ *
+ * Such situations are possible when user turns on "Show all owners", selects
+ * one of newly displayed owners and then turns off "Show all owners". Without
+ * "fake" groups a user can see inconsistent state in dialog.
+ */
+
+export function getDisplayOwnersGroups(files, allOwnersByPathMap,
+    reviewersIdSet, allowAllOwnersSubstition) {
+  const getDisplayOwnersFunc =
+      !allowAllOwnersSubstition || allOwnersByPathMap.size === 0 ||
+      reviewersIdSet.size === 0 ?
+        file => file.info.owners :
+        file => getDisplayOwners(file, allOwnersByPathMap, reviewersIdSet);
+  return groupFilesByOwners(files, getDisplayOwnersFunc);
+}
+
+function getDisplayOwners(file, allOwnersByPathMap, reviewersIdSet) {
+  const ownerSelected = owner => reviewersIdSet.has(owner.account._account_id);
+  const defaultOwners = file.info.owners;
+  if (!defaultOwners ||
+      defaultOwners.owned_by_all_users ||
+      defaultOwners.code_owners.some(ownerSelected)) {
+    return defaultOwners;
+  }
+  const allOwners = allOwnersByPathMap.get(file.path);
+  if (!allOwners) return defaultOwners;
+  if (allOwners.owned_by_all_users) return allOwners;
+  const selectedAllOwners = allOwners.code_owners.filter(ownerSelected);
+  if (selectedAllOwners.length === 0) return defaultOwners;
+  return {
+    code_owners: selectedAllOwners.slice(0, BestSuggestionsLimit),
+  };
+}
+
+function groupFilesByOwners(files, getDisplayOwnersFunc) {
+  // Note: for renamed or moved files, they will have two entries in the map
+  // we will treat them as two entries when group as well
+  const ownersFilesMap = new Map();
+  const failedToFetchFiles = new Set();
+  for (const file of files) {
+    // for files failed to fetch, add them to the special group
+    if (file.info.error) {
+      failedToFetchFiles.add(file);
+      continue;
+    }
+
+    // do not include files still in fetching
+    if (!file.info.owners) {
+      continue;
+    }
+    const displayOwners = getDisplayOwnersFunc(file);
+
+    const ownersKey = getOwnersGroupKey(displayOwners);
+    ownersFilesMap.set(
+        ownersKey,
+        ownersFilesMap.get(ownersKey) || {files: [], owners: displayOwners}
+    );
+    ownersFilesMap.get(ownersKey).files.push(file);
+  }
+  const groupedItems = [];
+  for (const ownersKey of ownersFilesMap.keys()) {
+    const groupName = getGroupName(ownersFilesMap.get(ownersKey).files);
+    groupedItems.push({
+      groupName,
+      files: ownersFilesMap.get(ownersKey).files,
+      owners: ownersFilesMap.get(ownersKey).owners,
+    });
+  }
+
+  if (failedToFetchFiles.size > 0) {
+    const failedFiles = [...failedToFetchFiles];
+    groupedItems.push({
+      groupName: getGroupName(failedFiles),
+      files: failedFiles,
+      error: new Error(
+          'Failed to fetch code owner info. Try to refresh the page.'),
+    });
+  }
+  return groupedItems;
+}
+
+function getOwnersGroupKey(owners) {
+  if (owners.owned_by_all_users) {
+    return '__owned_by_all_users__';
+  }
+  const code_owners = owners.code_owners;
+  return code_owners
+      .map(owner => owner.account._account_id)
+      .sort()
+      .join(',');
+}
+
+function getGroupName(files) {
+  const fileName = files[0].path.split('/').pop();
+  return {
+    name: fileName,
+    prefix: files.length > 1 ? `+ ${files.length - 1} more` : '',
+  };
+}
diff --git a/ui/suggest-owners.js b/ui/suggest-owners.js
index e0bf8a8..f07003f 100644
--- a/ui/suggest-owners.js
+++ b/ui/suggest-owners.js
@@ -15,7 +15,11 @@
  * limitations under the License.
  */
 import {CodeOwnersModelMixin} from './code-owners-model-mixin.js';
-import {SuggestionsState} from './code-owners-model.js';
+import {
+  SuggestionsState,
+  SuggestionsType,
+} from './code-owners-model.js';
+import {getDisplayOwnersGroups} from './suggest-owners-util.js';
 
 const SUGGESTION_POLLING_INTERVAL = 1000;
 
@@ -98,12 +102,9 @@
           border: 1px solid var(--view-background-color);
           border-radius: var(--border-radius);
           box-shadow: var(--elevation-level-1);
-          padding: var(--spacing-s);
+          padding: 0 var(--spacing-m);
           margin: var(--spacing-m) 0;
         }
-        p.loading {
-          text-align: center;
-        }
         .loadingSpin {
           display: inline-block;
         }
@@ -115,18 +116,31 @@
           max-height: 300px;
           overflow-y: auto;
         }
-        .suggestion-row {
+        .flex-break {
+          height: 0;
+          flex-basis: 100%;
+        }
+        .suggestion-row, .show-all-owners-row {
           display: flex;
           flex-direction: row;
           align-items: flex-start;
-          border-bottom: 1px solid var(--border-color);
+        }
+        .suggestion-row {
+          flex-wrap: wrap;
+          border-top: 1px solid var(--border-color);
           padding: var(--spacing-s) 0;
         }
-        .suggestion-row:last-of-type {
-          border-bottom: none;
+        .show-all-owners-row {
+          padding: var(--spacing-m) var(--spacing-xl) var(--spacing-s) 0;
+        }
+        .show-all-owners-row .loading {
+          padding: 0;
+        }
+        .show-all-owners-row .show-all-label {
+          margin-left: auto; /* align label to the right */
         }
         .suggestion-row-indicator {
-          margin-right: var(--spacing-m);
+          margin-right: var(--spacing-s);
           visibility: hidden;
           line-height: 26px;
         }
@@ -135,13 +149,18 @@
         }
         .suggestion-row-indicator[visible] iron-icon {
           color: var(--link-color);
+          vertical-align: top;
+          position: relative;
+          --iron-icon-height: 18px;
+          --iron-icon-width: 18px;
+          top: 4px; /* (26-18)/2 - 26px line-height and 18px icon */
         }
         .suggestion-group-name {
-          width: 200px;
+          width: 260px;
           line-height: 26px;
           text-overflow: ellipsis;
           overflow: hidden;
-          padding-right: var(--spacing-l);
+          padding-right: var(--spacing-s);
           white-space: nowrap;
         }
         .group-name-content {
@@ -159,14 +178,25 @@
           color: var(--deemphasized-text-color);
         }
         .suggested-owners {
+          --account-gap: var(--spacing-xs);
+          --negative-account-gap: calc(-1*var(--account-gap));
+          margin: var(--negative-account-gap) 0 0 var(--negative-account-gap);
           flex: 1;
         }
         .fetch-error-content,
+        .owned-by-all-users-content,
         .no-owners-content {
           line-height: 26px;
           flex: 1;
           padding-left: var(--spacing-m);
         }
+
+        .owned-by-all-users-content iron-icon {
+          width: 16px;
+          height: 16px;
+          padding-top: 5px;
+        }
+        
         .fetch-error-content {
           color: var(--error-text-color);
         }
@@ -176,15 +206,20 @@
         .no-owners-content a iron-icon {
           width: 16px;
           height: 16px;
+          padding-top: 5px;
         }
         gr-account-label {
-          background-color: var(--background-color-tertiary);
           display: inline-block;
           padding: var(--spacing-xs) var(--spacing-m);
           user-select: none;
           border: 1px solid transparent;
           --label-border-radius: 8px;
-          --account-max-length: 100px;
+          /* account-max-length defines the max text width inside account-label.
+           With 60px the gr-account-label always has width <= 100px and 5 labels
+           are always fit in a single row */
+          --account-max-length: 60px;
+          border: 1px solid var(--border-color);
+          margin: var(--account-gap) 0 0 var(--account-gap)
         }
         gr-account-label:focus {
           outline: none;
@@ -214,10 +249,22 @@
           margin-right: var(--spacing-m);
         }
       </style>
-      <p class="loading" hidden="[[!isLoading]]">
-        <span class="loadingSpin"></span>
-        [[progressText]]
-      </p>
+      <ul class="suggestion-container">
+        <li class="show-all-owners-row">
+          <p class="loading" hidden="[[!isLoading]]">
+            <span class="loadingSpin"></span>
+            [[progressText]]
+          </p>
+          <label class="show-all-label">
+            <input
+              id="showAllOwnersCheckbox"
+              type="checkbox"
+              checked="{{_showAllOwners::change}}"
+            />
+            Show all owners
+          </label>
+        </li>
+      </ul>
       <ul class="suggestion-container">
         <template
           is="dom-repeat"
@@ -261,31 +308,41 @@
               </div>
             </template>
             <template is="dom-if" if="[[!suggestion.error]]">
-              <template is="dom-if" if="[[!suggestion.owners.length]]">
+              <template is="dom-if" if="[[!_areOwnersFound(suggestion.owners)]]">
                 <div class="no-owners-content">
                   <span>Not found</span>
-                  <a on-click="_reportDocClick" href="https://gerrit.googlesource.com/plugins/code-owners/+/master/resources/Documentation/how-to-use.md#no-code-owners-found" target="_blank">
+                  <a on-click="_reportDocClick" href="https://gerrit.googlesource.com/plugins/code-owners/+/HEAD/resources/Documentation/how-to-use.md#no-code-owners-found" target="_blank">
                     <iron-icon icon="gr-icons:help-outline" title="read documentation"></iron-icon>
                   </a>
                 </div>
               </template>
-              <ul class="suggested-owners">
-                <template
-                  is="dom-repeat"
-                  items="[[suggestion.owners]]"
-                  as="owner"
-                  index-as="ownerIndex"
-                >
-                  <gr-account-label
-                    data-suggestion-index$="[[suggestionIndex]]"
-                    data-owner-index$="[[ownerIndex]]"
-                    account="[[owner.account]]"
-                    hide-hovercard
-                    selected$="[[isSelected(owner)]]"
-                    on-click="toggleAccount">
-                  </gr-account-label>
+              <template is="dom-if" if="[[suggestion.owners.owned_by_all_users]]">
+                <div class="owned-by-all-users-content">
+                  <iron-icon icon="gr-icons:info" ></iron-icon>
+                  <span>[[_getOwnedByAllUsersContent(isLoading, suggestedOwners)]]</span>
+                </div>
+              </template>
+              <template is="dom-if" if="[[!suggestion.owners.owned_by_all_users]]">
+                <template is="dom-if" if="[[_showAllOwners]]">
+                  <div class="flex-break"></div>
                 </template>
-              </ul>
+                <ul class="suggested-owners">
+                  <template
+                    is="dom-repeat"
+                    items="[[suggestion.owners.code_owners]]"
+                    as="owner"
+                    index-as="ownerIndex"
+                  ><!--
+                    --><gr-account-label
+                      data-suggestion-index$="[[suggestionIndex]]"
+                      data-owner-index$="[[ownerIndex]]"
+                      account="[[owner.account]]"
+                      selected$="[[isSelected(owner)]]"
+                      on-click="toggleAccount">
+                    </gr-account-label><!--
+                --></template>
+                </ul>
+              </template>
             </template>
           </li>
         </template>
@@ -300,8 +357,7 @@
         type: Boolean,
         value: true,
         reflectToAttribute: true,
-        computed: '_isHidden(model.areAllFilesApproved, ' +
-            'model.showSuggestions)',
+        computed: '_isHidden(model.showSuggestions)',
       },
       suggestedOwners: Array,
       isLoading: {
@@ -311,7 +367,21 @@
       reviewers: {
         type: Array,
       },
+      _reviewersIdSet: {
+        type: Object,
+        computed: '_getReviewersIdSet(reviewers)',
+      },
       pendingReviewers: Array,
+      _showAllOwners: {
+        type: Boolean,
+        value: false,
+        observer: '_showAllOwnersChanged',
+      },
+      _allOwnersByPathMap: {
+        type: Object,
+        computed:
+            `_getOwnersByPathMap(model.suggestionsByTypes.${SuggestionsType.ALL_SUGGESTIONS}.files)`,
+      },
     };
   }
 
@@ -319,15 +389,39 @@
     return [
       '_onReviewerChanged(reviewers)',
       '_onShowSuggestionsChanged(model.showSuggestions)',
-      '_onSuggestionsStateChanged(model.suggestionsState)',
-      '_onSuggestionsChanged(model.suggestions, model.suggestionsState)',
-      '_onSuggestionsLoadProgressChanged(model.suggestionsLoadProgress)',
+      '_onShowSuggestionsTypeChanged(model.showSuggestions,' +
+        'model.selectedSuggestionsType)',
+      '_onSuggestionsStateChanged(model.selectedSuggestions.state)',
+      '_onSuggestionsFilesChanged(model.selectedSuggestions.files, ' +
+        '_allOwnersByPathMap, _reviewersIdSet, model.selectedSuggestionsType,' +
+        'model.selectedSuggestions.state)',
+      '_onSuggestionsLoadProgressChanged(' +
+        'model.selectedSuggestions.loadProgress)',
     ];
   }
 
+  constructor() {
+    super();
+    // To prevent multiple reporting when switching back and forth showAllOwners
+    this.reportedEvents = {};
+    for (const suggestionType of Object.values(SuggestionsType)) {
+      this.reportedEvents[suggestionType] = {
+        fetchingStart: false,
+        fetchingFinished: false,
+      };
+    }
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    this._stopUpdateProgressTimer();
+    if (this.modelLoader) {
+      this.modelLoader.pauseActiveSuggestedOwnersLoading();
+    }
+  }
+
   _onShowSuggestionsChanged(showSuggestions) {
-    if (!showSuggestions ||
-        this.model.suggestionsLoadProgress === SuggestionsState.NotLoaded) {
+    if (!showSuggestions) {
       return;
     }
     // this is more of a hack to let review input lose focus
@@ -338,20 +432,31 @@
     // Can not use `this.async` as it's only available in
     // legacy element mixin which not used in this plugin.
     Polymer.Async.timeOut.run(() => this.click(), 100);
-
-    this.modelLoader.loadSuggestions();
-    this.reporting.reportLifeCycle('owners-suggestions-fetching-start');
   }
 
-  disconnectedCallback() {
-    super.disconnectedCallback();
-    this._stopUpdateProgressTimer();
+  _onShowSuggestionsTypeChanged(showSuggestion, selectedSuggestionsType) {
+    if (!showSuggestion) {
+      this.modelLoader.pauseActiveSuggestedOwnersLoading();
+      return;
+    }
+    this.modelLoader.loadSuggestions(selectedSuggestionsType);
+    // The progress is updated at the next _progressUpdateTimer tick.
+    // Without excplicit call to updateLoadSuggestionsProgress it looks like
+    // a slow reaction to checkbox.
+    this.modelLoader.updateLoadSelectedSuggestionsProgress();
+
+    if (!this.reportedEvents[selectedSuggestionsType].fetchingStart) {
+      this.reportedEvents[selectedSuggestionsType].fetchingStart = true;
+      this.reporting.reportLifeCycle('owners-suggestions-fetching-start', {
+        type: selectedSuggestionsType,
+      });
+    }
   }
 
   _startUpdateProgressTimer() {
     if (this._progressUpdateTimer) return;
     this._progressUpdateTimer = setInterval(() => {
-      this.modelLoader.updateLoadSuggestionsProgress();
+      this.modelLoader.updateLoadSelectedSuggestionsProgress();
     }, SUGGESTION_POLLING_INTERVAL);
   }
 
@@ -369,10 +474,8 @@
     this.isLoading = state === SuggestionsState.Loading;
   }
 
-  _isHidden(allFilesApproved, showSuggestions) {
-    if (!showSuggestions) return true;
-    // if all approved, no need to show the container
-    return allFilesApproved === undefined || !!allFilesApproved;
+  _isHidden(showSuggestions) {
+    return !showSuggestions;
   }
 
   loadPropertiesAfterModelChanged() {
@@ -381,19 +484,43 @@
     this.modelLoader.loadAreAllFilesApproved();
   }
 
-  _onSuggestionsChanged(suggestions, suggestionsState) {
+  _getReviewersIdSet(reviewers) {
+    return new Set((reviewers || []).map(account => account._account_id));
+  }
+
+  _onSuggestionsFilesChanged(files, allOwnersByPathMap, reviewersIdSet,
+      selectedSuggestionsType, suggestionsState) {
+    if (files === undefined || allOwnersByPathMap === undefined ||
+        reviewersIdSet === undefined || selectedSuggestionsType === undefined ||
+        suggestionsState === undefined) return;
+
+    const groups = getDisplayOwnersGroups(
+        files, allOwnersByPathMap, reviewersIdSet,
+        selectedSuggestionsType !== SuggestionsType.ALL_SUGGESTIONS);
     // The updateLoadSuggestionsProgress method also updates suggestions
-    this._updateSuggestions(suggestions || []);
+    this._updateSuggestions(groups);
     this._updateAllChips(this._currentReviewers);
-    if (!suggestions || suggestionsState !== SuggestionsState.Loaded) return;
-    const reportDetails = suggestions.reduce((details, cur) => {
-      details.totalGroups++;
-      details.stats.push([cur.files.length,
-        cur.owners ? cur.owners.length : 0]);
-      return details;
-    }, {totalGroups: 0, stats: []});
-    this.reporting.reportLifeCycle(
-        'owners-suggestions-fetching-finished', reportDetails);
+
+    if (suggestionsState !== SuggestionsState.Loaded) return;
+    if (!this.reportedEvents[selectedSuggestionsType].fetchingFinished) {
+      this.reportedEvents[selectedSuggestionsType].fetchingFinished = true;
+      const reportDetails = groups.reduce((details, cur) => {
+        details.totalGroups++;
+        details.stats.push([cur.files.length,
+          cur.owners && cur.owners.code_owners ?
+            cur.owners.code_owners.length : 0]);
+        return details;
+      }, {totalGroups: 0, stats: [], type: selectedSuggestionsType});
+      this.reporting.reportLifeCycle(
+          'owners-suggestions-fetching-finished', reportDetails);
+    }
+  }
+
+  _getOwnersByPathMap(files) {
+    return new Map((files || [])
+        .filter(file => !file.info.error && file.info.owners)
+        .map(file => [file.path, file.info.owners])
+    );
   }
 
   _onSuggestionsLoadProgressChanged(progress) {
@@ -402,9 +529,16 @@
 
   _updateSuggestions(suggestions) {
     // update group names and files, no modification on owners or error
-    this.suggestedOwners = suggestions.map(suggestion => {
+    const suggestedOwners = suggestions.map(suggestion => {
       return this.formatSuggestionInfo(suggestion);
     });
+    // move owned_by_all_users to the bottom:
+    const index = suggestedOwners
+        .findIndex(suggestion => suggestion.owners.owned_by_all_users);
+    if (index >= 0) {
+      suggestedOwners.push(suggestedOwners.splice(index, 1)[0]);
+    }
+    this.suggestedOwners = suggestedOwners;
   }
 
   _onReviewerChanged(reviewers) {
@@ -416,17 +550,29 @@
     const res = {};
     res.groupName = suggestion.groupName;
     res.files = suggestion.files.slice();
-    res.owners = (suggestion.owners || []).map(owner => {
-      const updatedOwner = {...owner};
-      const reviewers = this.change.reviewers.REVIEWER;
-      if (
-        reviewers &&
-        reviewers.find(reviewer => reviewer._account_id === owner._account_id)
-      ) {
-        updatedOwner.selected = true;
-      }
-      return updatedOwner;
-    });
+    if (suggestion.owners) {
+      const codeOwners = (suggestion.owners.code_owners || []).map(owner => {
+        const updatedOwner = {...owner};
+        const reviewers = this.change.reviewers.REVIEWER;
+        if (reviewers &&
+            reviewers.find(
+                reviewer => reviewer._account_id === owner._account_id)
+        ) {
+          updatedOwner.selected = true;
+        }
+        return updatedOwner;
+      });
+      res.owners = {
+        owned_by_all_users: !!suggestion.owners.owned_by_all_users,
+        code_owners: codeOwners,
+      };
+    } else {
+      res.owners = {
+        owned_by_all_users: false,
+        code_owners: [],
+      };
+    }
+
     res.error = suggestion.error;
     return res;
   }
@@ -462,7 +608,7 @@
   toggleAccount(e) {
     const grAccountLabel = e.currentTarget;
     const owner = this.suggestedOwners[grAccountLabel.dataset.suggestionIndex]
-        .owners[grAccountLabel.dataset.ownerIndex];
+        .owners.code_owners[grAccountLabel.dataset.ownerIndex];
     if (this.isSelected(owner)) {
       this.removeAccount(owner);
     } else {
@@ -475,13 +621,11 @@
     // update all occurences
     this.suggestedOwners.forEach((suggestion, sId) => {
       let hasSelected = false;
-      suggestion.owners.forEach((owner, oId) => {
-        if (
-          accounts.some(account => account._account_id
-              === owner.account._account_id)
-        ) {
+      suggestion.owners.code_owners.forEach((owner, oId) => {
+        if (accounts.some(
+            account => account._account_id === owner.account._account_id)) {
           this.set(
-              ['suggestedOwners', sId, 'owners', oId],
+              ['suggestedOwners', sId, 'owners', 'code_owners', oId],
               {...owner,
                 selected: true,
               }
@@ -489,11 +633,17 @@
           hasSelected = true;
         } else {
           this.set(
-              ['suggestedOwners', sId, 'owners', oId],
+              ['suggestedOwners', sId, 'owners', 'code_owners', oId],
               {...owner, selected: false}
           );
         }
       });
+      const nonServiceUser = account =>
+        !account.tags || account.tags.indexOf('SERVICE_USER') < 0;
+      if (suggestion.owners.owned_by_all_users &&
+          accounts.some(nonServiceUser)) {
+        hasSelected = true;
+      }
       this.set(['suggestedOwners', sId, 'hasSelected'], hasSelected);
     });
   }
@@ -506,6 +656,28 @@
     this.reporting.reportInteraction('code-owners-doc-click',
         {section: 'no owners found'});
   }
+
+  _areOwnersFound(owners) {
+    return owners.code_owners.length > 0 || !!owners.owned_by_all_users;
+  }
+
+  _getOwnedByAllUsersContent(isLoading, suggestedOwners) {
+    if (isLoading) {
+      return 'Any user can approve';
+    }
+    // If all users own all the files in the change suggestedOwners.length === 1
+    // (suggestedOwners - collection of owners groupbed by owners)
+    return suggestedOwners && suggestedOwners.length === 1 ?
+      'Any user can approve. Please select a user manually' :
+      'Any user from the other files can approve';
+  }
+
+  _showAllOwnersChanged(showAll) {
+    // The first call to this method happens before model is set.
+    if (!this.model) return;
+    this.model.setSelectedSuggestionType(showAll ?
+      SuggestionsType.ALL_SUGGESTIONS : SuggestionsType.BEST_SUGGESTIONS);
+  }
 }
 
 customElements.define(SuggestOwners.is, SuggestOwners);