Allow to disable the validation of code owner config files per branch

Being able to skip the validation on some branches is useful for
branches that are fully operated by bots. Bots cannot fix code owner
config files and any issue that blocks them from uploading/submitting is
more severe than an invalid code owner config file. So it's better to
allow invalid code owner config files and fix them at a later point in
time.

Whether code owner config files are validated on commit received and
submit can be controlled on project-level by setting
`codeOwners.enableValidationOnCommitReceived` and
`codeOwners.enableValidationOnSubmit` in code-owners.config.

With this change we now add a possibility to override this configuration
on branch-level by setting
`validation.<branch>.enableValidationOnCommitReceived` and
`validation.<branch>.enableValidationOnSubmit` in code-owners.config.

As all configuration parameters the new configuration parameters are
inherited from parent projects if not set. An inherited branch-level
configuration also overrides a local project-level configuration. This
allows to exempt branches globally from code owner config validations.

'<branch>' can be an exact ref, a ref pattern (e.g. "refs/heads/*") or a
regular expression (e.g. "^refs/heads/.*"). If a branch is matched by
multiple validation subsections it is undefined which subsection takes
precedence. This is because Config#getSubsections returns a Set which
doesn't preserve the order in which subsections appear in the config
file.

In contrast to other configuration parameters branch-level validation
parameters can only be set in code-owners.config, but not in
gerrit.config (because as plugin we cannot have subsections in
gerrit.config). This means to exempt branches globally the configuration
needs to be done in the code-owners.config of the All-Projects project.

Signed-off-by: Edwin Kempin <ekempin@google.com>
Change-Id: Ifaa955d56fa499c440ee338ef4c0f40b495a2542
diff --git a/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersTest.java b/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersTest.java
index a06525d..c85c506 100644
--- a/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersTest.java
+++ b/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersTest.java
@@ -168,7 +168,7 @@
                 CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS, subsection, key, values));
   }
 
-  private void updateCodeOwnersConfig(Project.NameKey project, Consumer<Config> configUpdater)
+  protected void updateCodeOwnersConfig(Project.NameKey project, Consumer<Config> configUpdater)
       throws Exception {
     Config codeOwnersConfig = new Config();
     configUpdater.accept(codeOwnersConfig);
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshot.java b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshot.java
index 72747a6..4595fff 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshot.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshot.java
@@ -131,14 +131,44 @@
     return generalConfig.getRejectNonResolvableImports(projectName, pluginConfig);
   }
 
-  /** Whether code owner configs should be validated when a commit is received. */
-  public CodeOwnerConfigValidationPolicy getCodeOwnerConfigValidationPolicyForCommitReceived() {
+  /**
+   * Whether code owner configs should be validated when a commit is received.
+   *
+   * @param branchName the branch for which it should be checked whether code owner configs should
+   *     be validated on commit received
+   */
+  public CodeOwnerConfigValidationPolicy getCodeOwnerConfigValidationPolicyForCommitReceived(
+      String branchName) {
+    requireNonNull(branchName, "branchName");
+
+    Optional<CodeOwnerConfigValidationPolicy> branchSpecificPolicy =
+        generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+            BranchNameKey.create(projectName, branchName), pluginConfig);
+    if (branchSpecificPolicy.isPresent()) {
+      return branchSpecificPolicy.get();
+    }
+
     return generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceived(
         projectName, pluginConfig);
   }
 
-  /** Whether code owner configs should be validated when a change is submitted. */
-  public CodeOwnerConfigValidationPolicy getCodeOwnerConfigValidationPolicyForSubmit() {
+  /**
+   * Whether code owner configs should be validated when a change is submitted.
+   *
+   * @param branchName the branch for which it should be checked whether code owner configs should
+   *     be validated on submit
+   */
+  public CodeOwnerConfigValidationPolicy getCodeOwnerConfigValidationPolicyForSubmit(
+      String branchName) {
+    requireNonNull(branchName, "branchName");
+
+    Optional<CodeOwnerConfigValidationPolicy> branchSpecificPolicy =
+        generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+            BranchNameKey.create(projectName, branchName), pluginConfig);
+    if (branchSpecificPolicy.isPresent()) {
+      return branchSpecificPolicy.get();
+    }
+
     return generalConfig.getCodeOwnerConfigValidationPolicyForSubmit(projectName, pluginConfig);
   }
 
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java b/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java
index 3558582..e0f38cb 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
@@ -33,12 +34,14 @@
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.project.RefPatternMatcher;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Optional;
+import java.util.regex.PatternSyntaxException;
 import org.eclipse.jgit.lib.Config;
 
 /**
@@ -56,6 +59,8 @@
 public class GeneralConfig {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  public static final String SECTION_VALIDATION = "validation";
+
   public static final String KEY_FILE_EXTENSION = "fileExtension";
   public static final String KEY_READ_ONLY = "readOnly";
   public static final String KEY_EXEMPT_PURE_REVERTS = "exemptPureReverts";
@@ -388,14 +393,16 @@
   }
 
   /**
-   * Gets the enable validation on commit received configuration from the given plugin config with
-   * fallback to {@code gerrit.config} and default to {@code true}.
+   * Gets the enable validation on commit received configuration from the given plugin config for
+   * the specified project with fallback to {@code gerrit.config} and default to {@code true}.
    *
    * <p>The enable validation on commit received controls whether code owner config files should be
    * validated when a commit is received.
    *
+   * @param project the project for which the enable validation on commit received configuration
+   *     should be read
    * @param pluginConfig the plugin config from which the enable validation on commit received
-   *     configuration should be read.
+   *     configuration should be read
    * @return whether code owner config files should be validated when a commit is received
    */
   CodeOwnerConfigValidationPolicy getCodeOwnerConfigValidationPolicyForCommitReceived(
@@ -405,14 +412,40 @@
   }
 
   /**
-   * Gets the enable validation on submit configuration from the given plugin config with fallback
-   * to {@code gerrit.config} and default to {@code true}.
+   * Gets the enable validation on commit received configuration from the given plugin config for
+   * the specified branch.
+   *
+   * <p>If multiple branch-specific configurations match the specified branch, it is undefined which
+   * of the matching branch configurations takes precedence.
+   *
+   * <p>The enable validation on commit received controls whether code owner config files should be
+   * validated when a commit is received.
+   *
+   * @param branchNameKey the branch and project for which the enable validation on commit received
+   *     configuration should be read
+   * @param pluginConfig the plugin config from which the enable validation on commit received
+   *     configuration should be read
+   * @return the enable validation on commit received configuration that is configured for the
+   *     branch, {@link Optional#empty()} if no branch specific configuration exists
+   */
+  Optional<CodeOwnerConfigValidationPolicy>
+      getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+          BranchNameKey branchNameKey, Config pluginConfig) {
+    return getCodeOwnerConfigValidationPolicyForBranch(
+        KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED, branchNameKey, pluginConfig);
+  }
+
+  /**
+   * Gets the enable validation on submit configuration from the given plugin config for the
+   * specified project with fallback to {@code gerrit.config} and default to {@code true}.
    *
    * <p>The enable validation on submit controls whether code owner config files should be validated
    * when a change is submitted.
    *
+   * @param project the project for which the enable validation on submit configuration should be
+   *     read
    * @param pluginConfig the plugin config from which the enable validation on submit configuration
-   *     should be read.
+   *     should be read
    * @return whether code owner config files should be validated when a change is submitted
    */
   CodeOwnerConfigValidationPolicy getCodeOwnerConfigValidationPolicyForSubmit(
@@ -421,6 +454,45 @@
         KEY_ENABLE_VALIDATION_ON_SUBMIT, project, pluginConfig);
   }
 
+  /**
+   * Gets the enable validation on submit configuration from the given plugin config for the
+   * specified branch.
+   *
+   * <p>If multiple branch-specific configurations match the specified branch, it is undefined which
+   * of the matching branch configurations takes precedence.
+   *
+   * <p>The enable validation on submit controls whether code owner config files should be validated
+   * when a change is submitted.
+   *
+   * @param branchNameKey the branch and project for which the enable validation on submit
+   *     configuration should be read
+   * @param pluginConfig the plugin config from which the enable validation on submit configuration
+   *     should be read
+   * @return the enable validation on submit configuration that is configured for the branch, {@link
+   *     Optional#empty()} if no branch specific configuration exists
+   */
+  Optional<CodeOwnerConfigValidationPolicy> getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+      BranchNameKey branchNameKey, Config pluginConfig) {
+    return getCodeOwnerConfigValidationPolicyForBranch(
+        KEY_ENABLE_VALIDATION_ON_SUBMIT, branchNameKey, pluginConfig);
+  }
+
+  private Optional<CodeOwnerConfigValidationPolicy> getCodeOwnerConfigValidationPolicyForBranch(
+      String key, BranchNameKey branchNameKey, Config pluginConfig) {
+    requireNonNull(key, "key");
+    requireNonNull(branchNameKey, "branchNameKey");
+    requireNonNull(pluginConfig, "pluginConfig");
+
+    Optional<String> validationSectionForBranch =
+        getValidationSectionForBranch(branchNameKey, pluginConfig);
+    if (!validationSectionForBranch.isPresent()) {
+      return Optional.empty();
+    }
+
+    return getCodeOwnerConfigValidationPolicyForBranch(
+        validationSectionForBranch.get(), key, branchNameKey.project(), pluginConfig);
+  }
+
   private CodeOwnerConfigValidationPolicy getCodeOwnerConfigValidationPolicy(
       String key, Project.NameKey project, Config pluginConfig) {
     requireNonNull(key, "key");
@@ -459,6 +531,76 @@
     }
   }
 
+  private Optional<String> getValidationSectionForBranch(
+      BranchNameKey branchNameKey, Config pluginConfig) {
+    ImmutableSet<String> matchingValidationSubsections =
+        pluginConfig.getSubsections(SECTION_VALIDATION).stream()
+            .filter(
+                refPattern -> {
+                  try {
+                    return RefPatternMatcher.getMatcher(refPattern)
+                        .match(branchNameKey.branch(), /* user= */ null);
+                  } catch (PatternSyntaxException e) {
+                    logger.atWarning().withCause(e).log(
+                        "invalid ref pattern %s for subsection %s.%s in %s.config of project %s",
+                        refPattern,
+                        SECTION_VALIDATION,
+                        refPattern,
+                        pluginName,
+                        branchNameKey.project());
+                    return false;
+                  }
+                })
+            .collect(toImmutableSet());
+
+    if (matchingValidationSubsections.isEmpty()) {
+      return Optional.empty();
+    }
+
+    String matchingValidationSubsection = matchingValidationSubsections.asList().get(0);
+    if (matchingValidationSubsections.size() > 1) {
+      logger.atWarning().log(
+          "branch %s matches multiple %s subsections in %.config of project %s: %s,"
+              + " subsection %s takes precedence",
+          branchNameKey.branch(),
+          SECTION_VALIDATION,
+          pluginName,
+          branchNameKey.project(),
+          matchingValidationSubsections,
+          matchingValidationSubsection);
+    }
+    return Optional.of(matchingValidationSubsection);
+  }
+
+  private Optional<CodeOwnerConfigValidationPolicy> getCodeOwnerConfigValidationPolicyForBranch(
+      String branchSubsection, String key, Project.NameKey project, Config pluginConfig) {
+    requireNonNull(branchSubsection, "branchSubsection");
+    requireNonNull(key, "key");
+    requireNonNull(project, "project");
+    requireNonNull(pluginConfig, "pluginConfig");
+
+    String codeOwnerConfigValidationPolicyString =
+        pluginConfig.getString(SECTION_VALIDATION, branchSubsection, key);
+    if (codeOwnerConfigValidationPolicyString != null) {
+      try {
+        return Optional.of(
+            pluginConfig.getEnum(
+                SECTION_VALIDATION, branchSubsection, key, CodeOwnerConfigValidationPolicy.TRUE));
+      } catch (IllegalArgumentException e) {
+        logger.atWarning().withCause(e).log(
+            "Ignoring invalid value %s for the code owner config validation policy in '%s.config'"
+                + " of project %s (parameter %s.%s.%s). Falling back to project-level setting.",
+            codeOwnerConfigValidationPolicyString,
+            pluginName,
+            project.get(),
+            SECTION_VALIDATION,
+            branchSubsection,
+            key);
+      }
+    }
+    return Optional.empty();
+  }
+
   /**
    * Gets the merge commit strategy from the given plugin config with fallback to {@code
    * gerrit.config}.
diff --git a/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java b/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java
index 3f79135..4f03274 100644
--- a/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java
+++ b/java/com/google/gerrit/plugins/codeowners/validation/CodeOwnerConfigValidator.java
@@ -177,7 +177,7 @@
       CodeOwnerConfigValidationPolicy codeOwnerConfigValidationPolicy =
           codeOwnersPluginConfiguration
               .getProjectConfig(receiveEvent.getProjectNameKey())
-              .getCodeOwnerConfigValidationPolicyForCommitReceived();
+              .getCodeOwnerConfigValidationPolicyForCommitReceived(receiveEvent.refName);
       logger.atFine().log("codeOwnerConfigValidationPolicy = %s", codeOwnerConfigValidationPolicy);
       Optional<ValidationResult> validationResult;
       if (!codeOwnerConfigValidationPolicy.runValidation()) {
@@ -247,7 +247,7 @@
       CodeOwnerConfigValidationPolicy codeOwnerConfigValidationPolicy =
           codeOwnersPluginConfiguration
               .getProjectConfig(branchNameKey.project())
-              .getCodeOwnerConfigValidationPolicyForSubmit();
+              .getCodeOwnerConfigValidationPolicyForSubmit(branchNameKey.branch());
       logger.atFine().log("codeOwnerConfigValidationPolicy = %s", codeOwnerConfigValidationPolicy);
       Optional<ValidationResult> validationResult;
       if (!codeOwnerConfigValidationPolicy.runValidation()) {
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java
index 3465717..b501277 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java
@@ -53,10 +53,12 @@
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerSet;
 import com.google.gerrit.plugins.codeowners.backend.PathExpressionMatcher;
 import com.google.gerrit.plugins.codeowners.backend.config.BackendConfig;
+import com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig;
 import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
 import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersCodeOwnerConfigParser;
 import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend;
 import com.google.gerrit.plugins.codeowners.backend.proto.ProtoCodeOwnerConfigParser;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerConfigValidationPolicy;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.inject.Inject;
 import com.google.inject.Key;
@@ -1968,6 +1970,43 @@
     }
   }
 
+  @Test
+  public void disableValidationForBranch() throws Exception {
+    setAsDefaultCodeOwners(admin);
+
+    // Disable the validation for the master branch.
+    updateCodeOwnersConfig(
+        project,
+        codeOwnersConfig -> {
+          codeOwnersConfig.setString(
+              GeneralConfig.SECTION_VALIDATION,
+              "refs/heads/master",
+              GeneralConfig.KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
+              CodeOwnerConfigValidationPolicy.FALSE.name());
+          codeOwnersConfig.setString(
+              GeneralConfig.SECTION_VALIDATION,
+              "refs/heads/master",
+              GeneralConfig.KEY_ENABLE_VALIDATION_ON_SUBMIT,
+              CodeOwnerConfigValidationPolicy.FALSE.name());
+        });
+
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations
+                .codeOwnerConfig(createCodeOwnerConfigKey("/"))
+                .getJGitFilePath(),
+            "INVALID");
+    assertOkWithHints(
+        r,
+        "skipping validation of code owner config files",
+        "code owners config validation is disabled");
+
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
   private CodeOwnerConfig createCodeOwnerConfigWithImport(
       CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig,
       CodeOwnerConfigImportType importType,
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 655b45e..50114fe 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/PutCodeOwnerProjectConfigIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/PutCodeOwnerProjectConfigIT.java
@@ -467,7 +467,7 @@
     assertThat(
             codeOwnersPluginConfiguration
                 .getProjectConfig(project)
-                .getCodeOwnerConfigValidationPolicyForCommitReceived())
+                .getCodeOwnerConfigValidationPolicyForCommitReceived("master"))
         .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
 
     CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
@@ -476,7 +476,7 @@
     assertThat(
             codeOwnersPluginConfiguration
                 .getProjectConfig(project)
-                .getCodeOwnerConfigValidationPolicyForCommitReceived())
+                .getCodeOwnerConfigValidationPolicyForCommitReceived("master"))
         .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
 
     input.enableValidationOnCommitReceived = CodeOwnerConfigValidationPolicy.TRUE;
@@ -484,7 +484,7 @@
     assertThat(
             codeOwnersPluginConfiguration
                 .getProjectConfig(project)
-                .getCodeOwnerConfigValidationPolicyForCommitReceived())
+                .getCodeOwnerConfigValidationPolicyForCommitReceived("master"))
         .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
   }
 
@@ -493,7 +493,7 @@
     assertThat(
             codeOwnersPluginConfiguration
                 .getProjectConfig(project)
-                .getCodeOwnerConfigValidationPolicyForSubmit())
+                .getCodeOwnerConfigValidationPolicyForSubmit("master"))
         .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
 
     CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput();
@@ -502,7 +502,7 @@
     assertThat(
             codeOwnersPluginConfiguration
                 .getProjectConfig(project)
-                .getCodeOwnerConfigValidationPolicyForSubmit())
+                .getCodeOwnerConfigValidationPolicyForSubmit("master"))
         .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
 
     input.enableValidationOnSubmit = CodeOwnerConfigValidationPolicy.TRUE;
@@ -510,7 +510,7 @@
     assertThat(
             codeOwnersPluginConfiguration
                 .getProjectConfig(project)
-                .getCodeOwnerConfigValidationPolicyForSubmit())
+                .getCodeOwnerConfigValidationPolicyForSubmit("master"))
         .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
   }
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshotTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshotTest.java
index 529cbd4..eff2303 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshotTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigSnapshotTest.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
 import com.google.gerrit.plugins.codeowners.backend.PathExpressionMatcher;
 import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerConfigValidationPolicy;
 import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.inject.Inject;
@@ -910,6 +911,196 @@
     assertThat(cfgSnapshot().areImplicitApprovalsEnabled()).isFalse();
   }
 
+  @Test
+  public void cannotGetCodeOwnerConfigValidationPolicyForCommitReceivedForNullBranch()
+      throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                cfgSnapshot()
+                    .getCodeOwnerConfigValidationPolicyForCommitReceived(/* branchName= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("branchName");
+  }
+
+  @Test
+  public void getCodeOwnerConfigValidationPolicyForCommitReceived_notConfigured() throws Exception {
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("non-existing"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+  }
+
+  @Test
+  public void getCodeOwnerConfigValidationPolicyForCommitReceived_configuredOnProjectLevel()
+      throws Exception {
+    configureEnableValidationOnCommitReceived(project, CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("non-existing"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  public void getCodeOwnerConfigValidationPolicyForCommitReceived_configuredOnBranchLevel()
+      throws Exception {
+    configureEnableValidationOnCommitReceivedForBranch(
+        project, "refs/heads/master", CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(
+            cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("refs/heads/master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("foo"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+  }
+
+  @Test
+  public void getCodeOwnerConfigValidationPolicyForCommitReceived_branchLevelConfigTakesPrecedence()
+      throws Exception {
+    updateCodeOwnersConfig(
+        project,
+        codeOwnersConfig -> {
+          codeOwnersConfig.setEnum(
+              CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+              /* subsection= */ null,
+              GeneralConfig.KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
+              CodeOwnerConfigValidationPolicy.DRY_RUN);
+          codeOwnersConfig.setEnum(
+              GeneralConfig.SECTION_VALIDATION,
+              "refs/heads/master",
+              GeneralConfig.KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
+              CodeOwnerConfigValidationPolicy.FALSE);
+        });
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(
+            cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("refs/heads/master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("foo"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.DRY_RUN);
+  }
+
+  @Test
+  public void
+      getCodeOwnerConfigValidationPolicyForCommitReceived_inheritedBranchLevelConfigTakesPrecedence()
+          throws Exception {
+    configureEnableValidationOnCommitReceivedForBranch(
+        allProjects, "refs/heads/master", CodeOwnerConfigValidationPolicy.FALSE);
+    configureEnableValidationOnCommitReceived(project, CodeOwnerConfigValidationPolicy.DRY_RUN);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(
+            cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("refs/heads/master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("foo"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.DRY_RUN);
+  }
+
+  @Test
+  public void
+      getCodeOwnerConfigValidationPolicyForCommitReceived_inheritedBranchLevelCanBeOverridden()
+          throws Exception {
+    configureEnableValidationOnCommitReceivedForBranch(
+        allProjects, "refs/heads/master", CodeOwnerConfigValidationPolicy.FALSE);
+    configureEnableValidationOnCommitReceivedForBranch(
+        project, "refs/heads/master", CodeOwnerConfigValidationPolicy.DRY_RUN);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForCommitReceived("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.DRY_RUN);
+  }
+
+  @Test
+  public void cannotGetCodeOwnerConfigValidationPolicyForSubmitForNullBranch() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit(/* branchName= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("branchName");
+  }
+
+  @Test
+  public void getCodeOwnerConfigValidationPolicyForSubmitd_notConfigured() throws Exception {
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("non-existing"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+  }
+
+  @Test
+  public void getCodeOwnerConfigValidationPolicyForSubmit_configuredOnProjectLevel()
+      throws Exception {
+    configureEnableValidationOnSubmit(project, CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("non-existing"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  public void getCodeOwnerConfigValidationPolicyForSubmit_configuredOnBranchLevel()
+      throws Exception {
+    configureEnableValidationOnSubmitForBranch(
+        project, "refs/heads/master", CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("refs/heads/master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("foo"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.TRUE);
+  }
+
+  @Test
+  public void getCodeOwnerConfigValidationPolicyForSubmit_branchLevelConfigTakesPrecedence()
+      throws Exception {
+    updateCodeOwnersConfig(
+        project,
+        codeOwnersConfig -> {
+          codeOwnersConfig.setEnum(
+              CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+              /* subsection= */ null,
+              GeneralConfig.KEY_ENABLE_VALIDATION_ON_SUBMIT,
+              CodeOwnerConfigValidationPolicy.DRY_RUN);
+          codeOwnersConfig.setEnum(
+              GeneralConfig.SECTION_VALIDATION,
+              "refs/heads/master",
+              GeneralConfig.KEY_ENABLE_VALIDATION_ON_SUBMIT,
+              CodeOwnerConfigValidationPolicy.FALSE);
+        });
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("refs/heads/master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("foo"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.DRY_RUN);
+  }
+
+  @Test
+  public void
+      getCodeOwnerConfigValidationPolicyForSubmit_inheritedBranchLevelConfigTakesPrecedence()
+          throws Exception {
+    configureEnableValidationOnSubmitForBranch(
+        allProjects, "refs/heads/master", CodeOwnerConfigValidationPolicy.FALSE);
+    configureEnableValidationOnSubmit(project, CodeOwnerConfigValidationPolicy.DRY_RUN);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("refs/heads/master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("foo"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.DRY_RUN);
+  }
+
+  @Test
+  public void getCodeOwnerConfigValidationPolicyForSubmit_inheritedBranchLevelCanBeOverridden()
+      throws Exception {
+    configureEnableValidationOnSubmitForBranch(
+        allProjects, "refs/heads/master", CodeOwnerConfigValidationPolicy.FALSE);
+    configureEnableValidationOnSubmitForBranch(
+        project, "refs/heads/master", CodeOwnerConfigValidationPolicy.DRY_RUN);
+    assertThat(cfgSnapshot().getCodeOwnerConfigValidationPolicyForSubmit("master"))
+        .isEqualTo(CodeOwnerConfigValidationPolicy.DRY_RUN);
+  }
+
   private CodeOwnersPluginConfigSnapshot cfgSnapshot() {
     return codeOwnersPluginConfigSnapshotFactory.create(project);
   }
@@ -997,6 +1188,56 @@
         requiredApproval);
   }
 
+  private void configureEnableValidationOnCommitReceived(
+      Project.NameKey project, CodeOwnerConfigValidationPolicy codeOwnerConfigValidationPolicy)
+      throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        GeneralConfig.KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
+        codeOwnerConfigValidationPolicy.name());
+  }
+
+  private void configureEnableValidationOnCommitReceivedForBranch(
+      Project.NameKey project,
+      String branchSubsection,
+      CodeOwnerConfigValidationPolicy codeOwnerConfigValidationPolicy)
+      throws Exception {
+    updateCodeOwnersConfig(
+        project,
+        codeOwnersConfig ->
+            codeOwnersConfig.setString(
+                GeneralConfig.SECTION_VALIDATION,
+                branchSubsection,
+                GeneralConfig.KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
+                codeOwnerConfigValidationPolicy.name()));
+  }
+
+  private void configureEnableValidationOnSubmit(
+      Project.NameKey project, CodeOwnerConfigValidationPolicy codeOwnerConfigValidationPolicy)
+      throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        GeneralConfig.KEY_ENABLE_VALIDATION_ON_SUBMIT,
+        codeOwnerConfigValidationPolicy.name());
+  }
+
+  private void configureEnableValidationOnSubmitForBranch(
+      Project.NameKey project,
+      String branchSubsection,
+      CodeOwnerConfigValidationPolicy codeOwnerConfigValidationPolicy)
+      throws Exception {
+    updateCodeOwnersConfig(
+        project,
+        codeOwnersConfig ->
+            codeOwnersConfig.setString(
+                GeneralConfig.SECTION_VALIDATION,
+                branchSubsection,
+                GeneralConfig.KEY_ENABLE_VALIDATION_ON_SUBMIT,
+                codeOwnerConfigValidationPolicy.name()));
+  }
+
   private AutoCloseable registerTestBackend() {
     RegistrationHandle registrationHandle =
         ((PrivateInternals_DynamicMapImpl<CodeOwnerBackend>) codeOwnerBackends)
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java
index 456deb5..3c9fc09 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java
@@ -31,12 +31,14 @@
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_READ_ONLY;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_REJECT_NON_RESOLVABLE_CODE_OWNERS;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_REJECT_NON_RESOLVABLE_IMPORTS;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.SECTION_VALIDATION;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
 import com.google.gerrit.plugins.codeowners.backend.EnableImplicitApprovals;
@@ -417,6 +419,165 @@
   }
 
   @Test
+  public void cannotGetEnableValidationOnCommitReceivedForBranchForNullBranch() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                    /* branchNameKey= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("branchNameKey");
+  }
+
+  @Test
+  public void cannotGetEnableValidationOnCommitReceivedForBranchForNullPluginConfig()
+      throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                    BranchNameKey.create(project, "master"), /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noBranchSpecificEnableValidationOnCommitReceivedConfiguration() throws Exception {
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "master"), new Config()))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificEnableValidationOnCommitReceivedConfiguration_exact()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "refs/heads/foo", KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificEnableValidationOnCommitReceivedConfiguration_refPattern()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "refs/heads/foo/*", KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificEnableValidationOnCommitReceivedConfiguration_regEx()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION,
+        "^refs/heads/.*foo.*",
+        KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
+        "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificEnableValidationOnCommitReceivedConfiguration_invalidRegEx()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "^refs/heads/[", KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void matchingBranchSpecificEnableValidationOnCommitReceivedConfiguration_exact()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "refs/heads/master", KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .value()
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  public void matchingBranchSpecificEnableValidationOnCommitReceivedConfiguration_refPattern()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "refs/heads/*", KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .value()
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  public void matchingBranchSpecificEnableValidationOnCommitReceivedConfiguration_regEx()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION,
+        "^refs/heads/.*bar.*",
+        KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
+        "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "foobarbaz"), cfg))
+        .value()
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  public void branchSpecificEnableValidationOnCommitReceivedConfigurationIsIgnoredIfValueIsInvalid()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION,
+        "refs/heads/master",
+        KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
+        "INVALID");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void multipleMatchingBranchSpecificEnableValidationOnCommitReceivedConfiguration()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "refs/heads/master", KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED, "false");
+    cfg.setString(
+        SECTION_VALIDATION, "refs/heads/*", KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED, "false");
+    cfg.setString(
+        SECTION_VALIDATION, "^refs/heads/.*", KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED, "false");
+
+    // it is non-deterministic which of the branch-specific configurations takes precedence, but
+    // since they all configure the same value it's not important for this assertion
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .value()
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
   public void cannotGetEnableValidationOnSubmitForNullProject() throws Exception {
     NullPointerException npe =
         assertThrows(
@@ -485,6 +646,147 @@
   }
 
   @Test
+  public void cannotGetEnableValidationOnSubmitForBranchForNullBranch() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                    /* branchNameKey= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("branchNameKey");
+  }
+
+  @Test
+  public void cannotGetEnableValidationOnSubmitForBranchForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                    BranchNameKey.create(project, "master"), /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noBranchSpecificEnableValidationOnSubmitConfiguration() throws Exception {
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                BranchNameKey.create(project, "master"), new Config()))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificEnableValidationOnSubmitConfiguration_exact()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_VALIDATION, "refs/heads/foo", KEY_ENABLE_VALIDATION_ON_SUBMIT, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificEnableValidationOnSubmitConfiguration_refPattern()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_VALIDATION, "refs/heads/foo/*", KEY_ENABLE_VALIDATION_ON_SUBMIT, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificEnableValidationOnSubmitConfiguration_regEx()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "^refs/heads/.*foo.*", KEY_ENABLE_VALIDATION_ON_SUBMIT, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForCommitReceivedForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void noMatchingBranchSpecificEnableValidationOnSubmitConfiguration_invalidRegEx()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_VALIDATION, "^refs/heads/[", KEY_ENABLE_VALIDATION_ON_SUBMIT, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void matchingBranchSpecificEnableValidationOnSubmitConfiguration_exact() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "refs/heads/master", KEY_ENABLE_VALIDATION_ON_SUBMIT, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .value()
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  public void matchingBranchSpecificEnableValidationOnSubmitConfiguration_refPattern()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_VALIDATION, "refs/heads/*", KEY_ENABLE_VALIDATION_ON_SUBMIT, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .value()
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  public void matchingBranchSpecificEnableValidationOnSubmitConfiguration_regEx() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "^refs/heads/.*bar.*", KEY_ENABLE_VALIDATION_ON_SUBMIT, "false");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                BranchNameKey.create(project, "foobarbaz"), cfg))
+        .value()
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
+  public void branchSpecificEnableValidationOnSubmitConfigurationIsIgnoredIfValueIsInvalid()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "refs/heads/master", KEY_ENABLE_VALIDATION_ON_SUBMIT, "INVALID");
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .isEmpty();
+  }
+
+  @Test
+  public void multipleMatchingBranchSpecificEnableValidationOnSubmitConfiguration()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_VALIDATION, "refs/heads/master", KEY_ENABLE_VALIDATION_ON_SUBMIT, "false");
+    cfg.setString(SECTION_VALIDATION, "refs/heads/*", KEY_ENABLE_VALIDATION_ON_SUBMIT, "false");
+    cfg.setString(SECTION_VALIDATION, "^refs/heads/.*", KEY_ENABLE_VALIDATION_ON_SUBMIT, "false");
+
+    // it is non-deterministic which of the branch-specific configurations takes precedence, but
+    // since they all configure the same value it's not important for this assertion
+    assertThat(
+            generalConfig.getCodeOwnerConfigValidationPolicyForSubmitForBranch(
+                BranchNameKey.create(project, "master"), cfg))
+        .value()
+        .isEqualTo(CodeOwnerConfigValidationPolicy.FALSE);
+  }
+
+  @Test
   public void cannotGetMergeCommitStrategyForNullPluginConfig() throws Exception {
     NullPointerException npe =
         assertThrows(
diff --git a/resources/Documentation/config.md b/resources/Documentation/config.md
index ffa4018..70e5630 100644
--- a/resources/Documentation/config.md
+++ b/resources/Documentation/config.md
@@ -605,10 +605,26 @@
         [plugin.@PLUGIN@.enableValidationOnCommitReceived](#pluginCodeOwnersEnableValidationOnCommitReceived)
         in `gerrit.config` and the `codeOwners.enableValidationOnCommitReceived`
         setting from parent projects.\
+        Can be overriden on branch-level by setting
+        [validation.\<branch\>.enableValidationOnCommitReceived](#validationBranchEnableValidationOnCommitReceived).\
         If not set, the global setting
         [plugin.@PLUGIN@.enableValidationOnCommitReceived](#pluginCodeOwnersEnableValidationOnCommitReceived)
         in `gerrit.config` is used.
 
+<a id="validationBranchEnableValidationOnCommitReceived">validation.\<branch\>.enableValidationOnCommitReceived</a>
+:       Branch-level policy for validating code owner config files when a commit
+        is received.\
+        Applies to all branches that are matched by `<branch>`, which can be
+        an exact ref name (e.g. `refs/heads/master`), a ref pattern (e.g.
+        `refs/heads/*`) or a regular expression (e.g. `^refs/heads/stable-.*`).\
+        If a branches matches multiple validation subsections it is undefined
+        which of the subsections takes precedence.\
+        Overrides the project-level configuration for validating code owner
+        config files when a commit is received that is configured by
+        [codeOwners.enableValidationOnCommitReceived](#codeOwnersEnableValidationOnCommitReceived).\
+        For further details see the description of
+        [codeOwners.enableValidationOnCommitReceived](#codeOwnersEnableValidationOnCommitReceived).
+
 <a id="codeOwnersEnableValidationOnSubmit">codeOwners.enableValidationOnSubmit</a>
 :       Policy for validating code owner config files when a change is
         submitted. Allowed values are `true` (the code owner config file
@@ -622,10 +638,26 @@
         [plugin.@PLUGIN@.enableValidationOnSubmit](#pluginCodeOwnersEnableValidationOnSubmit)
         in `gerrit.config` and the `codeOwners.enableValidationOnSubmit` setting
         from parent projects.\
+        Can be overriden on branch-level by setting
+        [validation.\<branch\>.enableValidationOnSubmit](#validationBranchEnableValidationOnSubmit).\
         If not set, the global setting
         [plugin.@PLUGIN@.enableValidationOnSubmit](#pluginCodeOwnersEnableValidationOnSubmit)
         in `gerrit.config` is used.
 
+<a id="validationBranchEnableValidationOnSubmit">validation.\<branch\>.enableValidationOnSubmit</a>
+:       Branch-level policy for validating code owner config files when a change
+        is submitted.\
+        Applies to all branches that are matched by `<branch>`, which can be
+        an exact ref name (e.g. `refs/heads/master`), a ref pattern (e.g.
+        `refs/heads/*`) or a regular expression (e.g. `^refs/heads/stable-.*`).\
+        If a branches matches multiple validation subsections it is undefined
+        which of the subsections takes precedence.\
+        Overrides the project-level configuration for validating code owner
+        config files when a change is submitted that is configured by
+        [codeOwners.enableValidationOnSubmit](#codeOwnersEnableValidationOnSubmit).\
+        For further details see the description of
+        [codeOwners.enableValidationOnSubmit](#codeOwnersEnableValidationOnSubmit).
+
 <a id="codeOwnersRejectNonResolvableCodeOwners">codeOwners.rejectNonResolvableCodeOwners</a>
 :       Whether modifications of code owner config files that newly add
         non-resolvable code owners should be rejected on commit received and