Merge changes If541bc32,I5a34a35f,I3ac34367

* changes:
  Use PerThreadCache to create code-owners plugin config only once per request
  Reduce number of CodeOwnersPluginConfiguration#getProjectConfig calls
  Refactor config reading as preparation to reduce plugin config loads
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java b/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java
index 34f4e0d..2824246 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java
@@ -171,7 +171,8 @@
     String quotedFileExtension =
         Pattern.quote(
             codeOwnersPluginConfiguration
-                .getFileExtension(project)
+                .getProjectConfig(project)
+                .getFileExtension()
                 .map(ext -> "." + ext)
                 .orElse(""));
     String nameExtension = "(\\w)+";
@@ -188,7 +189,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
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java b/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
index 3bdb518..c22dafe 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
@@ -19,6 +19,7 @@
 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.CodeOwnersPluginConfigSnapshot;
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ServerInitiated;
@@ -32,6 +33,7 @@
   protected void configure() {
     factory(CodeOwnersUpdate.Factory.class);
     factory(CodeOwnerConfigScanner.Factory.class);
+    factory(CodeOwnersPluginConfigSnapshot.Factory.class);
 
     DynamicMap.mapOf(binder(), CodeOwnerBackend.class);
 
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/ChangedFiles.java b/java/com/google/gerrit/plugins/codeowners/backend/ChangedFiles.java
index 787e452..cf88e62 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/ChangedFiles.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/ChangedFiles.java
@@ -127,7 +127,7 @@
         repoConfig,
         revWalk,
         revCommit,
-        codeOwnersPluginConfiguration.getMergeCommitStrategy(project));
+        codeOwnersPluginConfiguration.getProjectConfig(project).getMergeCommitStrategy());
   }
 
   public ImmutableSet<ChangedFile> compute(
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
index 45023fc..c25fe52 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfigSnapshot;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.plugins.codeowners.backend.config.RequiredApproval;
 import com.google.gerrit.plugins.codeowners.common.ChangedFile;
@@ -234,13 +235,14 @@
           "prepare stream to compute file statuses (project = %s, change = %d)",
           changeNotes.getProjectName(), changeNotes.getChangeId().get());
 
-      if (codeOwnersPluginConfiguration.arePureRevertsExempted(changeNotes.getProjectName())
-          && isPureRevert(changeNotes)) {
+      CodeOwnersPluginConfigSnapshot codeOwnersConfig =
+          codeOwnersPluginConfiguration.getProjectConfig(changeNotes.getProjectName());
+
+      if (codeOwnersConfig.arePureRevertsExempted() && isPureRevert(changeNotes)) {
         return getAllPathsAsApproved(changeNotes, changeNotes.getCurrentPatchSet());
       }
 
-      boolean enableImplicitApprovalFromUploader =
-          codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(changeNotes.getProjectName());
+      boolean enableImplicitApprovalFromUploader = codeOwnersConfig.areImplicitApprovalsEnabled();
       Account.Id patchSetUploader = changeNotes.getCurrentPatchSet().uploader();
       logger.atFine().log(
           "patchSetUploader = %d, implicit approval from uploader is %s",
@@ -249,12 +251,10 @@
       ImmutableList<PatchSetApproval> currentPatchSetApprovals =
           getCurrentPatchSetApprovals(changeNotes);
 
-      RequiredApproval requiredApproval =
-          codeOwnersPluginConfiguration.getRequiredApproval(changeNotes.getProjectName());
+      RequiredApproval requiredApproval = codeOwnersConfig.getRequiredApproval();
       logger.atFine().log("requiredApproval = %s", requiredApproval);
 
-      ImmutableSet<RequiredApproval> overrideApprovals =
-          codeOwnersPluginConfiguration.getOverrideApproval(changeNotes.getProjectName());
+      ImmutableSet<RequiredApproval> overrideApprovals = codeOwnersConfig.getOverrideApproval();
       boolean hasOverride =
           hasOverride(currentPatchSetApprovals, overrideApprovals, changeNotes, patchSetUploader);
       logger.atFine().log(
@@ -286,8 +286,7 @@
               currentPatchSetApprovals, requiredApproval, changeNotes, patchSetUploader);
       logger.atFine().log("reviewers = %s, approvers = %s", reviewerAccountIds, approverAccountIds);
 
-      FallbackCodeOwners fallbackCodeOwners =
-          codeOwnersPluginConfiguration.getFallbackCodeOwners(branch.project());
+      FallbackCodeOwners fallbackCodeOwners = codeOwnersConfig.getFallbackCodeOwners();
 
       CodeOwnerConfigHierarchy codeOwnerConfigHierarchy = codeOwnerConfigHierarchyProvider.get();
       return changedFiles
@@ -339,8 +338,10 @@
           changeNotes.getChangeId().get(),
           patchSet.id().get());
 
-      RequiredApproval requiredApproval =
-          codeOwnersPluginConfiguration.getRequiredApproval(changeNotes.getProjectName());
+      CodeOwnersPluginConfigSnapshot codeOwnersConfig =
+          codeOwnersPluginConfiguration.getProjectConfig(changeNotes.getProjectName());
+
+      RequiredApproval requiredApproval = codeOwnersConfig.getRequiredApproval();
       logger.atFine().log("requiredApproval = %s", requiredApproval);
 
       BranchNameKey branch = changeNotes.getChange().getDest();
@@ -348,8 +349,7 @@
       logger.atFine().log("dest branch %s has revision %s", branch.branch(), revision.name());
 
       boolean isProjectOwner = isProjectOwner(changeNotes.getProjectName(), accountId);
-      FallbackCodeOwners fallbackCodeOwners =
-          codeOwnersPluginConfiguration.getFallbackCodeOwners(branch.project());
+      FallbackCodeOwners fallbackCodeOwners = codeOwnersConfig.getFallbackCodeOwners();
       logger.atFine().log(
           "fallbackCodeOwner = %s, isProjectOwner = %s", fallbackCodeOwners, isProjectOwner);
       if (fallbackCodeOwners.equals(FallbackCodeOwners.PROJECT_OWNERS) && isProjectOwner) {
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScanner.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScanner.java
index 60d1220..9e15746 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScanner.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScanner.java
@@ -92,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());
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScanner.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScanner.java
index e28625e..4df1696 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScanner.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScanner.java
@@ -106,7 +106,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);
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java
index 1fece0d..e7e18e5 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java
@@ -191,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());
   }
 
   /**
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSubmitRule.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSubmitRule.java
index 2dc2d37..0683b25 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSubmitRule.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerSubmitRule.java
@@ -82,7 +82,9 @@
             "run code owner submit rule (project = %s, change = %d)",
             changeData.project().get(), changeData.getId().get());
 
-        if (codeOwnersPluginConfiguration.isDisabled(changeData.change().getDest())) {
+        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();
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwners.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwners.java
index 9315523..4247849 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwners.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwners.java
@@ -54,7 +54,9 @@
     requireNonNull(revision, "revision");
     codeOwnerMetrics.countCodeOwnerConfigReads.increment();
     CodeOwnerBackend codeOwnerBackend =
-        codeOwnersPluginConfiguration.getBackend(codeOwnerConfigKey.branchNameKey());
+        codeOwnersPluginConfiguration
+            .getProjectConfig(codeOwnerConfigKey.project())
+            .getBackend(codeOwnerConfigKey.branchNameKey().branch());
     return codeOwnerBackend.getCodeOwnerConfig(codeOwnerConfigKey, revision);
   }
 
@@ -63,7 +65,9 @@
     requireNonNull(codeOwnerConfigKey, "codeOwnerConfigKey");
     codeOwnerMetrics.countCodeOwnerConfigReads.increment();
     CodeOwnerBackend codeOwnerBackend =
-        codeOwnersPluginConfiguration.getBackend(codeOwnerConfigKey.branchNameKey());
+        codeOwnersPluginConfiguration
+            .getProjectConfig(codeOwnerConfigKey.project())
+            .getBackend(codeOwnerConfigKey.branchNameKey().branch());
     return codeOwnerBackend.getCodeOwnerConfig(codeOwnerConfigKey, /* revision= */ null);
   }
 
@@ -82,7 +86,9 @@
   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);
   }
 
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java
index 8e57412..a7c2f27 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java
@@ -19,13 +19,13 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.BranchNameKey;
 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.plugins.codeowners.backend.config.CodeOwnersPluginConfigSnapshot;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.plugins.codeowners.util.JgitPath;
 import com.google.gerrit.server.ChangeMessagesUtil;
@@ -88,10 +88,11 @@
   public void onReviewersAdded(Event event) {
     Change.Id changeId = Change.id(event.getChange()._number);
     Project.NameKey projectName = Project.nameKey(event.getChange().project);
-    BranchNameKey branchNameKey = BranchNameKey.create(projectName, event.getChange().branch);
 
-    if (codeOwnersPluginConfiguration.isDisabled(branchNameKey)
-        || codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(projectName) <= 0) {
+    CodeOwnersPluginConfigSnapshot codeOwnersConfig =
+        codeOwnersPluginConfiguration.getProjectConfig(projectName);
+    if (codeOwnersConfig.isDisabled(event.getChange().branch)
+        || codeOwnersConfig.getMaxPathsInChangeMessages() <= 0) {
       return;
     }
 
@@ -176,7 +177,7 @@
               reviewerAccount.getName()));
 
       int maxPathsInChangeMessage =
-          codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(projectName);
+          codeOwnersPluginConfiguration.getProjectConfig(projectName).getMaxPathsInChangeMessages();
       if (ownedPaths.size() <= maxPathsInChangeMessage) {
         appendPaths(message, ownedPaths.stream());
       } else {
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersUpdate.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersUpdate.java
index d2a7f01..5e41d70 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersUpdate.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersUpdate.java
@@ -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/OnCodeOwnerApproval.java b/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerApproval.java
index 1cf59ce..7f2cd2b 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerApproval.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerApproval.java
@@ -20,6 +20,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfigSnapshot;
 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;
@@ -69,7 +70,9 @@
       PatchSet patchSet,
       Map<String, Short> oldApprovals,
       Map<String, Short> approvals) {
-    if (codeOwnersPluginConfiguration.isDisabled(changeNotes.getChange().getDest())) {
+    CodeOwnersPluginConfigSnapshot codeOwnersConfig =
+        codeOwnersPluginConfiguration.getProjectConfig(changeNotes.getProjectName());
+    if (codeOwnersConfig.isDisabled(changeNotes.getChange().getDest().branch())) {
       return Optional.empty();
     }
 
@@ -78,8 +81,7 @@
       return Optional.empty();
     }
 
-    RequiredApproval requiredApproval =
-        codeOwnersPluginConfiguration.getRequiredApproval(changeNotes.getProjectName());
+    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
@@ -99,8 +101,9 @@
       Map<String, Short> oldApprovals,
       Map<String, Short> approvals,
       RequiredApproval requiredApproval) {
-    int maxPathsInChangeMessage =
-        codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(changeNotes.getProjectName());
+    CodeOwnersPluginConfigSnapshot codeOwnersConfig =
+        codeOwnersPluginConfiguration.getProjectConfig(changeNotes.getProjectName());
+    int maxPathsInChangeMessage = codeOwnersConfig.getMaxPathsInChangeMessages();
     if (maxPathsInChangeMessage <= 0) {
       return Optional.empty();
     }
@@ -137,7 +140,7 @@
     }
 
     boolean hasImplicitApprovalByUser =
-        codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(changeNotes.getProjectName())
+        codeOwnersConfig.areImplicitApprovalsEnabled()
             && patchSet.uploader().equals(user.getAccountId());
 
     boolean noLongerExplicitlyApproved = false;
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerOverride.java b/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerOverride.java
index 8a8c702..c06e760 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerOverride.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/OnCodeOwnerOverride.java
@@ -21,6 +21,7 @@
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfigSnapshot;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.plugins.codeowners.backend.config.RequiredApproval;
 import com.google.gerrit.server.IdentifiedUser;
@@ -57,7 +58,9 @@
       PatchSet patchSet,
       Map<String, Short> oldApprovals,
       Map<String, Short> approvals) {
-    if (codeOwnersPluginConfiguration.isDisabled(changeNotes.getChange().getDest())) {
+    CodeOwnersPluginConfigSnapshot codeOwnersConfig =
+        codeOwnersPluginConfiguration.getProjectConfig(changeNotes.getProjectName());
+    if (codeOwnersConfig.isDisabled(changeNotes.getChange().getDest().branch())) {
       return Optional.empty();
     }
 
@@ -67,7 +70,7 @@
     }
 
     ImmutableList<RequiredApproval> overrideApprovals =
-        codeOwnersPluginConfiguration.getOverrideApproval(changeNotes.getProjectName()).stream()
+        codeOwnersConfig.getOverrideApproval().stream()
             .sorted(comparing(RequiredApproval::toString))
             .collect(toImmutableList());
 
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java
index c382801..91b0f6f 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java
@@ -127,7 +127,9 @@
      */
     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);
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/UnresolvedImportFormatter.java b/java/com/google/gerrit/plugins/codeowners/backend/UnresolvedImportFormatter.java
index 0029d9e..52958d9 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/UnresolvedImportFormatter.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/UnresolvedImportFormatter.java
@@ -61,7 +61,9 @@
    */
   private CodeOwnerBackend getBackend(CodeOwnerConfig.Key codeOwnerConfigKey) {
     if (projectCache.get(codeOwnerConfigKey.project()).isPresent()) {
-      return codeOwnersPluginConfiguration.getBackend(codeOwnerConfigKey.branchNameKey());
+      return codeOwnersPluginConfiguration
+          .getProjectConfig(codeOwnerConfigKey.project())
+          .getBackend(codeOwnerConfigKey.branchNameKey().branch());
     }
     return backendConfig.getDefaultBackend();
   }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshot.java b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshot.java
new file mode 100644
index 0000000..666b2c1
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshot.java
@@ -0,0 +1,416 @@
+// 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.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.plugins.codeowners.backend.CodeOwnerBackend;
+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.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.assistedinject.Assisted;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Config;
+
+/** Snapshot of the code-owners plugin configuration for one project. */
+public class CodeOwnersPluginConfigSnapshot {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    CodeOwnersPluginConfigSnapshot create(Project.NameKey projectName);
+  }
+
+  private final String pluginName;
+  private final PluginConfigFactory pluginConfigFactory;
+  private final ProjectCache projectCache;
+  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;
+
+  @Inject
+  CodeOwnersPluginConfigSnapshot(
+      @PluginName String pluginName,
+      PluginConfigFactory pluginConfigFactory,
+      ProjectCache projectCache,
+      BackendConfig backendConfig,
+      GeneralConfig generalConfig,
+      OverrideApprovalConfig overrideApprovalConfig,
+      RequiredApprovalConfig requiredApprovalConfig,
+      StatusConfig statusConfig,
+      @Assisted Project.NameKey projectName) {
+    this.pluginName = pluginName;
+    this.pluginConfigFactory = pluginConfigFactory;
+    this.projectCache = projectCache;
+    this.backendConfig = backendConfig;
+    this.generalConfig = generalConfig;
+    this.overrideApprovalConfig = overrideApprovalConfig;
+    this.requiredApprovalConfig = requiredApprovalConfig;
+    this.statusConfig = statusConfig;
+    this.projectName = projectName;
+    this.pluginConfig = loadPluginConfig();
+  }
+
+  /** Gets the file extension of code owner config files, if any configured. */
+  public Optional<String> getFileExtension() {
+    return generalConfig.getFileExtension(pluginConfig);
+  }
+
+  /** Checks whether code owner configs are read-only. */
+  public boolean areCodeOwnerConfigsReadOnly() {
+    return generalConfig.getReadOnly(projectName, pluginConfig);
+  }
+
+  /**
+   * Checks whether pure revert changes are exempted from needing code owner approvals for submit.
+   */
+  public boolean arePureRevertsExempted() {
+    return generalConfig.getExemptPureReverts(projectName, pluginConfig);
+  }
+
+  /**
+   * Checks whether newly added non-resolvable code owners should be rejected on commit received and
+   * submit.
+   */
+  public boolean rejectNonResolvableCodeOwners() {
+    return generalConfig.getRejectNonResolvableCodeOwners(projectName, pluginConfig);
+  }
+
+  /**
+   * Checks whether newly added non-resolvable imports should be rejected on commit received and
+   * submit.
+   */
+  public boolean rejectNonResolvableImports() {
+    return generalConfig.getRejectNonResolvableImports(projectName, pluginConfig);
+  }
+
+  /** Whether code owner configs should be validated when a commit is received. */
+  public CodeOwnerConfigValidationPolicy getCodeOwnerConfigValidationPolicyForCommitReceived() {
+    return generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceived(
+        projectName, pluginConfig);
+  }
+
+  /** Whether code owner configs should be validated when a change is submitted. */
+  public CodeOwnerConfigValidationPolicy getCodeOwnerConfigValidationPolicyForSubmit() {
+    return generalConfig.getCodeOwnerConfigValidationPolicyForSubmit(projectName, pluginConfig);
+  }
+
+  /** Gets the merge commit strategy. */
+  public MergeCommitStrategy getMergeCommitStrategy() {
+    return generalConfig.getMergeCommitStrategy(projectName, pluginConfig);
+  }
+
+  /** Gets the fallback code owners. */
+  public FallbackCodeOwners getFallbackCodeOwners() {
+    return generalConfig.getFallbackCodeOwners(projectName, pluginConfig);
+  }
+
+  /** Gets the max paths in change messages. */
+  public int getMaxPathsInChangeMessages() {
+    return generalConfig.getMaxPathsInChangeMessages(projectName, pluginConfig);
+  }
+
+  /** Gets the global code owners. */
+  public ImmutableSet<CodeOwnerReference> getGlobalCodeOwners() {
+    return generalConfig.getGlobalCodeOwners(pluginConfig);
+  }
+
+  /** Gets the override info URL that is configured. */
+  public Optional<String> getOverrideInfoUrl() {
+    return generalConfig.getOverrideInfoUrl(pluginConfig);
+  }
+
+  /**
+   * 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 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");
+
+    boolean isDisabled =
+        statusConfig.isDisabledForBranch(
+            pluginConfig, BranchNameKey.create(projectName, branchName));
+    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() {
+    return statusConfig.isDisabledForProject(pluginConfig, projectName);
+  }
+
+  /**
+   * 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) {
+    // check if a branch specific backend is configured
+    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() {
+    // 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 an implicit code owner approval from the last uploader is assumed. */
+  public boolean areImplicitApprovalsEnabled() {
+    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() {
+    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 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.
+   *
+   * @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 ImmutableSet<RequiredApproval> getOverrideApproval() {
+    try {
+      return 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 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.
+   *
+   * @param requiredApprovalConfig the config from which the required approvals should be read
+   * @return the required approvals that is 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);
+  }
+
+  /**
+   * Reads and returns the config from the {@code code-owners.config} file in {@code
+   * refs/meta/config} branch.
+   *
+   * @return the code owners configurations
+   */
+  private Config loadPluginConfig() {
+    try {
+      return pluginConfigFactory.getProjectPluginConfigWithInheritance(projectName, pluginName);
+    } catch (NoSuchProjectException e) {
+      throw new IllegalStateException(
+          String.format(
+              "cannot get %s plugin config for non-existing project %s", pluginName, projectName),
+          e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfiguration.java b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfiguration.java
index a9b5ea6..0855baf 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfiguration.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfiguration.java
@@ -14,35 +14,18 @@
 
 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.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.backend.CodeOwnerBackend;
-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.cache.PerThreadCache;
 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.
@@ -65,213 +48,34 @@
   @VisibleForTesting
   static final String KEY_ENABLE_EXPERIMENTAL_REST_ENDPOINTS = "enableExperimentalRestEndpoints";
 
+  private final CodeOwnersPluginConfigSnapshot.Factory codeOwnersPluginConfigSnapshotFactory;
   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(
+      CodeOwnersPluginConfigSnapshot.Factory codeOwnersPluginConfigSnapshotFactory,
       @PluginName String pluginName,
       PluginConfigFactory pluginConfigFactory,
-      ProjectCache projectCache,
-      GeneralConfig generalConfig,
-      StatusConfig statusConfig,
-      BackendConfig backendConfig,
-      RequiredApprovalConfig requiredApprovalConfig,
-      OverrideApprovalConfig overrideApprovalConfig) {
+      GeneralConfig generalConfig) {
+    this.codeOwnersPluginConfigSnapshotFactory = codeOwnersPluginConfigSnapshotFactory;
     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.
+   * Returns the code-owner plugin configuration for the given projects.
    *
-   * @param project the project for which the configured file extension should be returned
-   * @return the file extension that is configured 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 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 which 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(project, getPluginConfig(project));
-  }
-
-  /**
-   * Checks whether pure revert changes are exempted from needing code owner approvals for submit.
-   *
-   * @param project the project for which it should be checked whether pure revert changes are
-   *     exempted from needing code owner approvals for submit
-   * @return whether pure revert changes are exempted from needing code owner approvals for submit
-   */
-  public boolean arePureRevertsExempted(Project.NameKey project) {
-    requireNonNull(project, "project");
-    return generalConfig.getExemptPureReverts(project, getPluginConfig(project));
-  }
-
-  /**
-   * Checks whether newly added non-resolvable code owners should be rejected on commit received and
-   * submit.
-   *
-   * @param project the project for which it should be checked whether non-resolvable code owners
-   *     should be rejected
-   * @return whether newly added non-resolvable code owners should be rejected on commit received
-   *     and submit
-   */
-  public boolean rejectNonResolvableCodeOwners(Project.NameKey project) {
-    requireNonNull(project, "project");
-    return generalConfig.getRejectNonResolvableCodeOwners(project, getPluginConfig(project));
-  }
-
-  /**
-   * Checks whether newly added non-resolvable imports should be rejected on commit received and
-   * submit.
-   *
-   * @param project the project for which it should be checked whether non-resolvable imports should
-   *     be rejected
-   * @return whether newly added non-resolvable imports should be rejected on commit received and
-   *     submit
-   */
-  public boolean rejectNonResolvableImports(Project.NameKey project) {
-    requireNonNull(project, "project");
-    return generalConfig.getRejectNonResolvableImports(project, 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));
-  }
-
-  /**
-   * Gets the max paths in change messages for the given project.
-   *
-   * @param project the project for which the fallback code owners should be retrieved
-   * @return the fallback code owners for the given project
-   */
-  public int getMaxPathsInChangeMessages(Project.NameKey project) {
-    requireNonNull(project, "project");
-    return generalConfig.getMaxPathsInChangeMessages(project, getPluginConfig(project));
-  }
-
-  /**
-   * Checks whether an implicit code owner approval from the last uploader is assumed.
-   *
-   * @param project the project for it should be checked whether implicit 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");
-    EnableImplicitApprovals enableImplicitApprovals =
-        generalConfig.getEnableImplicitApprovals(project, getPluginConfig(project));
-    switch (enableImplicitApprovals) {
-      case FALSE:
-        logger.atFine().log("implicit approvals on project %s are disabled", project);
-        return false;
-      case TRUE:
-        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 true;
-      case FORCED:
-        logger.atFine().log("implicit approvals on project %s are enforced", project);
-        return true;
-    }
-    throw new IllegalStateException(
-        String.format(
-            "unknown value %s for enableImplicitApprovals configuration in project %s",
-            enableImplicitApprovals, 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));
+  public CodeOwnersPluginConfigSnapshot getProjectConfig(Project.NameKey projectName) {
+    requireNonNull(projectName, "projectName");
+    return PerThreadCache.getOrCompute(
+        PerThreadCache.Key.create(CodeOwnersPluginConfigSnapshot.class, projectName),
+        () -> codeOwnersPluginConfigSnapshotFactory.create(projectName));
   }
 
   /**
@@ -286,252 +90,6 @@
   }
 
   /**
-   * 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
@@ -559,22 +117,4 @@
       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/restapi/AbstractGetCodeOwnersForPath.java b/java/com/google/gerrit/plugins/codeowners/restapi/AbstractGetCodeOwnersForPath.java
index d406be1..6fb8c15 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/AbstractGetCodeOwnersForPath.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/AbstractGetCodeOwnersForPath.java
@@ -237,7 +237,8 @@
     CodeOwnerResolverResult globalCodeOwners =
         codeOwnerResolver
             .get()
-            .resolve(codeOwnersPluginConfiguration.getGlobalCodeOwners(projectName));
+            .resolve(
+                codeOwnersPluginConfiguration.getProjectConfig(projectName).getGlobalCodeOwners());
     logger.atFine().log("including global code owners = %s", globalCodeOwners);
     return globalCodeOwners;
   }
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwner.java b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwner.java
index d326da6..9ab1ed1 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwner.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwner.java
@@ -239,7 +239,8 @@
   }
 
   private boolean isGlobalCodeOwner(Project.NameKey projectName) {
-    return codeOwnersPluginConfiguration.getGlobalCodeOwners(projectName).stream()
+    return codeOwnersPluginConfiguration.getProjectConfig(projectName).getGlobalCodeOwners()
+        .stream()
         .filter(cor -> cor.email().equals(email))
         .findAny()
         .isPresent();
@@ -247,7 +248,7 @@
 
   private boolean isFallbackCodeOwner(Project.NameKey projectName) {
     FallbackCodeOwners fallbackCodeOwners =
-        codeOwnersPluginConfiguration.getFallbackCodeOwners(projectName);
+        codeOwnersPluginConfiguration.getProjectConfig(projectName).getFallbackCodeOwners();
     switch (fallbackCodeOwners) {
       case NONE:
         return false;
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFiles.java b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFiles.java
index ae55e82..306442a 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFiles.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFiles.java
@@ -135,7 +135,9 @@
           .filter(
               branchNameKey ->
                   validateDisabledBranches(input)
-                      || !codeOwnersPluginConfiguration.isDisabled(branchNameKey))
+                      || !codeOwnersPluginConfiguration
+                          .getProjectConfig(branchNameKey.project())
+                          .isDisabled(branchNameKey.branch()))
           .forEach(
               branchNameKey ->
                   resultsByBranchBuilder.put(
@@ -159,7 +161,10 @@
       BranchNameKey branchNameKey,
       @Nullable ConsistencyProblemInfo.Status verbosity) {
     ListMultimap<String, ConsistencyProblemInfo> problemsByPath = LinkedListMultimap.create();
-    CodeOwnerBackend codeOwnerBackend = codeOwnersPluginConfiguration.getBackend(branchNameKey);
+    CodeOwnerBackend codeOwnerBackend =
+        codeOwnersPluginConfiguration
+            .getProjectConfig(branchNameKey.project())
+            .getBackend(branchNameKey.branch());
     codeOwnerConfigScannerFactory
         .create()
         // Do not check the default code owner config file in refs/meta/config, as this config is
@@ -260,7 +265,7 @@
         }
 
         if ((input.validateDisabledBranches == null || !input.validateDisabledBranches)
-            && codeOwnersPluginConfiguration.isDisabled(branchNameKey)) {
+            && codeOwnersPluginConfiguration.getProjectConfig(projectName).isDisabled(branchName)) {
           throw new BadRequestException(
               String.format(
                   "code owners functionality for branch %s is disabled,"
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFilesInRevision.java b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFilesInRevision.java
index d960595..db939c4 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFilesInRevision.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CheckCodeOwnerConfigFilesInRevision.java
@@ -84,7 +84,9 @@
         input.path);
 
     CodeOwnerBackend codeOwnerBackend =
-        codeOwnersPluginConfiguration.getBackend(revisionResource.getChange().getDest());
+        codeOwnersPluginConfiguration
+            .getProjectConfig(revisionResource.getProject())
+            .getBackend(revisionResource.getChange().getDest().branch());
 
     IdentifiedUser uploader = genericUserFactory.create(revisionResource.getPatchSet().uploader());
     logger.atFine().log("uploader = %s", uploader.getLoggableName());
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJson.java b/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJson.java
index 2f649f9..e233344 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJson.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJson.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.plugins.codeowners.api.GeneralInfo;
 import com.google.gerrit.plugins.codeowners.api.RequiredApprovalInfo;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfigSnapshot;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.plugins.codeowners.backend.config.RequiredApproval;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -65,7 +66,7 @@
     CodeOwnerProjectConfigInfo info = new CodeOwnerProjectConfigInfo();
     info.status = formatStatusInfo(projectResource);
 
-    if (codeOwnersPluginConfiguration.isDisabled(projectResource.getNameKey())) {
+    if (codeOwnersPluginConfiguration.getProjectConfig(projectResource.getNameKey()).isDisabled()) {
       return info;
     }
 
@@ -78,9 +79,12 @@
   }
 
   CodeOwnerBranchConfigInfo format(BranchResource branchResource) {
+    CodeOwnersPluginConfigSnapshot codeOwnersConfig =
+        codeOwnersPluginConfiguration.getProjectConfig(branchResource.getNameKey());
+
     CodeOwnerBranchConfigInfo info = new CodeOwnerBranchConfigInfo();
 
-    boolean disabled = codeOwnersPluginConfiguration.isDisabled(branchResource.getBranchKey());
+    boolean disabled = codeOwnersConfig.isDisabled(branchResource.getBranchKey().branch());
     info.disabled = disabled ? disabled : null;
 
     if (disabled) {
@@ -90,7 +94,7 @@
     info.general = formatGeneralInfo(branchResource.getNameKey());
     info.backendId =
         CodeOwnerBackendId.getBackendId(
-            codeOwnersPluginConfiguration.getBackend(branchResource.getBranchKey()).getClass());
+            codeOwnersConfig.getBackend(branchResource.getBranchKey().branch()).getClass());
     info.requiredApproval = formatRequiredApprovalInfo(branchResource.getNameKey());
     info.overrideApproval = formatOverrideApprovalInfo(branchResource.getNameKey());
 
@@ -98,17 +102,15 @@
   }
 
   private GeneralInfo formatGeneralInfo(Project.NameKey projectName) {
+    CodeOwnersPluginConfigSnapshot codeOwnersConfig =
+        codeOwnersPluginConfiguration.getProjectConfig(projectName);
+
     GeneralInfo generalInfo = new GeneralInfo();
-    generalInfo.fileExtension =
-        codeOwnersPluginConfiguration.getFileExtension(projectName).orElse(null);
-    generalInfo.mergeCommitStrategy =
-        codeOwnersPluginConfiguration.getMergeCommitStrategy(projectName);
-    generalInfo.implicitApprovals =
-        codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(projectName) ? true : null;
-    generalInfo.overrideInfoUrl =
-        codeOwnersPluginConfiguration.getOverrideInfoUrl(projectName).orElse(null);
-    generalInfo.fallbackCodeOwners =
-        codeOwnersPluginConfiguration.getFallbackCodeOwners(projectName);
+    generalInfo.fileExtension = codeOwnersConfig.getFileExtension().orElse(null);
+    generalInfo.mergeCommitStrategy = codeOwnersConfig.getMergeCommitStrategy();
+    generalInfo.implicitApprovals = codeOwnersConfig.areImplicitApprovalsEnabled() ? true : null;
+    generalInfo.overrideInfoUrl = codeOwnersConfig.getOverrideInfoUrl().orElse(null);
+    generalInfo.fallbackCodeOwners = codeOwnersConfig.getFallbackCodeOwners();
     return generalInfo;
   }
 
@@ -117,7 +119,9 @@
       throws RestApiException, PermissionBackendException, IOException {
     CodeOwnersStatusInfo info = new CodeOwnersStatusInfo();
     info.disabled =
-        codeOwnersPluginConfiguration.isDisabled(projectResource.getNameKey()) ? true : null;
+        codeOwnersPluginConfiguration.getProjectConfig(projectResource.getNameKey()).isDisabled()
+            ? true
+            : null;
 
     if (info.disabled == null) {
       ImmutableList<BranchNameKey> disabledBranches = getDisabledBranches(projectResource);
@@ -135,7 +139,10 @@
     BackendInfo info = new BackendInfo();
     info.id =
         CodeOwnerBackendId.getBackendId(
-            codeOwnersPluginConfiguration.getBackend(projectResource.getNameKey()).getClass());
+            codeOwnersPluginConfiguration
+                .getProjectConfig(projectResource.getNameKey())
+                .getBackend()
+                .getClass());
 
     ImmutableMap<String, String> idsByBranch =
         getBackendIdsPerBranch(projectResource).entrySet().stream()
@@ -147,14 +154,15 @@
   }
 
   private RequiredApprovalInfo formatRequiredApprovalInfo(Project.NameKey projectName) {
-    return formatRequiredApproval(codeOwnersPluginConfiguration.getRequiredApproval(projectName));
+    return formatRequiredApproval(
+        codeOwnersPluginConfiguration.getProjectConfig(projectName).getRequiredApproval());
   }
 
   @VisibleForTesting
   @Nullable
   ImmutableList<RequiredApprovalInfo> formatOverrideApprovalInfo(Project.NameKey projectName) {
     ImmutableList<RequiredApprovalInfo> overrideApprovalInfos =
-        codeOwnersPluginConfiguration.getOverrideApproval(projectName).stream()
+        codeOwnersPluginConfiguration.getProjectConfig(projectName).getOverrideApproval().stream()
             .sorted(comparing(requiredApproval -> requiredApproval.toString()))
             .map(CodeOwnerProjectConfigJson::formatRequiredApproval)
             .collect(toImmutableList());
@@ -174,7 +182,11 @@
   private ImmutableList<BranchNameKey> getDisabledBranches(ProjectResource projectResource)
       throws RestApiException, PermissionBackendException, IOException {
     return branches(projectResource)
-        .filter(codeOwnersPluginConfiguration::isDisabled)
+        .filter(
+            branchNameKey ->
+                codeOwnersPluginConfiguration
+                    .getProjectConfig(branchNameKey.project())
+                    .isDisabled(branchNameKey.branch()))
         .collect(toImmutableList());
   }
 
@@ -187,7 +199,10 @@
                 Function.identity(),
                 branchNameKey ->
                     CodeOwnerBackendId.getBackendId(
-                        codeOwnersPluginConfiguration.getBackend(branchNameKey).getClass())));
+                        codeOwnersPluginConfiguration
+                            .getProjectConfig(branchNameKey.project())
+                            .getBackend(branchNameKey.branch())
+                            .getClass())));
   }
 
   private Stream<BranchNameKey> branches(ProjectResource projectResource)
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerConfigFiles.java b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerConfigFiles.java
index ef08584..54d305a 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerConfigFiles.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/GetCodeOwnerConfigFiles.java
@@ -86,11 +86,13 @@
   }
 
   @Override
-  public Response<List<String>> apply(BranchResource resource) throws BadRequestException {
+  public Response<List<String>> apply(BranchResource branchResource) throws BadRequestException {
     validateOptions();
 
     CodeOwnerBackend codeOwnerBackend =
-        codeOwnersPluginConfiguration.getBackend(resource.getBranchKey());
+        codeOwnersPluginConfiguration
+            .getProjectConfig(branchResource.getNameKey())
+            .getBackend(branchResource.getBranchKey().branch());
     ImmutableList.Builder<Path> codeOwnerConfigs = ImmutableList.builder();
 
     if (email != null) {
@@ -106,7 +108,7 @@
         // files in refs/meta/config explicitly.
         .includeDefaultCodeOwnerConfig(false)
         .visit(
-            resource.getBranchKey(),
+            branchResource.getBranchKey(),
             codeOwnerConfig -> {
               Path codeOwnerConfigPath = codeOwnerBackend.getFilePath(codeOwnerConfig.key());
               if (email == null || containsEmail(codeOwnerConfig, codeOwnerConfigPath, email)) {
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/RenameEmail.java b/java/com/google/gerrit/plugins/codeowners/restapi/RenameEmail.java
index 6f597fd..e338543 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/RenameEmail.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/RenameEmail.java
@@ -99,7 +99,9 @@
     validateInput(input);
 
     CodeOwnerBackend codeOwnerBackend =
-        codeOwnersPluginConfiguration.getBackend(branchResource.getBranchKey());
+        codeOwnersPluginConfiguration
+            .getProjectConfig(branchResource.getNameKey())
+            .getBackend(branchResource.getBranchKey().branch());
 
     Account.Id accountOwningOldEmail = resolveEmail(input.oldEmail);
     Account.Id accountOwningNewEmail = resolveEmail(input.newEmail);
diff --git a/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java b/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java
index f230eb9..3f79135 100644
--- a/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java
+++ b/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java
@@ -38,6 +38,7 @@
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerResolver;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnersInternalServerErrorException;
 import com.google.gerrit.plugins.codeowners.backend.PathCodeOwners;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfigSnapshot;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.plugins.codeowners.backend.config.InvalidPluginConfigurationException;
 import com.google.gerrit.plugins.codeowners.common.ChangedFile;
@@ -174,8 +175,9 @@
                 .username(receiveEvent.user.getLoggableName())
                 .build())) {
       CodeOwnerConfigValidationPolicy codeOwnerConfigValidationPolicy =
-          codeOwnersPluginConfiguration.getCodeOwnerConfigValidationPolicyForCommitReceived(
-              receiveEvent.getProjectNameKey());
+          codeOwnersPluginConfiguration
+              .getProjectConfig(receiveEvent.getProjectNameKey())
+              .getCodeOwnerConfigValidationPolicyForCommitReceived();
       logger.atFine().log("codeOwnerConfigValidationPolicy = %s", codeOwnerConfigValidationPolicy);
       Optional<ValidationResult> validationResult;
       if (!codeOwnerConfigValidationPolicy.runValidation()) {
@@ -243,8 +245,9 @@
                 .patchSetId(patchSetId.get())
                 .build())) {
       CodeOwnerConfigValidationPolicy codeOwnerConfigValidationPolicy =
-          codeOwnersPluginConfiguration.getCodeOwnerConfigValidationPolicyForSubmit(
-              branchNameKey.project());
+          codeOwnersPluginConfiguration
+              .getProjectConfig(branchNameKey.project())
+              .getCodeOwnerConfigValidationPolicyForSubmit();
       logger.atFine().log("codeOwnerConfigValidationPolicy = %s", codeOwnerConfigValidationPolicy);
       Optional<ValidationResult> validationResult;
       if (!codeOwnerConfigValidationPolicy.runValidation()) {
@@ -303,7 +306,9 @@
       RevWalk revWalk,
       RevCommit revCommit,
       IdentifiedUser user) {
-    if (codeOwnersPluginConfiguration.isDisabled(branchNameKey)) {
+    CodeOwnersPluginConfigSnapshot codeOwnersConfig =
+        codeOwnersPluginConfiguration.getProjectConfig(branchNameKey.project());
+    if (codeOwnersConfig.isDisabled(branchNameKey.branch())) {
       return Optional.of(
           ValidationResult.create(
               pluginName,
@@ -311,7 +316,7 @@
               new CommitValidationMessage(
                   "code-owners functionality is disabled", ValidationMessage.Type.HINT)));
     }
-    if (codeOwnersPluginConfiguration.areCodeOwnerConfigsReadOnly(branchNameKey.project())) {
+    if (codeOwnersConfig.areCodeOwnerConfigsReadOnly()) {
       return Optional.of(
           ValidationResult.create(
               pluginName,
@@ -322,7 +327,7 @@
     }
 
     try {
-      CodeOwnerBackend codeOwnerBackend = codeOwnersPluginConfiguration.getBackend(branchNameKey);
+      CodeOwnerBackend codeOwnerBackend = codeOwnersConfig.getBackend(branchNameKey.branch());
 
       // For merge commits, always do the comparison against the destination branch
       // (MergeCommitStrategy.ALL_CHANGED_FILES). Doing the comparison against the auto-merge
@@ -922,7 +927,9 @@
     }
 
     CodeOwnerBackend codeOwnerBackend =
-        codeOwnersPluginConfiguration.getBackend(keyOfImportedCodeOwnerConfig.branchNameKey());
+        codeOwnersPluginConfiguration
+            .getProjectConfig(keyOfImportedCodeOwnerConfig.project())
+            .getBackend(keyOfImportedCodeOwnerConfig.branchNameKey().branch());
     if (!codeOwnerBackend.isCodeOwnerConfigFile(
         keyOfImportedCodeOwnerConfig.project(), codeOwnerConfigReference.fileName())) {
       return nonResolvableImport(
@@ -983,11 +990,13 @@
     return keyOfImportingCodeOwnerConfig.project().equals(keyOfImportedCodeOwnerConfig.project())
         && keyOfImportingCodeOwnerConfig.ref().equals(keyOfImportedCodeOwnerConfig.ref())
         && codeOwnersPluginConfiguration
-            .getBackend(keyOfImportingCodeOwnerConfig.branchNameKey())
+            .getProjectConfig(keyOfImportingCodeOwnerConfig.project())
+            .getBackend(keyOfImportingCodeOwnerConfig.branchNameKey().branch())
             .getFilePath(keyOfImportingCodeOwnerConfig)
             .equals(
                 codeOwnersPluginConfiguration
-                    .getBackend(keyOfImportedCodeOwnerConfig.branchNameKey())
+                    .getProjectConfig(keyOfImportedCodeOwnerConfig.project())
+                    .getBackend(keyOfImportedCodeOwnerConfig.branchNameKey().branch())
                     .getFilePath(keyOfImportedCodeOwnerConfig));
   }
 
@@ -1046,7 +1055,7 @@
         importType,
         codeOwnerConfigFilePath,
         message,
-        codeOwnersPluginConfiguration.rejectNonResolvableImports(project)
+        codeOwnersPluginConfiguration.getProjectConfig(project).rejectNonResolvableImports()
             ? ValidationMessage.Type.ERROR
             : ValidationMessage.Type.WARNING);
   }
@@ -1069,7 +1078,7 @@
     return Optional.of(
         new CommitValidationMessage(
             message,
-            codeOwnersPluginConfiguration.rejectNonResolvableCodeOwners(project)
+            codeOwnersPluginConfiguration.getProjectConfig(project).rejectNonResolvableCodeOwners()
                 ? ValidationMessage.Type.ERROR
                 : ValidationMessage.Type.WARNING));
   }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java
index be0f13b..90f950a 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.config.GerritConfig;
-import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -101,7 +100,7 @@
 
     PushResult r = pushRefsMetaConfig();
     assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
-    assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isTrue();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled()).isTrue();
   }
 
   @Test
@@ -118,7 +117,7 @@
 
     PushResult r = pushRefsMetaConfig();
     assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("master"))
         .isTrue();
   }
 
@@ -178,7 +177,7 @@
 
     PushResult r = pushRefsMetaConfig();
     assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
-    assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getBackend("master"))
         .isInstanceOf(ProtoBackend.class);
   }
 
@@ -196,7 +195,7 @@
 
     PushResult r = pushRefsMetaConfig();
     assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
-    assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getBackend("master"))
         .isInstanceOf(ProtoBackend.class);
   }
 
@@ -256,7 +255,8 @@
 
     PushResult r = pushRefsMetaConfig();
     assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
-    RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
+    RequiredApproval requiredApproval =
+        codeOwnersPluginConfiguration.getProjectConfig(project).getRequiredApproval();
     assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
     assertThat(requiredApproval).hasValueThat().isEqualTo(2);
   }
@@ -327,7 +327,7 @@
     PushResult r = pushRefsMetaConfig();
     assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
     ImmutableSet<RequiredApproval> overrideApproval =
-        codeOwnersPluginConfiguration.getOverrideApproval(project);
+        codeOwnersPluginConfiguration.getProjectConfig(project).getOverrideApproval();
     assertThat(overrideApproval).hasSize(1);
     assertThat(overrideApproval).element(0).hasLabelNameThat().isEqualTo("Code-Review");
     assertThat(overrideApproval).element(0).hasValueThat().isEqualTo(2);
@@ -424,7 +424,7 @@
     PushResult r = pushRefsMetaConfig();
     assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
     ImmutableSet<RequiredApproval> overrideApproval =
-        codeOwnersPluginConfiguration.getOverrideApproval(project);
+        codeOwnersPluginConfiguration.getProjectConfig(project).getOverrideApproval();
     assertThat(overrideApproval).hasSize(1);
     assertThat(overrideApproval).element(0).hasLabelNameThat().isEqualTo("Owners-Override");
     assertThat(overrideApproval).element(0).hasValueThat().isEqualTo(1);
@@ -444,7 +444,7 @@
 
     PushResult r = pushRefsMetaConfig();
     assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
-    assertThat(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getMergeCommitStrategy())
         .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
   }
 
@@ -483,7 +483,7 @@
 
     PushResult r = pushRefsMetaConfig();
     assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
-    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getFallbackCodeOwners())
         .isEqualTo(FallbackCodeOwners.ALL_USERS);
   }
 
@@ -522,7 +522,9 @@
 
     PushResult r = pushRefsMetaConfig();
     assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
-    assertThat(codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(project)).isEqualTo(50);
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).getMaxPathsInChangeMessages())
+        .isEqualTo(50);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/PutCodeOwnerProjectConfigIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/PutCodeOwnerProjectConfigIT.java
index c09a8ae..d8eefd3 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/PutCodeOwnerProjectConfigIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/PutCodeOwnerProjectConfigIT.java
@@ -85,29 +85,27 @@
 
   @Test
   public void disableAndReenableCodeOwnersFunctionality() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isFalse();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled()).isFalse();
 
     CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
     input.disabled = true;
     CodeOwnerProjectConfigInfo updatedConfig =
         projectCodeOwnersApiFactory.project(project).updateConfig(input);
     assertThat(updatedConfig.status.disabled).isTrue();
-    assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isTrue();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled()).isTrue();
 
     input.disabled = false;
     updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input);
     assertThat(updatedConfig.status.disabled).isNull();
-    assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isFalse();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled()).isFalse();
   }
 
   @Test
   public void setDisabledBranches() throws Exception {
-    BranchNameKey masterBranch = BranchNameKey.create(project, "master");
-    BranchNameKey fooBranch = BranchNameKey.create(project, "foo");
-
-    createBranch(fooBranch);
-    assertThat(codeOwnersPluginConfiguration.isDisabled(masterBranch)).isFalse();
-    assertThat(codeOwnersPluginConfiguration.isDisabled(fooBranch)).isFalse();
+    createBranch(BranchNameKey.create(project, "foo"));
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("master"))
+        .isFalse();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("foo")).isFalse();
 
     CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
     input.disabledBranches = ImmutableList.of("refs/heads/master", "refs/heads/foo");
@@ -115,25 +113,25 @@
         projectCodeOwnersApiFactory.project(project).updateConfig(input);
     assertThat(updatedConfig.status.disabledBranches)
         .containsExactly("refs/heads/master", "refs/heads/foo");
-    assertThat(codeOwnersPluginConfiguration.isDisabled(masterBranch)).isTrue();
-    assertThat(codeOwnersPluginConfiguration.isDisabled(fooBranch)).isTrue();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("master"))
+        .isTrue();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("foo")).isTrue();
 
     input = new CodeOwnerProjectConfigInput();
     input.disabledBranches = ImmutableList.of();
     updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input);
     assertThat(updatedConfig.status.disabledBranches).isNull();
-    assertThat(codeOwnersPluginConfiguration.isDisabled(masterBranch)).isFalse();
-    assertThat(codeOwnersPluginConfiguration.isDisabled(fooBranch)).isFalse();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("master"))
+        .isFalse();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("foo")).isFalse();
   }
 
   @Test
   public void setDisabledBranchesRegEx() throws Exception {
-    BranchNameKey masterBranch = BranchNameKey.create(project, "master");
-    BranchNameKey fooBranch = BranchNameKey.create(project, "foo");
-
-    createBranch(fooBranch);
-    assertThat(codeOwnersPluginConfiguration.isDisabled(masterBranch)).isFalse();
-    assertThat(codeOwnersPluginConfiguration.isDisabled(fooBranch)).isFalse();
+    createBranch(BranchNameKey.create(project, "foo"));
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("master"))
+        .isFalse();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("foo")).isFalse();
 
     CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
     input.disabledBranches = ImmutableList.of("refs/heads/*");
@@ -141,15 +139,14 @@
         projectCodeOwnersApiFactory.project(project).updateConfig(input);
     assertThat(updatedConfig.status.disabledBranches)
         .containsExactly("refs/heads/master", "refs/heads/foo");
-    assertThat(codeOwnersPluginConfiguration.isDisabled(masterBranch)).isTrue();
-    assertThat(codeOwnersPluginConfiguration.isDisabled(fooBranch)).isTrue();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("master"))
+        .isTrue();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("foo")).isTrue();
   }
 
   @Test
   public void setDisabledBranchThatDoesntExist() throws Exception {
-    BranchNameKey fooBranch = BranchNameKey.create(project, "foo");
-
-    assertThat(codeOwnersPluginConfiguration.isDisabled(fooBranch)).isFalse();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("foo")).isFalse();
 
     CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
     input.disabledBranches = ImmutableList.of("refs/heads/foo");
@@ -157,9 +154,9 @@
         projectCodeOwnersApiFactory.project(project).updateConfig(input);
     // status.disabledBranches does only contain existing branches
     assertThat(updatedConfig.status.disabledBranches).isNull();
-    assertThat(codeOwnersPluginConfiguration.isDisabled(fooBranch)).isTrue();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled("foo")).isTrue();
 
-    createBranch(fooBranch);
+    createBranch(BranchNameKey.create(project, "foo"));
     assertThat(projectCodeOwnersApiFactory.project(project).getConfig().status.disabledBranches)
         .containsExactly("refs/heads/foo");
   }
@@ -183,24 +180,29 @@
 
   @Test
   public void setFileExtension() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getFileExtension(project)).isEmpty();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getFileExtension())
+        .isEmpty();
 
     CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
     input.fileExtension = "foo";
     CodeOwnerProjectConfigInfo updatedConfig =
         projectCodeOwnersApiFactory.project(project).updateConfig(input);
     assertThat(updatedConfig.general.fileExtension).isEqualTo("foo");
-    assertThat(codeOwnersPluginConfiguration.getFileExtension(project)).value().isEqualTo("foo");
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getFileExtension())
+        .value()
+        .isEqualTo("foo");
 
     input.fileExtension = "";
     updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input);
     assertThat(updatedConfig.general.fileExtension).isNull();
-    assertThat(codeOwnersPluginConfiguration.getFileExtension(project)).isEmpty();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getFileExtension())
+        .isEmpty();
   }
 
   @Test
   public void setRequiredApproval() throws Exception {
-    RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
+    RequiredApproval requiredApproval =
+        codeOwnersPluginConfiguration.getProjectConfig(project).getRequiredApproval();
     assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
     assertThat(requiredApproval).hasValueThat().isEqualTo(1);
 
@@ -215,7 +217,8 @@
         projectCodeOwnersApiFactory.project(project).updateConfig(input);
     assertThat(updatedConfig.requiredApproval.label).isEqualTo(otherLabel);
     assertThat(updatedConfig.requiredApproval.value).isEqualTo(2);
-    requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
+    requiredApproval =
+        codeOwnersPluginConfiguration.getProjectConfig(project).getRequiredApproval();
     assertThat(requiredApproval).hasLabelNameThat().isEqualTo(otherLabel);
     assertThat(requiredApproval).hasValueThat().isEqualTo(2);
 
@@ -223,7 +226,8 @@
     updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input);
     assertThat(updatedConfig.requiredApproval.label).isEqualTo("Code-Review");
     assertThat(updatedConfig.requiredApproval.value).isEqualTo(1);
-    requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
+    requiredApproval =
+        codeOwnersPluginConfiguration.getProjectConfig(project).getRequiredApproval();
     assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
     assertThat(requiredApproval).hasValueThat().isEqualTo(1);
   }
@@ -249,7 +253,8 @@
 
   @Test
   public void setOverrideApproval() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getOverrideApproval(project)).isEmpty();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getOverrideApproval())
+        .isEmpty();
 
     String overrideLabel1 = "Bypass-Owners";
     String overrideLabel2 = "Owners-Override";
@@ -265,12 +270,14 @@
     assertThat(updatedConfig.overrideApproval.get(0).value).isEqualTo(1);
     assertThat(updatedConfig.overrideApproval.get(1).label).isEqualTo(overrideLabel2);
     assertThat(updatedConfig.overrideApproval.get(1).value).isEqualTo(1);
-    assertThat(codeOwnersPluginConfiguration.getOverrideApproval(project)).hasSize(2);
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getOverrideApproval())
+        .hasSize(2);
 
     input.overrideApprovals = ImmutableList.of();
     updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input);
     assertThat(updatedConfig.overrideApproval).isNull();
-    assertThat(codeOwnersPluginConfiguration.getOverrideApproval(project)).isEmpty();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getOverrideApproval())
+        .isEmpty();
   }
 
   @Test
@@ -294,7 +301,7 @@
 
   @Test
   public void setFallbackCodeOwners() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getFallbackCodeOwners())
         .isEqualTo(FallbackCodeOwners.NONE);
 
     CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
@@ -302,37 +309,39 @@
     CodeOwnerProjectConfigInfo updatedConfig =
         projectCodeOwnersApiFactory.project(project).updateConfig(input);
     assertThat(updatedConfig.general.fallbackCodeOwners).isEqualTo(FallbackCodeOwners.ALL_USERS);
-    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getFallbackCodeOwners())
         .isEqualTo(FallbackCodeOwners.ALL_USERS);
 
     input.fallbackCodeOwners = FallbackCodeOwners.NONE;
     updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input);
     assertThat(updatedConfig.general.fallbackCodeOwners).isEqualTo(FallbackCodeOwners.NONE);
-    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getFallbackCodeOwners())
         .isEqualTo(FallbackCodeOwners.NONE);
   }
 
   @Test
   public void setGlobalCodeOwners() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getGlobalCodeOwners(project)).isEmpty();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getGlobalCodeOwners())
+        .isEmpty();
 
     CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
     input.globalCodeOwners = ImmutableList.of(user.email(), "foo.bar@example.com");
     projectCodeOwnersApiFactory.project(project).updateConfig(input);
     assertThat(
-            codeOwnersPluginConfiguration.getGlobalCodeOwners(project).stream()
+            codeOwnersPluginConfiguration.getProjectConfig(project).getGlobalCodeOwners().stream()
                 .map(CodeOwnerReference::email)
                 .collect(toImmutableSet()))
         .containsExactly(user.email(), "foo.bar@example.com");
 
     input.globalCodeOwners = ImmutableList.of();
     projectCodeOwnersApiFactory.project(project).updateConfig(input);
-    assertThat(codeOwnersPluginConfiguration.getGlobalCodeOwners(project)).isEmpty();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getGlobalCodeOwners())
+        .isEmpty();
   }
 
   @Test
   public void setMergeCommitStrategy() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getMergeCommitStrategy())
         .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
 
     CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
@@ -341,166 +350,210 @@
         projectCodeOwnersApiFactory.project(project).updateConfig(input);
     assertThat(updatedConfig.general.mergeCommitStrategy)
         .isEqualTo(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
-    assertThat(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getMergeCommitStrategy())
         .isEqualTo(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
 
     input.mergeCommitStrategy = MergeCommitStrategy.ALL_CHANGED_FILES;
     updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input);
     assertThat(updatedConfig.general.mergeCommitStrategy)
         .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
-    assertThat(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getMergeCommitStrategy())
         .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
   }
 
   @Test
   public void setImplicitApprovals() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(project)).isFalse();
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).areImplicitApprovalsEnabled())
+        .isFalse();
 
     CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
     input.implicitApprovals = true;
     CodeOwnerProjectConfigInfo updatedConfig =
         projectCodeOwnersApiFactory.project(project).updateConfig(input);
     assertThat(updatedConfig.general.implicitApprovals).isTrue();
-    assertThat(codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(project)).isTrue();
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).areImplicitApprovalsEnabled())
+        .isTrue();
 
     input.implicitApprovals = false;
     updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input);
     assertThat(updatedConfig.general.implicitApprovals).isNull();
-    assertThat(codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(project)).isFalse();
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).areImplicitApprovalsEnabled())
+        .isFalse();
   }
 
   @Test
   public void setOverrideInfoUrl() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getOverrideInfoUrl(project)).isEmpty();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getOverrideInfoUrl())
+        .isEmpty();
 
     CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
     input.overrideInfoUrl = "http://foo.bar";
     CodeOwnerProjectConfigInfo updatedConfig =
         projectCodeOwnersApiFactory.project(project).updateConfig(input);
     assertThat(updatedConfig.general.overrideInfoUrl).isEqualTo("http://foo.bar");
-    assertThat(codeOwnersPluginConfiguration.getOverrideInfoUrl(project))
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getOverrideInfoUrl())
         .value()
         .isEqualTo("http://foo.bar");
 
     input.overrideInfoUrl = "";
     updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input);
     assertThat(updatedConfig.general.overrideInfoUrl).isNull();
-    assertThat(codeOwnersPluginConfiguration.getOverrideInfoUrl(project)).isEmpty();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).getOverrideInfoUrl())
+        .isEmpty();
   }
 
   @Test
   public void setReadOnly() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.areCodeOwnerConfigsReadOnly(project)).isFalse();
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).areCodeOwnerConfigsReadOnly())
+        .isFalse();
 
     CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
     input.readOnly = true;
     projectCodeOwnersApiFactory.project(project).updateConfig(input);
-    assertThat(codeOwnersPluginConfiguration.areCodeOwnerConfigsReadOnly(project)).isTrue();
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).areCodeOwnerConfigsReadOnly())
+        .isTrue();
 
     input.readOnly = false;
     projectCodeOwnersApiFactory.project(project).updateConfig(input);
-    assertThat(codeOwnersPluginConfiguration.areCodeOwnerConfigsReadOnly(project)).isFalse();
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).areCodeOwnerConfigsReadOnly())
+        .isFalse();
   }
 
   @Test
   public void setExemptPureReverts() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.arePureRevertsExempted(project)).isFalse();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).arePureRevertsExempted())
+        .isFalse();
 
     CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
     input.exemptPureReverts = true;
     projectCodeOwnersApiFactory.project(project).updateConfig(input);
-    assertThat(codeOwnersPluginConfiguration.arePureRevertsExempted(project)).isTrue();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).arePureRevertsExempted())
+        .isTrue();
 
     input.exemptPureReverts = false;
     projectCodeOwnersApiFactory.project(project).updateConfig(input);
-    assertThat(codeOwnersPluginConfiguration.arePureRevertsExempted(project)).isFalse();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).arePureRevertsExempted())
+        .isFalse();
   }
 
   @Test
   public void setEnableValidationOnCommitReceived() throws Exception {
     assertThat(
-            codeOwnersPluginConfiguration.getCodeOwnerConfigValidationPolicyForCommitReceived(
-                project))
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .getCodeOwnerConfigValidationPolicyForCommitReceived())
         .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
 
     CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
     input.enableValidationOnCommitReceived = CodeOwnerConfigValidationPolicy.FALSE;
     projectCodeOwnersApiFactory.project(project).updateConfig(input);
     assertThat(
-            codeOwnersPluginConfiguration.getCodeOwnerConfigValidationPolicyForCommitReceived(
-                project))
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .getCodeOwnerConfigValidationPolicyForCommitReceived())
         .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
 
     input.enableValidationOnCommitReceived = CodeOwnerConfigValidationPolicy.TRUE;
     projectCodeOwnersApiFactory.project(project).updateConfig(input);
     assertThat(
-            codeOwnersPluginConfiguration.getCodeOwnerConfigValidationPolicyForCommitReceived(
-                project))
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .getCodeOwnerConfigValidationPolicyForCommitReceived())
         .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
   }
 
   @Test
   public void setEnableValidationOnSubmit() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getCodeOwnerConfigValidationPolicyForSubmit(project))
+    assertThat(
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .getCodeOwnerConfigValidationPolicyForSubmit())
         .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
 
     CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
     input.enableValidationOnSubmit = CodeOwnerConfigValidationPolicy.FALSE;
     projectCodeOwnersApiFactory.project(project).updateConfig(input);
-    assertThat(codeOwnersPluginConfiguration.getCodeOwnerConfigValidationPolicyForSubmit(project))
+    assertThat(
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .getCodeOwnerConfigValidationPolicyForSubmit())
         .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
 
     input.enableValidationOnSubmit = CodeOwnerConfigValidationPolicy.TRUE;
     projectCodeOwnersApiFactory.project(project).updateConfig(input);
-    assertThat(codeOwnersPluginConfiguration.getCodeOwnerConfigValidationPolicyForSubmit(project))
+    assertThat(
+            codeOwnersPluginConfiguration
+                .getProjectConfig(project)
+                .getCodeOwnerConfigValidationPolicyForSubmit())
         .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
   }
 
   @Test
   public void setRejectNonResolvableCodeOwners() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.rejectNonResolvableCodeOwners(project)).isTrue();
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).rejectNonResolvableCodeOwners())
+        .isTrue();
 
     CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
     input.rejectNonResolvableCodeOwners = false;
     projectCodeOwnersApiFactory.project(project).updateConfig(input);
-    assertThat(codeOwnersPluginConfiguration.rejectNonResolvableCodeOwners(project)).isFalse();
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).rejectNonResolvableCodeOwners())
+        .isFalse();
 
     input.rejectNonResolvableCodeOwners = true;
     projectCodeOwnersApiFactory.project(project).updateConfig(input);
-    assertThat(codeOwnersPluginConfiguration.rejectNonResolvableCodeOwners(project)).isTrue();
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).rejectNonResolvableCodeOwners())
+        .isTrue();
   }
 
   @Test
   public void setRejectNonResolvableImports() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.rejectNonResolvableImports(project)).isTrue();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).rejectNonResolvableImports())
+        .isTrue();
 
     CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
     input.rejectNonResolvableImports = false;
     projectCodeOwnersApiFactory.project(project).updateConfig(input);
-    assertThat(codeOwnersPluginConfiguration.rejectNonResolvableImports(project)).isFalse();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).rejectNonResolvableImports())
+        .isFalse();
 
     input.rejectNonResolvableImports = true;
     projectCodeOwnersApiFactory.project(project).updateConfig(input);
-    assertThat(codeOwnersPluginConfiguration.rejectNonResolvableImports(project)).isTrue();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).rejectNonResolvableImports())
+        .isTrue();
   }
 
   @Test
   public void setMaxPathsInChangeMessages() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(project))
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).getMaxPathsInChangeMessages())
         .isEqualTo(GeneralConfig.DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
 
     CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
     input.maxPathsInChangeMessages = 10;
     projectCodeOwnersApiFactory.project(project).updateConfig(input);
-    assertThat(codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(project)).isEqualTo(10);
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).getMaxPathsInChangeMessages())
+        .isEqualTo(10);
 
     input.maxPathsInChangeMessages = 0;
     projectCodeOwnersApiFactory.project(project).updateConfig(input);
-    assertThat(codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(project)).isEqualTo(0);
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).getMaxPathsInChangeMessages())
+        .isEqualTo(0);
 
     input.maxPathsInChangeMessages = GeneralConfig.DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES;
     projectCodeOwnersApiFactory.project(project).updateConfig(input);
-    assertThat(codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(project))
+    assertThat(
+            codeOwnersPluginConfiguration.getProjectConfig(project).getMaxPathsInChangeMessages())
         .isEqualTo(GeneralConfig.DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
   }
 
@@ -544,12 +597,12 @@
     ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
     deleteRef.deleteSingleRef(projectState, RefNames.REFS_CONFIG);
 
-    assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isFalse();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled()).isFalse();
 
     CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
     input.disabled = true;
     projectCodeOwnersApiFactory.project(project).updateConfig(input);
 
-    assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isTrue();
+    assertThat(codeOwnersPluginConfiguration.getProjectConfig(project).isDisabled()).isTrue();
   }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshotTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshotTest.java
new file mode 100644
index 0000000..997eb19
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshotTest.java
@@ -0,0 +1,874 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend.config;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.plugins.codeowners.testing.RequiredApprovalSubject.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.OptionalSubject.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.PrivateInternals_DynamicMapImpl;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
+import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigUpdate;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
+import com.google.gerrit.plugins.codeowners.backend.PathExpressionMatcher;
+import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
+import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.inject.Inject;
+import com.google.inject.Key;
+import com.google.inject.util.Providers;
+import java.nio.file.Path;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link CodeOwnersPluginConfigSnapshot}. */
+public class CodeOwnersPluginConfigSnapshotTest extends AbstractCodeOwnersTest {
+  @Inject private ProjectOperations projectOperations;
+
+  private CodeOwnersPluginConfigSnapshot.Factory codeOwnersPluginConfigSnapshotFactory;
+  private DynamicMap<CodeOwnerBackend> codeOwnerBackends;
+
+  @Before
+  public void setUpCodeOwnersPlugin() throws Exception {
+    codeOwnersPluginConfigSnapshotFactory =
+        plugin.getSysInjector().getInstance(CodeOwnersPluginConfigSnapshot.Factory.class);
+    codeOwnerBackends =
+        plugin.getSysInjector().getInstance(new Key<DynamicMap<CodeOwnerBackend>>() {});
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
+  public void getFileExtensionIfNoneIsConfiguredOnProjectLevel() throws Exception {
+    assertThat(cfgSnapshot().getFileExtension()).value().isEqualTo("foo");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
+  public void fileExtensionOnProjectLevelOverridesDefaultFileExtension() throws Exception {
+    configureFileExtension(project, "bar");
+    assertThat(cfgSnapshot().getFileExtension()).value().isEqualTo("bar");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
+  public void fileExtensionIsInheritedFromParentProject() throws Exception {
+    configureFileExtension(allProjects, "bar");
+    assertThat(cfgSnapshot().getFileExtension()).value().isEqualTo("bar");
+  }
+
+  @Test
+  public void inheritedFileExtensionCanBeOverridden() throws Exception {
+    configureFileExtension(allProjects, "foo");
+    configureFileExtension(project, "bar");
+    assertThat(cfgSnapshot().getFileExtension()).value().isEqualTo("bar");
+  }
+
+  @Test
+  public void getMergeCommitStrategyIfNoneIsConfigured() throws Exception {
+    assertThat(cfgSnapshot().getMergeCommitStrategy())
+        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.mergeCommitStrategy",
+      value = "FILES_WITH_CONFLICT_RESOLUTION")
+  public void getMergeCommitStrategyIfNoneIsConfiguredOnProjectLevel() throws Exception {
+    assertThat(cfgSnapshot().getMergeCommitStrategy())
+        .isEqualTo(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.mergeCommitStrategy",
+      value = "FILES_WITH_CONFLICT_RESOLUTION")
+  public void mergeCommitStrategyOnProjectLevelOverridesGlobalMergeCommitStrategy()
+      throws Exception {
+    configureMergeCommitStrategy(project, MergeCommitStrategy.ALL_CHANGED_FILES);
+    assertThat(cfgSnapshot().getMergeCommitStrategy())
+        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.mergeCommitStrategy",
+      value = "FILES_WITH_CONFLICT_RESOLUTION")
+  public void mergeCommitStrategyIsInheritedFromParentProject() throws Exception {
+    configureMergeCommitStrategy(allProjects, MergeCommitStrategy.ALL_CHANGED_FILES);
+    assertThat(cfgSnapshot().getMergeCommitStrategy())
+        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
+  }
+
+  @Test
+  public void inheritedMergeCommitStrategyCanBeOverridden() throws Exception {
+    configureMergeCommitStrategy(allProjects, MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
+    configureMergeCommitStrategy(project, MergeCommitStrategy.ALL_CHANGED_FILES);
+    assertThat(cfgSnapshot().getMergeCommitStrategy())
+        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
+  }
+
+  @Test
+  public void getFallbackCodeOwnersIfNoneIsConfigured() throws Exception {
+    assertThat(cfgSnapshot().getFallbackCodeOwners()).isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  public void getFallbackCodeOwnersIfNoneIsConfiguredOnProjectLevel() throws Exception {
+    assertThat(cfgSnapshot().getFallbackCodeOwners()).isEqualTo(FallbackCodeOwners.ALL_USERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  public void fallbackCodeOnwersOnProjectLevelOverridesGlobalFallbackCodeOwners() throws Exception {
+    configureFallbackCodeOwners(project, FallbackCodeOwners.NONE);
+    assertThat(cfgSnapshot().getFallbackCodeOwners()).isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  public void fallbackCodeOwnersIsInheritedFromParentProject() throws Exception {
+    configureFallbackCodeOwners(allProjects, FallbackCodeOwners.NONE);
+    assertThat(cfgSnapshot().getFallbackCodeOwners()).isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  public void inheritedFallbackCodeOwnersCanBeOverridden() throws Exception {
+    configureFallbackCodeOwners(allProjects, FallbackCodeOwners.ALL_USERS);
+    configureFallbackCodeOwners(project, FallbackCodeOwners.NONE);
+    assertThat(cfgSnapshot().getFallbackCodeOwners()).isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  public void getMaxPathsInChangeMessagesIfNoneIsConfigured() throws Exception {
+    assertThat(cfgSnapshot().getMaxPathsInChangeMessages())
+        .isEqualTo(GeneralConfig.DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "50")
+  public void getMaxPathsInChangeMessagesIfNoneIsConfiguredOnProjectLevel() throws Exception {
+    assertThat(cfgSnapshot().getMaxPathsInChangeMessages()).isEqualTo(50);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "50")
+  public void maxPathInChangeMessagesOnProjectLevelOverridesGlobalMaxPathInChangeMessages()
+      throws Exception {
+    configureMaxPathsInChangeMessages(project, 20);
+    assertThat(cfgSnapshot().getMaxPathsInChangeMessages()).isEqualTo(20);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "50")
+  public void maxPathInChangeMessagesIsInheritedFromParentProject() throws Exception {
+    configureMaxPathsInChangeMessages(allProjects, 20);
+    assertThat(cfgSnapshot().getMaxPathsInChangeMessages()).isEqualTo(20);
+  }
+
+  @Test
+  public void inheritedMaxPathInChangeMessagesCanBeOverridden() throws Exception {
+    configureMaxPathsInChangeMessages(allProjects, 50);
+    configureMaxPathsInChangeMessages(project, 20);
+    assertThat(cfgSnapshot().getMaxPathsInChangeMessages()).isEqualTo(20);
+  }
+
+  @Test
+  public void cannotCheckForNullBranchIfCodeOwnersFunctionalityIsDisabled() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class, () -> cfgSnapshot().isDisabled(/* branchName= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("branchName");
+  }
+
+  @Test
+  public void checkIfCodeOwnersFunctionalityIsDisabledForNonExistingBranch() throws Exception {
+    assertThat(cfgSnapshot().isDisabled("non-existing")).isFalse();
+  }
+
+  @Test
+  public void checkIfCodeOwnersFunctionalityIsDisabledForProjectWithEmptyConfig() throws Exception {
+    assertThat(cfgSnapshot().isDisabled()).isFalse();
+  }
+
+  @Test
+  public void checkIfCodeOwnersFunctionalityIsDisabledForBranchWithEmptyConfig() throws Exception {
+    assertThat(cfgSnapshot().isDisabled("master")).isFalse();
+  }
+
+  @Test
+  public void codeOwnersFunctionalityIsDisabledForProject() throws Exception {
+    disableCodeOwnersForProject(project);
+    assertThat(cfgSnapshot().isDisabled()).isTrue();
+  }
+
+  @Test
+  public void codeOwnersFunctionalityIsDisabledForBranchIfItIsDisabledForProject()
+      throws Exception {
+    disableCodeOwnersForProject(project);
+    assertThat(cfgSnapshot().isDisabled("master")).isTrue();
+  }
+
+  @Test
+  public void codeOwnersFunctionalityIsDisabledForBranch_exactRef() throws Exception {
+    configureDisabledBranch(project, "refs/heads/master");
+    assertThat(cfgSnapshot().isDisabled("master")).isTrue();
+    assertThat(cfgSnapshot().isDisabled("other")).isFalse();
+  }
+
+  @Test
+  public void codeOwnersFunctionalityIsDisabledForBranch_refPattern() throws Exception {
+    configureDisabledBranch(project, "refs/heads/*");
+    assertThat(cfgSnapshot().isDisabled("master")).isTrue();
+    assertThat(cfgSnapshot().isDisabled("other")).isTrue();
+    assertThat(cfgSnapshot().isDisabled(RefNames.REFS_META)).isFalse();
+  }
+
+  @Test
+  public void codeOwnersFunctionalityIsDisabledForBranch_regularExpression() throws Exception {
+    configureDisabledBranch(project, "^refs/heads/.*");
+    assertThat(cfgSnapshot().isDisabled("master")).isTrue();
+    assertThat(cfgSnapshot().isDisabled("other")).isTrue();
+    assertThat(cfgSnapshot().isDisabled(RefNames.REFS_META)).isFalse();
+  }
+
+  @Test
+  public void codeOwnersFunctionalityIsDisabledForBranch_invalidRegularExpression()
+      throws Exception {
+    configureDisabledBranch(project, "^refs/heads/[");
+    assertThat(cfgSnapshot().isDisabled("master")).isFalse();
+  }
+
+  @Test
+  public void disabledIsInheritedFromParentProject() throws Exception {
+    disableCodeOwnersForProject(allProjects);
+    assertThat(cfgSnapshot().isDisabled()).isTrue();
+  }
+
+  @Test
+  public void inheritedDisabledAlsoCountsForBranch() throws Exception {
+    disableCodeOwnersForProject(allProjects);
+    assertThat(cfgSnapshot().isDisabled("master")).isTrue();
+  }
+
+  @Test
+  public void inheritedDisabledValueIsIgnoredIfInvalid() throws Exception {
+    configureDisabled(project, "invalid");
+    assertThat(cfgSnapshot().isDisabled()).isFalse();
+  }
+
+  @Test
+  public void inheritedDisabledValueIsIgnoredForBranchIfInvalid() throws Exception {
+    configureDisabled(project, "invalid");
+    assertThat(cfgSnapshot().isDisabled("master")).isFalse();
+  }
+
+  @Test
+  public void disabledForOtherProjectHasNoEffect() throws Exception {
+    Project.NameKey otherProject = projectOperations.newProject().create();
+    disableCodeOwnersForProject(otherProject);
+    assertThat(cfgSnapshot().isDisabled()).isFalse();
+  }
+
+  @Test
+  public void disabledBranchForOtherProjectHasNoEffect() throws Exception {
+    Project.NameKey otherProject = projectOperations.newProject().create();
+    configureDisabledBranch(otherProject, "refs/heads/master");
+    assertThat(cfgSnapshot().isDisabled("master")).isFalse();
+  }
+
+  @Test
+  public void disabledBranchIsInheritedFromParentProject() throws Exception {
+    configureDisabledBranch(allProjects, "refs/heads/master");
+    assertThat(cfgSnapshot().isDisabled("master")).isTrue();
+  }
+
+  @Test
+  public void inheritedDisabledCanBeOverridden() throws Exception {
+    disableCodeOwnersForProject(allProjects);
+    enableCodeOwnersForProject(project);
+    assertThat(cfgSnapshot().isDisabled("master")).isFalse();
+  }
+
+  @Test
+  public void inheritedDisabledBranchCanBeOverridden() throws Exception {
+    configureDisabledBranch(allProjects, "refs/heads/master");
+    enableCodeOwnersForAllBranches(project);
+    assertThat(cfgSnapshot().isDisabled("master")).isFalse();
+  }
+
+  @Test
+  public void getBackendForNonExistingBranch() throws Exception {
+    assertThat(cfgSnapshot().getBackend("non-existing")).isInstanceOf(FindOwnersBackend.class);
+  }
+
+  @Test
+  public void getDefaultBackendWhenNoBackendIsConfigured() throws Exception {
+    assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(FindOwnersBackend.class);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = TestCodeOwnerBackend.ID)
+  public void getConfiguredDefaultBackend() throws Exception {
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(TestCodeOwnerBackend.class);
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = "non-existing-backend")
+  public void cannotGetBackendIfNonExistingBackendIsConfigured() throws Exception {
+    InvalidPluginConfigurationException exception =
+        assertThrows(
+            InvalidPluginConfigurationException.class, () -> cfgSnapshot().getBackend("master"));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid configuration of the code-owners plugin. Code owner backend"
+                + " 'non-existing-backend' that is configured in gerrit.config (parameter"
+                + " plugin.code-owners.backend) not found.");
+  }
+
+  @Test
+  public void getBackendConfiguredOnProjectLevel() throws Exception {
+    configureBackend(project, TestCodeOwnerBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(TestCodeOwnerBackend.class);
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = FindOwnersBackend.ID)
+  public void backendConfiguredOnProjectLevelOverridesDefaultBackend() throws Exception {
+    configureBackend(project, TestCodeOwnerBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(TestCodeOwnerBackend.class);
+    }
+  }
+
+  @Test
+  public void backendIsInheritedFromParentProject() throws Exception {
+    configureBackend(allProjects, TestCodeOwnerBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(TestCodeOwnerBackend.class);
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = FindOwnersBackend.ID)
+  public void inheritedBackendOverridesDefaultBackend() throws Exception {
+    configureBackend(allProjects, TestCodeOwnerBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(TestCodeOwnerBackend.class);
+    }
+  }
+
+  @Test
+  public void projectLevelBackendOverridesInheritedBackend() throws Exception {
+    configureBackend(allProjects, TestCodeOwnerBackend.ID);
+    configureBackend(project, FindOwnersBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(FindOwnersBackend.class);
+    }
+  }
+
+  @Test
+  public void cannotGetBackendIfNonExistingBackendIsConfiguredOnProjectLevel() throws Exception {
+    configureBackend(project, "non-existing-backend");
+    InvalidPluginConfigurationException exception =
+        assertThrows(
+            InvalidPluginConfigurationException.class, () -> cfgSnapshot().getBackend("master"));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Invalid configuration of the code-owners plugin. Code owner backend"
+                    + " 'non-existing-backend' that is configured for project %s in"
+                    + " code-owners.config (parameter codeOwners.backend) not found.",
+                project));
+  }
+
+  @Test
+  public void projectLevelBackendForOtherProjectHasNoEffect() throws Exception {
+    Project.NameKey otherProject = projectOperations.newProject().create();
+    configureBackend(otherProject, TestCodeOwnerBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(FindOwnersBackend.class);
+    }
+  }
+
+  @Test
+  public void getBackendConfiguredOnBranchLevel() throws Exception {
+    configureBackend(project, "refs/heads/master", TestCodeOwnerBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(TestCodeOwnerBackend.class);
+    }
+  }
+
+  @Test
+  public void getBackendConfiguredOnBranchLevelShortName() throws Exception {
+    configureBackend(project, "master", TestCodeOwnerBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(TestCodeOwnerBackend.class);
+    }
+  }
+
+  @Test
+  public void branchLevelBackendOnFullNameTakesPrecedenceOverBranchLevelBackendOnShortName()
+      throws Exception {
+    configureBackend(project, "master", TestCodeOwnerBackend.ID);
+    configureBackend(project, "refs/heads/master", FindOwnersBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(FindOwnersBackend.class);
+    }
+  }
+
+  @Test
+  public void branchLevelBackendOverridesProjectLevelBackend() throws Exception {
+    configureBackend(project, TestCodeOwnerBackend.ID);
+    configureBackend(project, "master", FindOwnersBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(FindOwnersBackend.class);
+    }
+  }
+
+  @Test
+  public void cannotGetBackendIfNonExistingBackendIsConfiguredOnBranchLevel() throws Exception {
+    configureBackend(project, "master", "non-existing-backend");
+    InvalidPluginConfigurationException exception =
+        assertThrows(
+            InvalidPluginConfigurationException.class, () -> cfgSnapshot().getBackend("master"));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Invalid configuration of the code-owners plugin. Code owner backend"
+                    + " 'non-existing-backend' that is configured for project %s in"
+                    + " code-owners.config (parameter codeOwners.master.backend) not found.",
+                project));
+  }
+
+  @Test
+  public void branchLevelBackendForOtherBranchHasNoEffect() throws Exception {
+    configureBackend(project, "foo", TestCodeOwnerBackend.ID);
+    try (AutoCloseable registration = registerTestBackend()) {
+      assertThat(cfgSnapshot().getBackend("master")).isInstanceOf(FindOwnersBackend.class);
+    }
+  }
+
+  @Test
+  public void getDefaultRequiredApprovalWhenNoRequiredApprovalIsConfigured() throws Exception {
+    RequiredApproval requiredApproval = cfgSnapshot().getRequiredApproval();
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo(RequiredApprovalConfig.DEFAULT_LABEL);
+    assertThat(requiredApproval).hasValueThat().isEqualTo(RequiredApprovalConfig.DEFAULT_VALUE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Code-Review+2")
+  public void getConfiguredDefaultRequireApproval() throws Exception {
+    RequiredApproval requiredApproval = cfgSnapshot().getRequiredApproval();
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Foo-Bar+1")
+  public void cannotGetRequiredApprovalIfNonExistingLabelIsConfiguredAsRequiredApproval()
+      throws Exception {
+    InvalidPluginConfigurationException exception =
+        assertThrows(
+            InvalidPluginConfigurationException.class, () -> cfgSnapshot().getRequiredApproval());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Invalid configuration of the code-owners plugin. Required approval 'Foo-Bar+1'"
+                    + " that is configured in gerrit.config (parameter"
+                    + " plugin.code-owners.requiredApproval) is invalid: Label Foo-Bar doesn't exist"
+                    + " for project %s.",
+                project.get()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Code-Review+3")
+  public void cannotGetRequiredApprovalIfNonExistingLabelValueIsConfiguredAsRequiredApproval()
+      throws Exception {
+    InvalidPluginConfigurationException exception =
+        assertThrows(
+            InvalidPluginConfigurationException.class, () -> cfgSnapshot().getRequiredApproval());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Invalid configuration of the code-owners plugin. Required approval"
+                    + " 'Code-Review+3' that is configured in gerrit.config (parameter"
+                    + " plugin.code-owners.requiredApproval) is invalid: Label Code-Review on"
+                    + " project %s doesn't allow value 3.",
+                project.get()));
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "INVALID")
+  public void cannotGetRequiredApprovalIfInvalidRequiredApprovalIsConfigured() throws Exception {
+    InvalidPluginConfigurationException exception =
+        assertThrows(
+            InvalidPluginConfigurationException.class, () -> cfgSnapshot().getRequiredApproval());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid configuration of the code-owners plugin. Required approval 'INVALID' that is"
+                + " configured in gerrit.config (parameter plugin.code-owners.requiredApproval) is"
+                + " invalid: Invalid format, expected '<label-name>+<label-value>'.");
+  }
+
+  @Test
+  public void getRequiredApprovalConfiguredOnProjectLevel() throws Exception {
+    configureRequiredApproval(project, "Code-Review+2");
+    RequiredApproval requiredApproval = cfgSnapshot().getRequiredApproval();
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
+  }
+
+  @Test
+  public void getRequiredApprovalMultipleConfiguredOnProjectLevel() throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        RequiredApprovalConfig.KEY_REQUIRED_APPROVAL,
+        ImmutableList.of("Code-Review+2", "Code-Review+1"));
+
+    // If multiple values are set for a key, the last value wins.
+    RequiredApproval requiredApproval = cfgSnapshot().getRequiredApproval();
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(1);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Code-Review+1")
+  public void requiredApprovalConfiguredOnProjectLevelOverridesDefaultRequiredApproval()
+      throws Exception {
+    configureRequiredApproval(project, "Code-Review+2");
+    RequiredApproval requiredApproval = cfgSnapshot().getRequiredApproval();
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
+  }
+
+  @Test
+  public void requiredApprovalIsInheritedFromParentProject() throws Exception {
+    configureRequiredApproval(allProjects, "Code-Review+2");
+    RequiredApproval requiredApproval = cfgSnapshot().getRequiredApproval();
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.backend", value = FindOwnersBackend.ID)
+  public void inheritedRequiredApprovalOverridesDefaultRequiredApproval() throws Exception {
+    configureRequiredApproval(allProjects, "Code-Review+2");
+    RequiredApproval requiredApproval = cfgSnapshot().getRequiredApproval();
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
+  }
+
+  @Test
+  public void projectLevelRequiredApprovalOverridesInheritedRequiredApproval() throws Exception {
+    configureRequiredApproval(allProjects, "Code-Review+1");
+    configureRequiredApproval(project, "Code-Review+2");
+    RequiredApproval requiredApproval = cfgSnapshot().getRequiredApproval();
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
+  }
+
+  @Test
+  public void
+      cannotGetRequiredApprovalIfNonExistingLabelIsConfiguredAsRequiredApprovalOnProjectLevel()
+          throws Exception {
+    configureRequiredApproval(project, "Foo-Bar+1");
+    InvalidPluginConfigurationException exception =
+        assertThrows(
+            InvalidPluginConfigurationException.class, () -> cfgSnapshot().getRequiredApproval());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Invalid configuration of the code-owners plugin. Required approval 'Foo-Bar+1'"
+                    + " that is configured in code-owners.config (parameter"
+                    + " codeOwners.requiredApproval) is invalid: Label Foo-Bar doesn't exist for"
+                    + " project %s.",
+                project.get()));
+  }
+
+  @Test
+  public void
+      cannotGetRequiredApprovalIfNonExistingLabelValueIsConfiguredAsRequiredApprovalOnProjectLevel()
+          throws Exception {
+    configureRequiredApproval(project, "Code-Review+3");
+    InvalidPluginConfigurationException exception =
+        assertThrows(
+            InvalidPluginConfigurationException.class, () -> cfgSnapshot().getRequiredApproval());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Invalid configuration of the code-owners plugin. Required approval"
+                    + " 'Code-Review+3' that is configured in code-owners.config (parameter"
+                    + " codeOwners.requiredApproval) is invalid: Label Code-Review on project %s"
+                    + " doesn't allow value 3.",
+                project.get()));
+  }
+
+  @Test
+  public void cannotGetRequiredApprovalIfInvalidRequiredApprovalIsConfiguredOnProjectLevel()
+      throws Exception {
+    configureRequiredApproval(project, "INVALID");
+    InvalidPluginConfigurationException exception =
+        assertThrows(
+            InvalidPluginConfigurationException.class, () -> cfgSnapshot().getRequiredApproval());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid configuration of the code-owners plugin. Required approval 'INVALID' that is"
+                + " configured in code-owners.config (parameter codeOwners.requiredApproval) is"
+                + " invalid: Invalid format, expected '<label-name>+<label-value>'.");
+  }
+
+  @Test
+  public void projectLevelRequiredApprovalForOtherProjectHasNoEffect() throws Exception {
+    Project.NameKey otherProject = projectOperations.newProject().create();
+    configureRequiredApproval(otherProject, "Code-Review+2");
+    RequiredApproval requiredApproval = cfgSnapshot().getRequiredApproval();
+    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).hasValueThat().isEqualTo(1);
+  }
+
+  @Test
+  public void getOverrideApprovalWhenNoRequiredApprovalIsConfigured() throws Exception {
+    assertThat(cfgSnapshot().getOverrideApproval()).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Code-Review+2")
+  public void getConfiguredDefaultOverrideApproval() throws Exception {
+    ImmutableSet<RequiredApproval> requiredApproval = cfgSnapshot().getOverrideApproval();
+    assertThat(requiredApproval).hasSize(1);
+    assertThat(requiredApproval).element(0).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).element(0).hasValueThat().isEqualTo(2);
+  }
+
+  @Test
+  public void getOverrideApprovalConfiguredOnProjectLevel() throws Exception {
+    configureOverrideApproval(project, "Code-Review+2");
+    ImmutableSet<RequiredApproval> requiredApproval = cfgSnapshot().getOverrideApproval();
+    assertThat(requiredApproval).hasSize(1);
+    assertThat(requiredApproval).element(0).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).element(0).hasValueThat().isEqualTo(2);
+  }
+
+  @Test
+  public void getOverrideApprovalMultipleConfiguredOnProjectLevel() throws Exception {
+    createOwnersOverrideLabel();
+    createOwnersOverrideLabel("Other-Override");
+
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL,
+        ImmutableList.of("Owners-Override+1", "Other-Override+1"));
+
+    ImmutableSet<RequiredApproval> requiredApprovals = cfgSnapshot().getOverrideApproval();
+    assertThat(
+            requiredApprovals.stream()
+                .map(requiredApproval -> requiredApproval.toString())
+                .collect(toImmutableSet()))
+        .containsExactly("Owners-Override+1", "Other-Override+1");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "INVALID")
+  public void getOverrideApprovalIfInvalidOverrideApprovalIsConfigured() throws Exception {
+    assertThat(cfgSnapshot().getOverrideApproval()).isEmpty();
+  }
+
+  @Test
+  public void getOverrideApprovalIfInvalidOverrideApprovalIsConfiguredOnProjectLevel()
+      throws Exception {
+    configureOverrideApproval(project, "INVALID");
+    assertThat(cfgSnapshot().getOverrideApproval()).isEmpty();
+  }
+
+  @Test
+  public void getOverrideApprovalDuplicatesAreFilteredOut() throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL,
+        ImmutableList.of("Code-Review+2", "Code-Review+1", "Code-Review+2"));
+
+    // If multiple values are set for a key, the last value wins.
+    ImmutableSet<RequiredApproval> requiredApproval = cfgSnapshot().getOverrideApproval();
+    assertThat(requiredApproval).hasSize(1);
+    assertThat(requiredApproval).element(0).hasLabelNameThat().isEqualTo("Code-Review");
+    assertThat(requiredApproval).element(0).hasValueThat().isEqualTo(1);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void implicitApprovalsAreDisabledIfRequiredLabelIgnoresSelfApprovals() throws Exception {
+    assertThat(cfgSnapshot().areImplicitApprovalsEnabled()).isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.ignoreSelfApproval = true;
+    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(cfgSnapshot().areImplicitApprovalsEnabled()).isFalse();
+  }
+
+  private CodeOwnersPluginConfigSnapshot cfgSnapshot() {
+    return codeOwnersPluginConfigSnapshotFactory.create(project);
+  }
+
+  private void configureFileExtension(Project.NameKey project, String fileExtension)
+      throws Exception {
+    setCodeOwnersConfig(
+        project, /* subsection= */ null, GeneralConfig.KEY_FILE_EXTENSION, fileExtension);
+  }
+
+  private void configureMergeCommitStrategy(
+      Project.NameKey project, MergeCommitStrategy mergeCommitStrategy) throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        GeneralConfig.KEY_MERGE_COMMIT_STRATEGY,
+        mergeCommitStrategy.name());
+  }
+
+  private void configureFallbackCodeOwners(
+      Project.NameKey project, FallbackCodeOwners fallbackCodeOwners) throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        GeneralConfig.KEY_FALLBACK_CODE_OWNERS,
+        fallbackCodeOwners.name());
+  }
+
+  private void configureMaxPathsInChangeMessages(
+      Project.NameKey project, int maxPathsInChangeMessages) throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        GeneralConfig.KEY_MAX_PATHS_IN_CHANGE_MESSAGES,
+        Integer.toString(maxPathsInChangeMessages));
+  }
+
+  private void configureDisabled(Project.NameKey project, String disabled) throws Exception {
+    setCodeOwnersConfig(project, /* subsection= */ null, StatusConfig.KEY_DISABLED, disabled);
+  }
+
+  private void configureDisabledBranch(Project.NameKey project, String disabledBranch)
+      throws Exception {
+    setCodeOwnersConfig(
+        project, /* subsection= */ null, StatusConfig.KEY_DISABLED_BRANCH, disabledBranch);
+  }
+
+  private void enableCodeOwnersForAllBranches(Project.NameKey project) throws Exception {
+    setCodeOwnersConfig(project, /* subsection= */ null, StatusConfig.KEY_DISABLED_BRANCH, "");
+  }
+
+  private void configureBackend(Project.NameKey project, String backendName) throws Exception {
+    configureBackend(project, /* branch= */ null, backendName);
+  }
+
+  private void configureBackend(
+      Project.NameKey project, @Nullable String branch, String backendName) throws Exception {
+    setCodeOwnersConfig(project, branch, BackendConfig.KEY_BACKEND, backendName);
+  }
+
+  private void configureRequiredApproval(Project.NameKey project, String requiredApproval)
+      throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        RequiredApprovalConfig.KEY_REQUIRED_APPROVAL,
+        requiredApproval);
+  }
+
+  private void configureOverrideApproval(Project.NameKey project, String requiredApproval)
+      throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL,
+        requiredApproval);
+  }
+
+  private AutoCloseable registerTestBackend() {
+    RegistrationHandle registrationHandle =
+        ((PrivateInternals_DynamicMapImpl<CodeOwnerBackend>) codeOwnerBackends)
+            .put("gerrit", TestCodeOwnerBackend.ID, Providers.of(new TestCodeOwnerBackend()));
+    return registrationHandle::remove;
+  }
+
+  private static class TestCodeOwnerBackend implements CodeOwnerBackend {
+    static final String ID = "test-backend";
+
+    @Override
+    public Optional<CodeOwnerConfig> getCodeOwnerConfig(
+        CodeOwnerConfig.Key codeOwnerConfigKey,
+        @Nullable RevWalk revWalk,
+        @Nullable ObjectId revision) {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public Optional<CodeOwnerConfig> upsertCodeOwnerConfig(
+        CodeOwnerConfig.Key codeOwnerConfigKey,
+        CodeOwnerConfigUpdate codeOwnerConfigUpdate,
+        @Nullable IdentifiedUser currentUser) {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public boolean isCodeOwnerConfigFile(NameKey project, String fileName) {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public Path getFilePath(CodeOwnerConfig.Key codeOwnerConfigKey) {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
+    public Optional<PathExpressionMatcher> getPathExpressionMatcher() {
+      return Optional.empty();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigurationTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigurationTest.java
index 72d6d75..71c6849 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigurationTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigurationTest.java
@@ -14,42 +14,13 @@
 
 package com.google.gerrit.plugins.codeowners.backend.config;
 
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.plugins.codeowners.testing.RequiredApprovalSubject.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.config.GerritConfig;
-import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.Project.NameKey;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.common.LabelDefinitionInput;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.PrivateInternals_DynamicMapImpl;
-import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
-import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
-import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
-import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigUpdate;
-import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
-import com.google.gerrit.plugins.codeowners.backend.PathExpressionMatcher;
-import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
-import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.inject.Inject;
-import com.google.inject.Key;
-import com.google.inject.util.Providers;
-import java.nio.file.Path;
-import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -58,408 +29,30 @@
  * com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration}.
  */
 public class CodeOwnersPluginConfigurationTest extends AbstractCodeOwnersTest {
-  @Inject private ProjectOperations projectOperations;
-
   private CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
-  private DynamicMap<CodeOwnerBackend> codeOwnerBackends;
 
   @Before
   public void setUpCodeOwnersPlugin() throws Exception {
     codeOwnersPluginConfiguration =
         plugin.getSysInjector().getInstance(CodeOwnersPluginConfiguration.class);
-    codeOwnerBackends =
-        plugin.getSysInjector().getInstance(new Key<DynamicMap<CodeOwnerBackend>>() {});
   }
 
   @Test
-  public void cannotCheckForNullProjectIfCodeOwnersFunctionalityIsDisabled() throws Exception {
+  public void cannotGetProjectConfigForNullProject() throws Exception {
     NullPointerException npe =
         assertThrows(
             NullPointerException.class,
-            () -> codeOwnersPluginConfiguration.isDisabled(/* project= */ (Project.NameKey) null));
-    assertThat(npe).hasMessageThat().isEqualTo("project");
+            () -> codeOwnersPluginConfiguration.getProjectConfig(/* projectName= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("projectName");
   }
 
   @Test
-  public void cannotCheckForNullBranchIfCodeOwnersFunctionalityIsDisabled() throws Exception {
-    NullPointerException npe =
-        assertThrows(
-            NullPointerException.class,
-            () ->
-                codeOwnersPluginConfiguration.isDisabled(
-                    /* branchNameKey= */ (BranchNameKey) null));
-    assertThat(npe).hasMessageThat().isEqualTo("branchNameKey");
-  }
-
-  @Test
-  public void cannotCheckIfCodeOwnersFunctionalityIsDisabledForNonExistingProject()
-      throws Exception {
+  public void cannotGetProjectConfigForNonExistingProject() throws Exception {
     IllegalStateException exception =
         assertThrows(
             IllegalStateException.class,
             () ->
-                codeOwnersPluginConfiguration.isDisabled(Project.nameKey("non-existing-project")));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            "cannot get code-owners plugin config for non-existing project non-existing-project");
-  }
-
-  @Test
-  public void cannotCheckIfCodeOwnersFunctionalityIsDisabledForBranchOfNonExistingProject()
-      throws Exception {
-    IllegalStateException exception =
-        assertThrows(
-            IllegalStateException.class,
-            () ->
-                codeOwnersPluginConfiguration.isDisabled(
-                    BranchNameKey.create(Project.nameKey("non-existing-project"), "master")));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            "cannot get code-owners plugin config for non-existing project non-existing-project");
-  }
-
-  @Test
-  public void checkIfCodeOwnersFunctionalityIsDisabledForNonExistingBranch() throws Exception {
-    assertThat(
-            codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "non-existing")))
-        .isFalse();
-  }
-
-  @Test
-  public void checkIfCodeOwnersFunctionalityIsDisabledForProjectWithEmptyConfig() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isFalse();
-  }
-
-  @Test
-  public void checkIfCodeOwnersFunctionalityIsDisabledForBranchWithEmptyConfig() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isFalse();
-  }
-
-  @Test
-  public void codeOwnersFunctionalityIsDisabledForProject() throws Exception {
-    disableCodeOwnersForProject(project);
-    assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isTrue();
-  }
-
-  @Test
-  public void codeOwnersFunctionalityIsDisabledForBranchIfItIsDisabledForProject()
-      throws Exception {
-    disableCodeOwnersForProject(project);
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isTrue();
-  }
-
-  @Test
-  public void codeOwnersFunctionalityIsDisabledForBranch_exactRef() throws Exception {
-    configureDisabledBranch(project, "refs/heads/master");
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isTrue();
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "other")))
-        .isFalse();
-  }
-
-  @Test
-  public void codeOwnersFunctionalityIsDisabledForBranch_refPattern() throws Exception {
-    configureDisabledBranch(project, "refs/heads/*");
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isTrue();
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "other")))
-        .isTrue();
-    assertThat(
-            codeOwnersPluginConfiguration.isDisabled(
-                BranchNameKey.create(project, RefNames.REFS_META)))
-        .isFalse();
-  }
-
-  @Test
-  public void codeOwnersFunctionalityIsDisabledForBranch_regularExpression() throws Exception {
-    configureDisabledBranch(project, "^refs/heads/.*");
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isTrue();
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "other")))
-        .isTrue();
-    assertThat(
-            codeOwnersPluginConfiguration.isDisabled(
-                BranchNameKey.create(project, RefNames.REFS_META)))
-        .isFalse();
-  }
-
-  @Test
-  public void codeOwnersFunctionalityIsDisabledForBranch_invalidRegularExpression()
-      throws Exception {
-    configureDisabledBranch(project, "^refs/heads/[");
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isFalse();
-  }
-
-  @Test
-  public void disabledIsInheritedFromParentProject() throws Exception {
-    disableCodeOwnersForProject(allProjects);
-    assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isTrue();
-  }
-
-  @Test
-  public void inheritedDisabledAlsoCountsForBranch() throws Exception {
-    disableCodeOwnersForProject(allProjects);
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isTrue();
-  }
-
-  @Test
-  public void inheritedDisabledValueIsIgnoredIfInvalid() throws Exception {
-    configureDisabled(project, "invalid");
-    assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isFalse();
-  }
-
-  @Test
-  public void inheritedDisabledValueIsIgnoredForBranchIfInvalid() throws Exception {
-    configureDisabled(project, "invalid");
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isFalse();
-  }
-
-  @Test
-  public void disabledForOtherProjectHasNoEffect() throws Exception {
-    Project.NameKey otherProject = projectOperations.newProject().create();
-    disableCodeOwnersForProject(otherProject);
-    assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isFalse();
-  }
-
-  @Test
-  public void disabledBranchForOtherProjectHasNoEffect() throws Exception {
-    Project.NameKey otherProject = projectOperations.newProject().create();
-    configureDisabledBranch(otherProject, "refs/heads/master");
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isFalse();
-  }
-
-  @Test
-  public void disabledBranchIsInheritedFromParentProject() throws Exception {
-    configureDisabledBranch(allProjects, "refs/heads/master");
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isTrue();
-  }
-
-  @Test
-  public void inheritedDisabledCanBeOverridden() throws Exception {
-    disableCodeOwnersForProject(allProjects);
-    enableCodeOwnersForProject(project);
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isFalse();
-  }
-
-  @Test
-  public void inheritedDisabledBranchCanBeOverridden() throws Exception {
-    configureDisabledBranch(allProjects, "refs/heads/master");
-    enableCodeOwnersForAllBranches(project);
-    assertThat(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .isFalse();
-  }
-
-  @Test
-  public void cannotGetBackendForNonExistingProject() throws Exception {
-    IllegalStateException exception =
-        assertThrows(
-            IllegalStateException.class,
-            () ->
-                codeOwnersPluginConfiguration.getBackend(
-                    BranchNameKey.create(Project.nameKey("non-existing-project"), "master")));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            "cannot get code-owners plugin config for non-existing project non-existing-project");
-  }
-
-  @Test
-  public void getBackendForNonExistingBranch() throws Exception {
-    assertThat(
-            codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "non-existing")))
-        .isInstanceOf(FindOwnersBackend.class);
-  }
-
-  @Test
-  public void getDefaultBackendWhenNoBackendIsConfigured() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-        .isInstanceOf(FindOwnersBackend.class);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.backend", value = TestCodeOwnerBackend.ID)
-  public void getConfiguredDefaultBackend() throws Exception {
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(TestCodeOwnerBackend.class);
-    }
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.backend", value = "non-existing-backend")
-  public void cannotGetBackendIfNonExistingBackendIsConfigured() throws Exception {
-    InvalidPluginConfigurationException exception =
-        assertThrows(
-            InvalidPluginConfigurationException.class,
-            () ->
-                codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            "Invalid configuration of the code-owners plugin. Code owner backend"
-                + " 'non-existing-backend' that is configured in gerrit.config (parameter"
-                + " plugin.code-owners.backend) not found.");
-  }
-
-  @Test
-  public void getBackendConfiguredOnProjectLevel() throws Exception {
-    configureBackend(project, TestCodeOwnerBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(TestCodeOwnerBackend.class);
-    }
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.backend", value = FindOwnersBackend.ID)
-  public void backendConfiguredOnProjectLevelOverridesDefaultBackend() throws Exception {
-    configureBackend(project, TestCodeOwnerBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(TestCodeOwnerBackend.class);
-    }
-  }
-
-  @Test
-  public void backendIsInheritedFromParentProject() throws Exception {
-    configureBackend(allProjects, TestCodeOwnerBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(TestCodeOwnerBackend.class);
-    }
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.backend", value = FindOwnersBackend.ID)
-  public void inheritedBackendOverridesDefaultBackend() throws Exception {
-    configureBackend(allProjects, TestCodeOwnerBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(TestCodeOwnerBackend.class);
-    }
-  }
-
-  @Test
-  public void projectLevelBackendOverridesInheritedBackend() throws Exception {
-    configureBackend(allProjects, TestCodeOwnerBackend.ID);
-    configureBackend(project, FindOwnersBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(FindOwnersBackend.class);
-    }
-  }
-
-  @Test
-  public void cannotGetBackendIfNonExistingBackendIsConfiguredOnProjectLevel() throws Exception {
-    configureBackend(project, "non-existing-backend");
-    InvalidPluginConfigurationException exception =
-        assertThrows(
-            InvalidPluginConfigurationException.class,
-            () ->
-                codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            String.format(
-                "Invalid configuration of the code-owners plugin. Code owner backend"
-                    + " 'non-existing-backend' that is configured for project %s in"
-                    + " code-owners.config (parameter codeOwners.backend) not found.",
-                project));
-  }
-
-  @Test
-  public void projectLevelBackendForOtherProjectHasNoEffect() throws Exception {
-    Project.NameKey otherProject = projectOperations.newProject().create();
-    configureBackend(otherProject, TestCodeOwnerBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(FindOwnersBackend.class);
-    }
-  }
-
-  @Test
-  public void getBackendConfiguredOnBranchLevel() throws Exception {
-    configureBackend(project, "refs/heads/master", TestCodeOwnerBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(TestCodeOwnerBackend.class);
-    }
-  }
-
-  @Test
-  public void getBackendConfiguredOnBranchLevelShortName() throws Exception {
-    configureBackend(project, "master", TestCodeOwnerBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(TestCodeOwnerBackend.class);
-    }
-  }
-
-  @Test
-  public void branchLevelBackendOnFullNameTakesPrecedenceOverBranchLevelBackendOnShortName()
-      throws Exception {
-    configureBackend(project, "master", TestCodeOwnerBackend.ID);
-    configureBackend(project, "refs/heads/master", FindOwnersBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(FindOwnersBackend.class);
-    }
-  }
-
-  @Test
-  public void branchLevelBackendOverridesProjectLevelBackend() throws Exception {
-    configureBackend(project, TestCodeOwnerBackend.ID);
-    configureBackend(project, "master", FindOwnersBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(FindOwnersBackend.class);
-    }
-  }
-
-  @Test
-  public void cannotGetBackendIfNonExistingBackendIsConfiguredOnBranchLevel() throws Exception {
-    configureBackend(project, "master", "non-existing-backend");
-    InvalidPluginConfigurationException exception =
-        assertThrows(
-            InvalidPluginConfigurationException.class,
-            () ->
-                codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            String.format(
-                "Invalid configuration of the code-owners plugin. Code owner backend"
-                    + " 'non-existing-backend' that is configured for project %s in"
-                    + " code-owners.config (parameter codeOwners.master.backend) not found.",
-                project));
-  }
-
-  @Test
-  public void branchLevelBackendForOtherBranchHasNoEffect() throws Exception {
-    configureBackend(project, "foo", TestCodeOwnerBackend.ID);
-    try (AutoCloseable registration = registerTestBackend()) {
-      assertThat(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-          .isInstanceOf(FindOwnersBackend.class);
-    }
-  }
-
-  @Test
-  public void cannotGetRequiredApprovalForNonExistingProject() throws Exception {
-    IllegalStateException exception =
-        assertThrows(
-            IllegalStateException.class,
-            () ->
-                codeOwnersPluginConfiguration.getRequiredApproval(
+                codeOwnersPluginConfiguration.getProjectConfig(
                     Project.nameKey("non-existing-project")));
     assertThat(exception)
         .hasMessageThat()
@@ -468,285 +61,6 @@
   }
 
   @Test
-  public void getDefaultRequiredApprovalWhenNoRequiredApprovalIsConfigured() throws Exception {
-    RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    assertThat(requiredApproval).hasLabelNameThat().isEqualTo(RequiredApprovalConfig.DEFAULT_LABEL);
-    assertThat(requiredApproval).hasValueThat().isEqualTo(RequiredApprovalConfig.DEFAULT_VALUE);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Code-Review+2")
-  public void getConfiguredDefaultRequireApproval() throws Exception {
-    RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
-    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Foo-Bar+1")
-  public void cannotGetRequiredApprovalIfNonExistingLabelIsConfiguredAsRequiredApproval()
-      throws Exception {
-    InvalidPluginConfigurationException exception =
-        assertThrows(
-            InvalidPluginConfigurationException.class,
-            () -> codeOwnersPluginConfiguration.getRequiredApproval(project));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            String.format(
-                "Invalid configuration of the code-owners plugin. Required approval 'Foo-Bar+1'"
-                    + " that is configured in gerrit.config (parameter"
-                    + " plugin.code-owners.requiredApproval) is invalid: Label Foo-Bar doesn't exist"
-                    + " for project %s.",
-                project.get()));
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Code-Review+3")
-  public void cannotGetRequiredApprovalIfNonExistingLabelValueIsConfiguredAsRequiredApproval()
-      throws Exception {
-    InvalidPluginConfigurationException exception =
-        assertThrows(
-            InvalidPluginConfigurationException.class,
-            () -> codeOwnersPluginConfiguration.getRequiredApproval(project));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            String.format(
-                "Invalid configuration of the code-owners plugin. Required approval"
-                    + " 'Code-Review+3' that is configured in gerrit.config (parameter"
-                    + " plugin.code-owners.requiredApproval) is invalid: Label Code-Review on"
-                    + " project %s doesn't allow value 3.",
-                project.get()));
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "INVALID")
-  public void cannotGetRequiredApprovalIfInvalidRequiredApprovalIsConfigured() throws Exception {
-    InvalidPluginConfigurationException exception =
-        assertThrows(
-            InvalidPluginConfigurationException.class,
-            () -> codeOwnersPluginConfiguration.getRequiredApproval(project));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            "Invalid configuration of the code-owners plugin. Required approval 'INVALID' that is"
-                + " configured in gerrit.config (parameter plugin.code-owners.requiredApproval) is"
-                + " invalid: Invalid format, expected '<label-name>+<label-value>'.");
-  }
-
-  @Test
-  public void getRequiredApprovalConfiguredOnProjectLevel() throws Exception {
-    configureRequiredApproval(project, "Code-Review+2");
-    RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
-    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
-  }
-
-  @Test
-  public void getRequiredApprovalMultipleConfiguredOnProjectLevel() throws Exception {
-    setCodeOwnersConfig(
-        project,
-        /* subsection= */ null,
-        RequiredApprovalConfig.KEY_REQUIRED_APPROVAL,
-        ImmutableList.of("Code-Review+2", "Code-Review+1"));
-
-    // If multiple values are set for a key, the last value wins.
-    RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
-    assertThat(requiredApproval).hasValueThat().isEqualTo(1);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Code-Review+1")
-  public void requiredApprovalConfiguredOnProjectLevelOverridesDefaultRequiredApproval()
-      throws Exception {
-    configureRequiredApproval(project, "Code-Review+2");
-    RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
-    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
-  }
-
-  @Test
-  public void requiredApprovalIsInheritedFromParentProject() throws Exception {
-    configureRequiredApproval(allProjects, "Code-Review+2");
-    RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
-    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.backend", value = FindOwnersBackend.ID)
-  public void inheritedRequiredApprovalOverridesDefaultRequiredApproval() throws Exception {
-    configureRequiredApproval(allProjects, "Code-Review+2");
-    RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
-    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
-  }
-
-  @Test
-  public void projectLevelRequiredApprovalOverridesInheritedRequiredApproval() throws Exception {
-    configureRequiredApproval(allProjects, "Code-Review+1");
-    configureRequiredApproval(project, "Code-Review+2");
-    RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
-    assertThat(requiredApproval).hasValueThat().isEqualTo(2);
-  }
-
-  @Test
-  public void
-      cannotGetRequiredApprovalIfNonExistingLabelIsConfiguredAsRequiredApprovalOnProjectLevel()
-          throws Exception {
-    configureRequiredApproval(project, "Foo-Bar+1");
-    InvalidPluginConfigurationException exception =
-        assertThrows(
-            InvalidPluginConfigurationException.class,
-            () -> codeOwnersPluginConfiguration.getRequiredApproval(project));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            String.format(
-                "Invalid configuration of the code-owners plugin. Required approval 'Foo-Bar+1'"
-                    + " that is configured in code-owners.config (parameter"
-                    + " codeOwners.requiredApproval) is invalid: Label Foo-Bar doesn't exist for"
-                    + " project %s.",
-                project.get()));
-  }
-
-  @Test
-  public void
-      cannotGetRequiredApprovalIfNonExistingLabelValueIsConfiguredAsRequiredApprovalOnProjectLevel()
-          throws Exception {
-    configureRequiredApproval(project, "Code-Review+3");
-    InvalidPluginConfigurationException exception =
-        assertThrows(
-            InvalidPluginConfigurationException.class,
-            () -> codeOwnersPluginConfiguration.getRequiredApproval(project));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            String.format(
-                "Invalid configuration of the code-owners plugin. Required approval"
-                    + " 'Code-Review+3' that is configured in code-owners.config (parameter"
-                    + " codeOwners.requiredApproval) is invalid: Label Code-Review on project %s"
-                    + " doesn't allow value 3.",
-                project.get()));
-  }
-
-  @Test
-  public void cannotGetRequiredApprovalIfInvalidRequiredApprovalIsConfiguredOnProjectLevel()
-      throws Exception {
-    configureRequiredApproval(project, "INVALID");
-    InvalidPluginConfigurationException exception =
-        assertThrows(
-            InvalidPluginConfigurationException.class,
-            () -> codeOwnersPluginConfiguration.getRequiredApproval(project));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            "Invalid configuration of the code-owners plugin. Required approval 'INVALID' that is"
-                + " configured in code-owners.config (parameter codeOwners.requiredApproval) is"
-                + " invalid: Invalid format, expected '<label-name>+<label-value>'.");
-  }
-
-  @Test
-  public void projectLevelRequiredApprovalForOtherProjectHasNoEffect() throws Exception {
-    Project.NameKey otherProject = projectOperations.newProject().create();
-    configureRequiredApproval(otherProject, "Code-Review+2");
-    RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project);
-    assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review");
-    assertThat(requiredApproval).hasValueThat().isEqualTo(1);
-  }
-
-  @Test
-  public void cannotGetOverrideApprovalForNonExistingProject() throws Exception {
-    IllegalStateException exception =
-        assertThrows(
-            IllegalStateException.class,
-            () ->
-                codeOwnersPluginConfiguration.getOverrideApproval(
-                    Project.nameKey("non-existing-project")));
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            "cannot get code-owners plugin config for non-existing project non-existing-project");
-  }
-
-  @Test
-  public void getOverrideApprovalWhenNoRequiredApprovalIsConfigured() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getOverrideApproval(project)).isEmpty();
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "Code-Review+2")
-  public void getConfiguredDefaultOverrideApproval() throws Exception {
-    ImmutableSet<RequiredApproval> requiredApproval =
-        codeOwnersPluginConfiguration.getOverrideApproval(project);
-    assertThat(requiredApproval).hasSize(1);
-    assertThat(requiredApproval).element(0).hasLabelNameThat().isEqualTo("Code-Review");
-    assertThat(requiredApproval).element(0).hasValueThat().isEqualTo(2);
-  }
-
-  @Test
-  public void getOverrideApprovalConfiguredOnProjectLevel() throws Exception {
-    configureOverrideApproval(project, "Code-Review+2");
-    ImmutableSet<RequiredApproval> requiredApproval =
-        codeOwnersPluginConfiguration.getOverrideApproval(project);
-    assertThat(requiredApproval).hasSize(1);
-    assertThat(requiredApproval).element(0).hasLabelNameThat().isEqualTo("Code-Review");
-    assertThat(requiredApproval).element(0).hasValueThat().isEqualTo(2);
-  }
-
-  @Test
-  public void getOverrideApprovalMultipleConfiguredOnProjectLevel() throws Exception {
-    createOwnersOverrideLabel();
-    createOwnersOverrideLabel("Other-Override");
-
-    setCodeOwnersConfig(
-        project,
-        /* subsection= */ null,
-        OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL,
-        ImmutableList.of("Owners-Override+1", "Other-Override+1"));
-
-    ImmutableSet<RequiredApproval> requiredApprovals =
-        codeOwnersPluginConfiguration.getOverrideApproval(project);
-    assertThat(
-            requiredApprovals.stream()
-                .map(requiredApproval -> requiredApproval.toString())
-                .collect(toImmutableSet()))
-        .containsExactly("Owners-Override+1", "Other-Override+1");
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.overrideApproval", value = "INVALID")
-  public void getOverrideApprovalIfInvalidOverrideApprovalIsConfigured() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getOverrideApproval(project)).isEmpty();
-  }
-
-  @Test
-  public void getOverrideApprovalIfInvalidOverrideApprovalIsConfiguredOnProjectLevel()
-      throws Exception {
-    configureOverrideApproval(project, "INVALID");
-    assertThat(codeOwnersPluginConfiguration.getOverrideApproval(project)).isEmpty();
-  }
-
-  @Test
-  public void getOverrideApprovalDuplicatesAreFilteredOut() throws Exception {
-    setCodeOwnersConfig(
-        project,
-        /* subsection= */ null,
-        OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL,
-        ImmutableList.of("Code-Review+2", "Code-Review+1", "Code-Review+2"));
-
-    // If multiple values are set for a key, the last value wins.
-    ImmutableSet<RequiredApproval> requiredApproval =
-        codeOwnersPluginConfiguration.getOverrideApproval(project);
-    assertThat(requiredApproval).hasSize(1);
-    assertThat(requiredApproval).element(0).hasLabelNameThat().isEqualTo("Code-Review");
-    assertThat(requiredApproval).element(0).hasValueThat().isEqualTo(1);
-  }
-
-  @Test
   @GerritConfig(name = "plugin.code-owners.enableExperimentalRestEndpoints", value = "false")
   public void checkExperimentalRestEndpointsEnabledThrowsExceptionIfDisabled() throws Exception {
     MethodNotAllowedException exception =
@@ -774,315 +88,4 @@
   public void experimentalRestEndpointsNotEnabled_invalidConfig() throws Exception {
     assertThat(codeOwnersPluginConfiguration.areExperimentalRestEndpointsEnabled()).isFalse();
   }
-
-  @Test
-  public void cannotGetFileExtensionForNullProject() throws Exception {
-    NullPointerException npe =
-        assertThrows(
-            NullPointerException.class,
-            () -> codeOwnersPluginConfiguration.getFileExtension(/* project= */ null));
-    assertThat(npe).hasMessageThat().isEqualTo("project");
-  }
-
-  @Test
-  public void getFileExtensionIfNoneIsConfigured() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getFileExtension(project)).isEmpty();
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
-  public void getFileExtensionIfNoneIsConfiguredOnProjectLevel() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getFileExtension(project)).value().isEqualTo("foo");
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
-  public void fileExtensionOnProjectLevelOverridesDefaultFileExtension() throws Exception {
-    configureFileExtension(project, "bar");
-    assertThat(codeOwnersPluginConfiguration.getFileExtension(project)).value().isEqualTo("bar");
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
-  public void fileExtensionIsInheritedFromParentProject() throws Exception {
-    configureFileExtension(allProjects, "bar");
-    assertThat(codeOwnersPluginConfiguration.getFileExtension(project)).value().isEqualTo("bar");
-  }
-
-  @Test
-  public void inheritedFileExtensionCanBeOverridden() throws Exception {
-    configureFileExtension(allProjects, "foo");
-    configureFileExtension(project, "bar");
-    assertThat(codeOwnersPluginConfiguration.getFileExtension(project)).value().isEqualTo("bar");
-  }
-
-  @Test
-  public void cannotGetMergeCommitStrategyForNullProject() throws Exception {
-    NullPointerException npe =
-        assertThrows(
-            NullPointerException.class,
-            () -> codeOwnersPluginConfiguration.getMergeCommitStrategy(/* project= */ null));
-    assertThat(npe).hasMessageThat().isEqualTo("project");
-  }
-
-  @Test
-  public void getMergeCommitStrategyIfNoneIsConfigured() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
-        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "plugin.code-owners.mergeCommitStrategy",
-      value = "FILES_WITH_CONFLICT_RESOLUTION")
-  public void getMergeCommitStrategyIfNoneIsConfiguredOnProjectLevel() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
-        .isEqualTo(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "plugin.code-owners.mergeCommitStrategy",
-      value = "FILES_WITH_CONFLICT_RESOLUTION")
-  public void mergeCommitStrategyOnProjectLevelOverridesGlobalMergeCommitStrategy()
-      throws Exception {
-    configureMergeCommitStrategy(project, MergeCommitStrategy.ALL_CHANGED_FILES);
-    assertThat(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
-        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "plugin.code-owners.mergeCommitStrategy",
-      value = "FILES_WITH_CONFLICT_RESOLUTION")
-  public void mergeCommitStrategyIsInheritedFromParentProject() throws Exception {
-    configureMergeCommitStrategy(allProjects, MergeCommitStrategy.ALL_CHANGED_FILES);
-    assertThat(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
-        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
-  }
-
-  @Test
-  public void inheritedMergeCommitStrategyCanBeOverridden() throws Exception {
-    configureMergeCommitStrategy(allProjects, MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
-    configureMergeCommitStrategy(project, MergeCommitStrategy.ALL_CHANGED_FILES);
-    assertThat(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
-        .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
-  }
-
-  @Test
-  public void cannotGetFallbackCodeOwnersForNullProject() throws Exception {
-    NullPointerException npe =
-        assertThrows(
-            NullPointerException.class,
-            () -> codeOwnersPluginConfiguration.getFallbackCodeOwners(/* project= */ null));
-    assertThat(npe).hasMessageThat().isEqualTo("project");
-  }
-
-  @Test
-  public void getFallbackCodeOwnersIfNoneIsConfigured() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
-        .isEqualTo(FallbackCodeOwners.NONE);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
-  public void getFallbackCodeOwnersIfNoneIsConfiguredOnProjectLevel() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
-        .isEqualTo(FallbackCodeOwners.ALL_USERS);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
-  public void fallbackCodeOnwersOnProjectLevelOverridesGlobalFallbackCodeOwners() throws Exception {
-    configureFallbackCodeOwners(project, FallbackCodeOwners.NONE);
-    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
-        .isEqualTo(FallbackCodeOwners.NONE);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
-  public void fallbackCodeOwnersIsInheritedFromParentProject() throws Exception {
-    configureFallbackCodeOwners(allProjects, FallbackCodeOwners.NONE);
-    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
-        .isEqualTo(FallbackCodeOwners.NONE);
-  }
-
-  @Test
-  public void inheritedFallbackCodeOwnersCanBeOverridden() throws Exception {
-    configureFallbackCodeOwners(allProjects, FallbackCodeOwners.ALL_USERS);
-    configureFallbackCodeOwners(project, FallbackCodeOwners.NONE);
-    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
-        .isEqualTo(FallbackCodeOwners.NONE);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
-  public void implicitApprovalsAreDisabledIfRequiredLabelIgnoresSelfApprovals() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(project)).isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.ignoreSelfApproval = true;
-    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
-    assertThat(codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(project)).isFalse();
-  }
-
-  @Test
-  public void cannotGetMaxPathsInChangeMessagesForNullProject() throws Exception {
-    NullPointerException npe =
-        assertThrows(
-            NullPointerException.class,
-            () -> codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(/* project= */ null));
-    assertThat(npe).hasMessageThat().isEqualTo("project");
-  }
-
-  @Test
-  public void getMaxPathsInChangeMessagesIfNoneIsConfigured() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(project))
-        .isEqualTo(GeneralConfig.DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "50")
-  public void getMaxPathsInChangeMessagesIfNoneIsConfiguredOnProjectLevel() throws Exception {
-    assertThat(codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(project)).isEqualTo(50);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "50")
-  public void maxPathInChangeMessagesOnProjectLevelOverridesGlobalMaxPathInChangeMessages()
-      throws Exception {
-    configureFallbackCodeOwners(project, FallbackCodeOwners.NONE);
-    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
-        .isEqualTo(FallbackCodeOwners.NONE);
-  }
-
-  @Test
-  @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "50")
-  public void maxPathInChangeMessagesIsInheritedFromParentProject() throws Exception {
-    configureMaxPathsInChangeMessages(allProjects, 20);
-    assertThat(codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(project)).isEqualTo(20);
-  }
-
-  @Test
-  public void inheritedMaxPathInChangeMessagesCanBeOverridden() throws Exception {
-    configureMaxPathsInChangeMessages(allProjects, 50);
-    configureMaxPathsInChangeMessages(project, 20);
-    assertThat(codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(project)).isEqualTo(20);
-  }
-
-  private void configureDisabled(Project.NameKey project, String disabled) throws Exception {
-    setCodeOwnersConfig(project, /* subsection= */ null, StatusConfig.KEY_DISABLED, disabled);
-  }
-
-  private void configureDisabledBranch(Project.NameKey project, String disabledBranch)
-      throws Exception {
-    setCodeOwnersConfig(
-        project, /* subsection= */ null, StatusConfig.KEY_DISABLED_BRANCH, disabledBranch);
-  }
-
-  private void enableCodeOwnersForAllBranches(Project.NameKey project) throws Exception {
-    setCodeOwnersConfig(project, /* subsection= */ null, StatusConfig.KEY_DISABLED_BRANCH, "");
-  }
-
-  private void configureBackend(Project.NameKey project, String backendName) throws Exception {
-    configureBackend(project, /* branch= */ null, backendName);
-  }
-
-  private void configureBackend(
-      Project.NameKey project, @Nullable String branch, String backendName) throws Exception {
-    setCodeOwnersConfig(project, branch, BackendConfig.KEY_BACKEND, backendName);
-  }
-
-  private void configureRequiredApproval(Project.NameKey project, String requiredApproval)
-      throws Exception {
-    setCodeOwnersConfig(
-        project,
-        /* subsection= */ null,
-        RequiredApprovalConfig.KEY_REQUIRED_APPROVAL,
-        requiredApproval);
-  }
-
-  private void configureOverrideApproval(Project.NameKey project, String requiredApproval)
-      throws Exception {
-    setCodeOwnersConfig(
-        project,
-        /* subsection= */ null,
-        OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL,
-        requiredApproval);
-  }
-
-  private void configureFileExtension(Project.NameKey project, String fileExtension)
-      throws Exception {
-    setCodeOwnersConfig(
-        project, /* subsection= */ null, GeneralConfig.KEY_FILE_EXTENSION, fileExtension);
-  }
-
-  private void configureMergeCommitStrategy(
-      Project.NameKey project, MergeCommitStrategy mergeCommitStrategy) throws Exception {
-    setCodeOwnersConfig(
-        project,
-        /* subsection= */ null,
-        GeneralConfig.KEY_MERGE_COMMIT_STRATEGY,
-        mergeCommitStrategy.name());
-  }
-
-  private void configureFallbackCodeOwners(
-      Project.NameKey project, FallbackCodeOwners fallbackCodeOwners) throws Exception {
-    setCodeOwnersConfig(
-        project,
-        /* subsection= */ null,
-        GeneralConfig.KEY_FALLBACK_CODE_OWNERS,
-        fallbackCodeOwners.name());
-  }
-
-  private void configureMaxPathsInChangeMessages(
-      Project.NameKey project, int maxPathsInChangeMessages) throws Exception {
-    setCodeOwnersConfig(
-        project,
-        /* subsection= */ null,
-        GeneralConfig.KEY_MAX_PATHS_IN_CHANGE_MESSAGES,
-        Integer.toString(maxPathsInChangeMessages));
-  }
-
-  private AutoCloseable registerTestBackend() {
-    RegistrationHandle registrationHandle =
-        ((PrivateInternals_DynamicMapImpl<CodeOwnerBackend>) codeOwnerBackends)
-            .put("gerrit", TestCodeOwnerBackend.ID, Providers.of(new TestCodeOwnerBackend()));
-    return registrationHandle::remove;
-  }
-
-  private static class TestCodeOwnerBackend implements CodeOwnerBackend {
-    static final String ID = "test-backend";
-
-    @Override
-    public Optional<CodeOwnerConfig> getCodeOwnerConfig(
-        CodeOwnerConfig.Key codeOwnerConfigKey,
-        @Nullable RevWalk revWalk,
-        @Nullable ObjectId revision) {
-      throw new UnsupportedOperationException("not implemented");
-    }
-
-    @Override
-    public Optional<CodeOwnerConfig> upsertCodeOwnerConfig(
-        CodeOwnerConfig.Key codeOwnerConfigKey,
-        CodeOwnerConfigUpdate codeOwnerConfigUpdate,
-        @Nullable IdentifiedUser currentUser) {
-      throw new UnsupportedOperationException("not implemented");
-    }
-
-    @Override
-    public boolean isCodeOwnerConfigFile(NameKey project, String fileName) {
-      throw new UnsupportedOperationException("not implemented");
-    }
-
-    @Override
-    public Path getFilePath(CodeOwnerConfig.Key codeOwnerConfigKey) {
-      throw new UnsupportedOperationException("not implemented");
-    }
-
-    @Override
-    public Optional<PathExpressionMatcher> getPathExpressionMatcher() {
-      return Optional.empty();
-    }
-  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJsonTest.java b/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJsonTest.java
index 13a326d..313bef4 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJsonTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJsonTest.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.plugins.codeowners.api.RequiredApprovalInfo;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId;
 import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfigSnapshot;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.plugins.codeowners.backend.config.RequiredApproval;
 import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
@@ -63,6 +64,7 @@
   @Rule public final MockitoRule mockito = MockitoJUnit.rule().strictness(Strictness.STRICT_STUBS);
 
   @Mock private CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
+  @Mock private CodeOwnersPluginConfigSnapshot codeOwnersPluginConfigSnapshot;
 
   @Inject private CurrentUser currentUser;
 
@@ -106,14 +108,15 @@
   public void formatBackendIds() throws Exception {
     createBranch(BranchNameKey.create(project, "stable-2.10"));
 
-    when(codeOwnersPluginConfiguration.getBackend(project)).thenReturn(findOwnersBackend);
-    when(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
+    when(codeOwnersPluginConfigSnapshot.getBackend()).thenReturn(findOwnersBackend);
+    when(codeOwnersPluginConfigSnapshot.getBackend("refs/heads/master"))
         .thenReturn(findOwnersBackend);
-    when(codeOwnersPluginConfiguration.getBackend(
-            BranchNameKey.create(project, "refs/meta/config")))
+    when(codeOwnersPluginConfigSnapshot.getBackend("refs/meta/config"))
         .thenReturn(findOwnersBackend);
-    when(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "stable-2.10")))
+    when(codeOwnersPluginConfigSnapshot.getBackend("refs/heads/stable-2.10"))
         .thenReturn(protoBackend);
+    when(codeOwnersPluginConfiguration.getProjectConfig(project))
+        .thenReturn(codeOwnersPluginConfigSnapshot);
 
     BackendInfo backendInfo = codeOwnerProjectConfigJson.formatBackendInfo(createProjectResource());
     assertThat(backendInfo.id).isEqualTo(CodeOwnerBackendId.FIND_OWNERS.getBackendId());
@@ -129,12 +132,13 @@
 
   @Test
   public void idsPerBranchNotSetIfThereIsNoBranchSpecificBackendConfiguration() throws Exception {
-    when(codeOwnersPluginConfiguration.getBackend(project)).thenReturn(findOwnersBackend);
-    when(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
+    when(codeOwnersPluginConfigSnapshot.getBackend()).thenReturn(findOwnersBackend);
+    when(codeOwnersPluginConfigSnapshot.getBackend("refs/heads/master"))
         .thenReturn(findOwnersBackend);
-    when(codeOwnersPluginConfiguration.getBackend(
-            BranchNameKey.create(project, "refs/meta/config")))
+    when(codeOwnersPluginConfigSnapshot.getBackend("refs/meta/config"))
         .thenReturn(findOwnersBackend);
+    when(codeOwnersPluginConfiguration.getProjectConfig(project))
+        .thenReturn(codeOwnersPluginConfigSnapshot);
 
     BackendInfo backendInfo = codeOwnerProjectConfigJson.formatBackendInfo(createProjectResource());
     assertThat(backendInfo.id).isEqualTo(CodeOwnerBackendId.FIND_OWNERS.getBackendId());
@@ -146,31 +150,32 @@
     createOwnersOverrideLabel();
     createBranch(BranchNameKey.create(project, "stable-2.10"));
 
-    when(codeOwnersPluginConfiguration.isDisabled(project)).thenReturn(false);
-    when(codeOwnersPluginConfiguration.isDisabled(any(BranchNameKey.class))).thenReturn(false);
-    when(codeOwnersPluginConfiguration.getBackend(project)).thenReturn(findOwnersBackend);
-    when(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-        .thenReturn(findOwnersBackend);
-    when(codeOwnersPluginConfiguration.getBackend(
-            BranchNameKey.create(project, "refs/meta/config")))
-        .thenReturn(findOwnersBackend);
-    when(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "stable-2.10")))
-        .thenReturn(protoBackend);
-    when(codeOwnersPluginConfiguration.getFileExtension(project)).thenReturn(Optional.of("foo"));
-    when(codeOwnersPluginConfiguration.getOverrideInfoUrl(project))
-        .thenReturn(Optional.of("http://foo.example.com"));
-    when(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
+    when(codeOwnersPluginConfigSnapshot.getFileExtension()).thenReturn(Optional.of("foo"));
+    when(codeOwnersPluginConfigSnapshot.getMergeCommitStrategy())
         .thenReturn(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
-    when(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+    when(codeOwnersPluginConfigSnapshot.getFallbackCodeOwners())
         .thenReturn(FallbackCodeOwners.ALL_USERS);
-    when(codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(project)).thenReturn(true);
-    when(codeOwnersPluginConfiguration.getRequiredApproval(project))
+    when(codeOwnersPluginConfigSnapshot.getOverrideInfoUrl())
+        .thenReturn(Optional.of("http://foo.example.com"));
+    when(codeOwnersPluginConfigSnapshot.isDisabled()).thenReturn(false);
+    when(codeOwnersPluginConfigSnapshot.isDisabled(any(String.class))).thenReturn(false);
+    when(codeOwnersPluginConfigSnapshot.getBackend()).thenReturn(findOwnersBackend);
+    when(codeOwnersPluginConfigSnapshot.getBackend("refs/heads/master"))
+        .thenReturn(findOwnersBackend);
+    when(codeOwnersPluginConfigSnapshot.getBackend("refs/meta/config"))
+        .thenReturn(findOwnersBackend);
+    when(codeOwnersPluginConfigSnapshot.getBackend("refs/heads/stable-2.10"))
+        .thenReturn(protoBackend);
+    when(codeOwnersPluginConfigSnapshot.areImplicitApprovalsEnabled()).thenReturn(true);
+    when(codeOwnersPluginConfigSnapshot.getRequiredApproval())
         .thenReturn(RequiredApproval.create(getDefaultCodeReviewLabel(), (short) 2));
-    when(codeOwnersPluginConfiguration.getOverrideApproval(project))
+    when(codeOwnersPluginConfigSnapshot.getOverrideApproval())
         .thenReturn(
             ImmutableSet.of(
                 RequiredApproval.create(
                     LabelType.withDefaultValues("Owners-Override"), (short) 1)));
+    when(codeOwnersPluginConfiguration.getProjectConfig(project))
+        .thenReturn(codeOwnersPluginConfigSnapshot);
 
     CodeOwnerProjectConfigInfo codeOwnerProjectConfigInfo =
         codeOwnerProjectConfigJson.format(createProjectResource());
@@ -198,7 +203,9 @@
 
   @Test
   public void disabledBranchesNotSetIfDisabledOnProjectLevel() throws Exception {
-    when(codeOwnersPluginConfiguration.isDisabled(project)).thenReturn(true);
+    when(codeOwnersPluginConfigSnapshot.isDisabled()).thenReturn(true);
+    when(codeOwnersPluginConfiguration.getProjectConfig(project))
+        .thenReturn(codeOwnersPluginConfigSnapshot);
     CodeOwnersStatusInfo statusInfo =
         codeOwnerProjectConfigJson.formatStatusInfo(createProjectResource());
     assertThat(statusInfo.disabled).isTrue();
@@ -207,12 +214,11 @@
 
   @Test
   public void emptyStatus() throws Exception {
-    when(codeOwnersPluginConfiguration.isDisabled(project)).thenReturn(false);
-    when(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .thenReturn(false);
-    when(codeOwnersPluginConfiguration.isDisabled(
-            BranchNameKey.create(project, "refs/meta/config")))
-        .thenReturn(false);
+    when(codeOwnersPluginConfigSnapshot.isDisabled()).thenReturn(false);
+    when(codeOwnersPluginConfigSnapshot.isDisabled("refs/heads/master")).thenReturn(false);
+    when(codeOwnersPluginConfigSnapshot.isDisabled("refs/meta/config")).thenReturn(false);
+    when(codeOwnersPluginConfiguration.getProjectConfig(project))
+        .thenReturn(codeOwnersPluginConfigSnapshot);
     CodeOwnersStatusInfo statusInfo =
         codeOwnerProjectConfigJson.formatStatusInfo(createProjectResource());
     assertThat(statusInfo.disabled).isNull();
@@ -221,12 +227,11 @@
 
   @Test
   public void withDisabledBranches() throws Exception {
-    when(codeOwnersPluginConfiguration.isDisabled(project)).thenReturn(false);
-    when(codeOwnersPluginConfiguration.isDisabled(BranchNameKey.create(project, "master")))
-        .thenReturn(true);
-    when(codeOwnersPluginConfiguration.isDisabled(
-            BranchNameKey.create(project, "refs/meta/config")))
-        .thenReturn(false);
+    when(codeOwnersPluginConfigSnapshot.isDisabled()).thenReturn(false);
+    when(codeOwnersPluginConfigSnapshot.isDisabled("refs/heads/master")).thenReturn(true);
+    when(codeOwnersPluginConfigSnapshot.isDisabled("refs/meta/config")).thenReturn(false);
+    when(codeOwnersPluginConfiguration.getProjectConfig(project))
+        .thenReturn(codeOwnersPluginConfigSnapshot);
     CodeOwnersStatusInfo statusInfo =
         codeOwnerProjectConfigJson.formatStatusInfo(createProjectResource());
     assertThat(statusInfo.disabled).isNull();
@@ -237,11 +242,13 @@
   public void withMultipleOverrides() throws Exception {
     createOwnersOverrideLabel();
 
-    when(codeOwnersPluginConfiguration.getOverrideApproval(project))
+    when(codeOwnersPluginConfigSnapshot.getOverrideApproval())
         .thenReturn(
             ImmutableSet.of(
                 RequiredApproval.create(LabelType.withDefaultValues("Owners-Override"), (short) 1),
                 RequiredApproval.create(LabelType.withDefaultValues("Code-Review"), (short) 2)));
+    when(codeOwnersPluginConfiguration.getProjectConfig(project))
+        .thenReturn(codeOwnersPluginConfigSnapshot);
 
     ImmutableList<RequiredApprovalInfo> requiredApprovalInfos =
         codeOwnerProjectConfigJson.formatOverrideApprovalInfo(project);
@@ -264,24 +271,26 @@
         .addCodeOwnerEmail(admin.email())
         .create();
 
-    when(codeOwnersPluginConfiguration.isDisabled(any(BranchNameKey.class))).thenReturn(false);
-    when(codeOwnersPluginConfiguration.getBackend(BranchNameKey.create(project, "master")))
-        .thenReturn(findOwnersBackend);
-    when(codeOwnersPluginConfiguration.getFileExtension(project)).thenReturn(Optional.of("foo"));
-    when(codeOwnersPluginConfiguration.getOverrideInfoUrl(project))
-        .thenReturn(Optional.of("http://foo.example.com"));
-    when(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
+    when(codeOwnersPluginConfigSnapshot.getFileExtension()).thenReturn(Optional.of("foo"));
+    when(codeOwnersPluginConfigSnapshot.getMergeCommitStrategy())
         .thenReturn(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
-    when(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+    when(codeOwnersPluginConfigSnapshot.getFallbackCodeOwners())
         .thenReturn(FallbackCodeOwners.ALL_USERS);
-    when(codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(project)).thenReturn(true);
-    when(codeOwnersPluginConfiguration.getRequiredApproval(project))
+    when(codeOwnersPluginConfigSnapshot.getOverrideInfoUrl())
+        .thenReturn(Optional.of("http://foo.example.com"));
+    when(codeOwnersPluginConfigSnapshot.isDisabled(any(String.class))).thenReturn(false);
+    when(codeOwnersPluginConfigSnapshot.getBackend("refs/heads/master"))
+        .thenReturn(findOwnersBackend);
+    when(codeOwnersPluginConfigSnapshot.areImplicitApprovalsEnabled()).thenReturn(true);
+    when(codeOwnersPluginConfigSnapshot.getRequiredApproval())
         .thenReturn(RequiredApproval.create(getDefaultCodeReviewLabel(), (short) 2));
-    when(codeOwnersPluginConfiguration.getOverrideApproval(project))
+    when(codeOwnersPluginConfigSnapshot.getOverrideApproval())
         .thenReturn(
             ImmutableSet.of(
                 RequiredApproval.create(
                     LabelType.withDefaultValues("Owners-Override"), (short) 1)));
+    when(codeOwnersPluginConfiguration.getProjectConfig(project))
+        .thenReturn(codeOwnersPluginConfigSnapshot);
 
     CodeOwnerBranchConfigInfo codeOwnerBranchConfigInfo =
         codeOwnerProjectConfigJson.format(createBranchResource("refs/heads/master"));
@@ -306,7 +315,9 @@
 
   @Test
   public void formatCodeOwnerBranchConfig_disabled() throws Exception {
-    when(codeOwnersPluginConfiguration.isDisabled(any(BranchNameKey.class))).thenReturn(true);
+    when(codeOwnersPluginConfigSnapshot.isDisabled(any(String.class))).thenReturn(true);
+    when(codeOwnersPluginConfiguration.getProjectConfig(project))
+        .thenReturn(codeOwnersPluginConfigSnapshot);
 
     CodeOwnerBranchConfigInfo codeOwnerBranchConfigInfo =
         codeOwnerProjectConfigJson.format(createBranchResource("refs/heads/master"));