Merge branch 'stable-3.3'

* stable-3.3:
  Remove the bug-report link from code-owners

Signed-off-by: Edwin Kempin <ekempin@google.com>
Change-Id: Id8613eda43ba5ef08952b6a15f365537b9d4bf37
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 6222b7d..9e15746 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,