Merge branch 'stable-3.14'

* stable-3.14:
  Add ESLint configuration
  Adapt prolog-runtime dependency to rules_jvm_external
  Allow matchers to override auto-owners-approved
  Refactor tests in copy-condition IT
  Remove unneeded @Nullable on rootEntry in PathOwners
  Avoid nullable PathOwnersEntry associated with projects
  Add AlreadyApprovedByCopyConditionsIT hierarchy OWNERS test cases
  Add auto-owners-approved to OWNERS
  Make fields PathOwners field final
  Ignore commit message diffs in already-approved-by_owners
  Replace use of static EMPTY ReadOnlyPathOwnersEntry with Optional<>
  Avoid passing rootEntry to calculateCurrentEntry()
  Fix import of com.google.common.collect.Sets
  Remove definition of owners-common as a plugin
  Open JDK internals for PowerMock in owners-common tests
  Disambiguate Truth assertions in owners-common tests
  Reformat with GJF 1.24

Change-Id: I19ce5ba0649caf19b2531499db4dc4ec698957a1
diff --git a/owners-common/BUILD b/owners-common/BUILD
index 0f12783..faf2d08 100644
--- a/owners-common/BUILD
+++ b/owners-common/BUILD
@@ -11,16 +11,15 @@
     deps = PLUGIN_DEPS_NEVERLINK + EXTERNAL_DEPS,
 )
 
-gerrit_plugin(
-    name = "owners-common__plugin",
-    srcs = glob(["src/main/java/**/*.java"]),
-    resources = glob(["src/main/resources/**/*"]),
-)
-
 junit_tests(
     name = "test",
     testonly = 1,
     srcs = glob(["src/test/java/**/*.java"]),
+    jvm_flags = [
+        "--add-opens=java.base/java.lang=ALL-UNNAMED",
+        "--add-opens=java.base/java.util=ALL-UNNAMED",
+        "--add-opens=java.base/java.util.stream=ALL-UNNAMED",
+    ],
     deps = EXTERNAL_TEST_DEPS + PLUGIN_DEPS + PLUGIN_TEST_DEPS + [
         ":owners-common",
     ],
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/ConfigurationParser.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/ConfigurationParser.java
index 54c624e..921d967 100644
--- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/ConfigurationParser.java
+++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/ConfigurationParser.java
@@ -21,6 +21,7 @@
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
 import com.google.gerrit.entities.Account.Id;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import java.io.IOException;
 import java.util.Optional;
 import java.util.Set;
@@ -47,11 +48,22 @@
         Optional.ofNullable(jsonNode.get("label"))
             .map(JsonNode::asText)
             .flatMap(LabelDefinition::parse));
+    Optional<InheritableBoolean> autoOwnersApproved = parseAutoOwnersApproved(jsonNode);
+
+    autoOwnersApproved.ifPresent(ret::setAutoOwnersApproved);
+
     addClassicMatcher(jsonNode, ret);
     addMatchers(jsonNode, ret);
     return ret;
   }
 
+  private Optional<InheritableBoolean> parseAutoOwnersApproved(JsonNode jsonNode) {
+    return Optional.ofNullable(jsonNode.get("auto-owners-approved"))
+        .map(JsonNode::asText)
+        .map(String::toUpperCase)
+        .map(InheritableBoolean::valueOf);
+  }
+
   private void addClassicMatcher(JsonNode jsonNode, OwnersConfig ret) {
     ret.setOwners(toClassicOwnersList(jsonNode, "owners").collect(Collectors.toSet()));
     ret.setReviewers(toClassicOwnersList(jsonNode, "reviewers").collect(Collectors.toSet()));
@@ -109,17 +121,27 @@
             .flatMap(o -> accounts.find(o).stream())
             .collect(Collectors.toSet());
 
+    InheritableBoolean autoOwnersApproved =
+        parseAutoOwnersApproved(node).orElse(InheritableBoolean.INHERIT);
+
     Optional<Matcher> suffixMatcher =
-        getText(node, "suffix").map(el -> new SuffixMatcher(el, owners, reviewers, groupOwners));
+        getText(node, "suffix")
+            .map(el -> new SuffixMatcher(el, owners, reviewers, groupOwners, autoOwnersApproved));
     Optional<Matcher> regexMatcher =
-        getText(node, "regex").map(el -> new RegExMatcher(el, owners, reviewers, groupOwners));
+        getText(node, "regex")
+            .map(el -> new RegExMatcher(el, owners, reviewers, groupOwners, autoOwnersApproved));
     Optional<Matcher> partialRegexMatcher =
         getText(node, "partial_regex")
-            .map(el -> new PartialRegExMatcher(el, owners, reviewers, groupOwners));
+            .map(
+                el ->
+                    new PartialRegExMatcher(
+                        el, owners, reviewers, groupOwners, autoOwnersApproved));
     Optional<Matcher> exactMatcher =
-        getText(node, "exact").map(el -> new ExactMatcher(el, owners, reviewers, groupOwners));
+        getText(node, "exact")
+            .map(el -> new ExactMatcher(el, owners, reviewers, groupOwners, autoOwnersApproved));
     Optional<Matcher> genericMatcher =
-        getText(node, "generic").map(el -> new GenericMatcher(el, owners, reviewers, groupOwners));
+        getText(node, "generic")
+            .map(el -> new GenericMatcher(el, owners, reviewers, groupOwners, autoOwnersApproved));
 
     return Optional.ofNullable(
         suffixMatcher.orElseGet(
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/ExactMatcher.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/ExactMatcher.java
index 7088289..02b9ab5 100644
--- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/ExactMatcher.java
+++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/ExactMatcher.java
@@ -17,12 +17,17 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Account.Id;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import java.util.Set;
 
 public class ExactMatcher extends Matcher {
   public ExactMatcher(
-      String path, Set<Account.Id> owners, Set<Account.Id> reviewers, Set<String> groupOwners) {
-    super(path, owners, reviewers, groupOwners);
+      String path,
+      Set<Account.Id> owners,
+      Set<Account.Id> reviewers,
+      Set<String> groupOwners,
+      InheritableBoolean autoOwnersApproved) {
+    super(path, owners, reviewers, groupOwners, autoOwnersApproved);
   }
 
   @Override
@@ -31,7 +36,11 @@
   }
 
   @Override
-  protected Matcher clone(Set<Id> owners, Set<Id> reviewers, Set<String> groupOwners) {
-    return new ExactMatcher(path, owners, reviewers, groupOwners);
+  protected Matcher clone(
+      Set<Id> owners,
+      Set<Id> reviewers,
+      Set<String> groupOwners,
+      InheritableBoolean autoOwnersApproved) {
+    return new ExactMatcher(path, owners, reviewers, groupOwners, autoOwnersApproved);
   }
 }
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/GenericMatcher.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/GenericMatcher.java
index e37065b..f46ee7f 100644
--- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/GenericMatcher.java
+++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/GenericMatcher.java
@@ -16,12 +16,17 @@
 package com.googlesource.gerrit.owners.common;
 
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import java.util.Set;
 
 public class GenericMatcher extends RegExMatcher {
 
   public GenericMatcher(
-      String path, Set<Account.Id> owners, Set<Account.Id> reviewers, Set<String> groupOwners) {
-    super(path, owners, reviewers, groupOwners);
+      String path,
+      Set<Account.Id> owners,
+      Set<Account.Id> reviewers,
+      Set<String> groupOwners,
+      InheritableBoolean autoOwnersApproved) {
+    super(path, owners, reviewers, groupOwners, autoOwnersApproved);
   }
 }
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/Matcher.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/Matcher.java
index 321b588..278e75d 100644
--- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/Matcher.java
+++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/Matcher.java
@@ -17,6 +17,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Account.Id;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import java.util.Set;
 
 public abstract class Matcher {
@@ -24,13 +25,19 @@
   private Set<Account.Id> reviewers;
   private Set<String> groupOwners;
   protected String path;
+  private InheritableBoolean autoOwnersApproved;
 
   public Matcher(
-      String key, Set<Account.Id> owners, Set<Account.Id> reviewers, Set<String> groupOwners) {
+      String key,
+      Set<Account.Id> owners,
+      Set<Account.Id> reviewers,
+      Set<String> groupOwners,
+      InheritableBoolean autoOwnersApproved) {
     this.path = key;
     this.owners = owners;
     this.reviewers = reviewers;
     this.groupOwners = groupOwners;
+    this.autoOwnersApproved = autoOwnersApproved;
   }
 
   @Override
@@ -43,6 +50,8 @@
         + groupOwners
         + ", reviewers="
         + reviewers
+        + ", autoOwnersApproved="
+        + autoOwnersApproved
         + "]";
   }
 
@@ -74,6 +83,10 @@
     return path;
   }
 
+  public InheritableBoolean getAutoOwnersApproved() {
+    return autoOwnersApproved;
+  }
+
   public abstract boolean matches(String pathToMatch);
 
   public Matcher merge(Matcher other) {
@@ -84,10 +97,17 @@
     return clone(
         mergeSet(owners, other.owners),
         mergeSet(reviewers, other.reviewers),
-        mergeSet(groupOwners, other.groupOwners));
+        mergeSet(groupOwners, other.groupOwners),
+        other.autoOwnersApproved == InheritableBoolean.INHERIT
+            ? autoOwnersApproved
+            : other.getAutoOwnersApproved());
   }
 
-  protected abstract Matcher clone(Set<Id> owners, Set<Id> reviewers, Set<String> groupOwners);
+  protected abstract Matcher clone(
+      Set<Id> owners,
+      Set<Id> reviewers,
+      Set<String> groupOwners,
+      InheritableBoolean autoOwnersApproved);
 
   private <T> Set<T> mergeSet(Set<T> set1, Set<T> set2) {
     ImmutableSet.Builder<T> setBuilder = ImmutableSet.builder();
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/OwnersConfig.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/OwnersConfig.java
index 7e7e705..1fce900 100644
--- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/OwnersConfig.java
+++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/OwnersConfig.java
@@ -16,8 +16,11 @@
 
 package com.googlesource.gerrit.owners.common;
 
+import static com.google.gerrit.extensions.client.InheritableBoolean.INHERIT;
+
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
@@ -40,6 +43,9 @@
   /** Label that is required for submit. CodeReview if nothing is specified. */
   private Optional<LabelDefinition> label = Optional.empty();
 
+  /** Ability to enable or disable the owners auto approval, when configured */
+  private InheritableBoolean autoOwnersApproved = INHERIT;
+
   @Override
   public String toString() {
     return "OwnersConfig [inherited="
@@ -50,6 +56,8 @@
         + matchers
         + ", label="
         + label
+        + ", autoOwnersApproved="
+        + autoOwnersApproved
         + "]";
   }
 
@@ -92,4 +100,12 @@
   public Optional<LabelDefinition> getLabel() {
     return label;
   }
+
+  public InheritableBoolean getAutoOwnersApproved() {
+    return autoOwnersApproved;
+  }
+
+  public void setAutoOwnersApproved(InheritableBoolean autoOwnersApproved) {
+    this.autoOwnersApproved = autoOwnersApproved;
+  }
 }
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/OwnersMap.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/OwnersMap.java
index 760b8a6..102f954 100644
--- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/OwnersMap.java
+++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/OwnersMap.java
@@ -31,6 +31,7 @@
   private Map<String, Set<Account.Id>> fileOwners = Maps.newHashMap();
   private Map<String, Set<Account.Id>> fileReviewers = Maps.newHashMap();
   private Map<String, Set<String>> fileGroupOwners = Maps.newHashMap();
+  private Set<String> fileOwnersBannedAutoApproval = Sets.newHashSet();
   private Optional<LabelDefinition> label = Optional.empty();
 
   @Override
@@ -86,6 +87,10 @@
     return fileGroupOwners;
   }
 
+  public Set<String> getFileOwnersBannedAutoApproval() {
+    return fileOwnersBannedAutoApproval;
+  }
+
   public void addFileOwners(String file, Set<Id> owners) {
     if (owners.isEmpty()) {
       return;
@@ -122,6 +127,14 @@
     fileGroupOwners.computeIfAbsent(file, (f) -> Sets.newHashSet()).addAll(groupOwners);
   }
 
+  public void banFileFromOwnersAutoApproval(String file) {
+    fileOwnersBannedAutoApproval.add(file);
+  }
+
+  public void allowFileForAutoApproval(String file) {
+    fileOwnersBannedAutoApproval.remove(file);
+  }
+
   public Optional<LabelDefinition> getLabel() {
     return label;
   }
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PartialRegExMatcher.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PartialRegExMatcher.java
index 479a287..8fe7812 100644
--- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PartialRegExMatcher.java
+++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PartialRegExMatcher.java
@@ -17,6 +17,7 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Account.Id;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import java.util.Set;
 import java.util.regex.Pattern;
 
@@ -25,8 +26,12 @@
   Pattern pattern;
 
   public PartialRegExMatcher(
-      String path, Set<Account.Id> owners, Set<Account.Id> reviewers, Set<String> groupOwners) {
-    super(path, owners, reviewers, groupOwners);
+      String path,
+      Set<Account.Id> owners,
+      Set<Account.Id> reviewers,
+      Set<String> groupOwners,
+      InheritableBoolean autoOwnersApproved) {
+    super(path, owners, reviewers, groupOwners, autoOwnersApproved);
     pattern = Pattern.compile(".*" + path + ".*");
   }
 
@@ -36,7 +41,11 @@
   }
 
   @Override
-  protected Matcher clone(Set<Id> owners, Set<Id> reviewers, Set<String> groupOwners) {
-    return new PartialRegExMatcher(path, owners, reviewers, groupOwners);
+  protected Matcher clone(
+      Set<Id> owners,
+      Set<Id> reviewers,
+      Set<String> groupOwners,
+      InheritableBoolean autoOwnersApproved) {
+    return new PartialRegExMatcher(path, owners, reviewers, groupOwners, autoOwnersApproved);
   }
 }
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwners.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwners.java
index 1e5023b..c7d33fe 100644
--- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwners.java
+++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwners.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
@@ -84,11 +85,13 @@
 
   private final GitRepositoryManager repositoryManager;
 
-  private Map<String, Matcher> matchers;
+  private final Map<String, Matcher> matchers;
 
-  private Map<String, Set<Id>> fileOwners;
+  private final Map<String, Set<Id>> fileOwners;
 
-  private Map<String, Set<String>> fileGroupOwners;
+  private final Map<String, Set<String>> fileGroupOwners;
+
+  private final Set<String> fileOwnersBannedAutoApproval;
 
   private final boolean expandGroups;
 
@@ -175,8 +178,10 @@
     matchers = map.getMatchers();
     fileOwners = map.getFileOwners();
     fileGroupOwners = map.getFileGroupOwners();
+    fileOwnersBannedAutoApproval = map.getFileOwnersBannedAutoApproval();
     label = globalLabel.or(map::getLabel);
   }
+
   /**
    * Returns a read only view of the paths to owners mapping.
    *
@@ -207,6 +212,10 @@
     return fileGroupOwners;
   }
 
+  public Set<String> getFileOwnersBannedAutoApproval() {
+    return fileOwnersBannedAutoApproval;
+  }
+
   public boolean expandGroups() {
     return expandGroups;
   }
@@ -234,7 +243,7 @@
       // Using a `map` would have needed a try/catch inside the lamba, resulting in more code
       List<ReadOnlyPathOwnersEntry> parentsPathOwnersEntries =
           getPathOwnersEntries(parentProjectsNames, RefNames.REFS_CONFIG, cache);
-      ReadOnlyPathOwnersEntry projectEntry =
+      Optional<ReadOnlyPathOwnersEntry> projectEntry =
           getPathOwnersEntryOrEmpty(project, repository, RefNames.REFS_CONFIG, cache);
       PathOwnersEntry rootEntry = getPathOwnersEntryOrNew(project, repository, branch, cache);
 
@@ -256,6 +265,9 @@
         ownersMap.addFileOwners(path, currentEntry.getOwners());
         ownersMap.addFileReviewers(path, currentEntry.getReviewers());
         ownersMap.addFileGroupOwners(path, currentEntry.getGroupOwners());
+        if (!currentEntry.isAutoOwnersApproved()) {
+          ownersMap.banFileFromOwnersAutoApproval(path);
+        }
 
         // Only add the path to the OWNERS file to reduce the number of
         // entries in the result
@@ -293,20 +305,20 @@
     ImmutableList.Builder<ReadOnlyPathOwnersEntry> pathOwnersEntries = ImmutableList.builder();
     for (Project.NameKey projectName : projectNames) {
       try (Repository repo = repositoryManager.openRepository(projectName)) {
-        pathOwnersEntries =
-            pathOwnersEntries.add(
-                getPathOwnersEntryOrEmpty(projectName.get(), repo, branch, cache));
+        Optional<ReadOnlyPathOwnersEntry> pathOwnersEntry =
+            getPathOwnersEntryOrEmpty(projectName.get(), repo, branch, cache);
+        if (pathOwnersEntry.isPresent()) {
+          pathOwnersEntries = pathOwnersEntries.add(pathOwnersEntry.get());
+        }
       }
     }
     return pathOwnersEntries.build();
   }
 
-  private ReadOnlyPathOwnersEntry getPathOwnersEntryOrEmpty(
+  private Optional<ReadOnlyPathOwnersEntry> getPathOwnersEntryOrEmpty(
       String project, Repository repo, String branch, PathOwnersEntriesCache cache)
       throws InvalidOwnersFileException, ExecutionException {
-    return getPathOwnersEntry(project, repo, branch, cache)
-        .map(v -> (ReadOnlyPathOwnersEntry) v)
-        .orElse(PathOwnersEntry.EMPTY);
+    return getPathOwnersEntry(project, repo, branch, cache).map(v -> (ReadOnlyPathOwnersEntry) v);
   }
 
   private PathOwnersEntry getPathOwnersEntryOrNew(
@@ -336,6 +348,7 @@
                             Optional.empty(),
                             Collections.emptySet(),
                             Collections.emptySet(),
+                            Optional.empty(),
                             Collections.emptySet(),
                             Collections.emptySet())));
   }
@@ -378,6 +391,22 @@
         ownersMap.addFileOwners(path, matcher.getOwners());
         ownersMap.addFileGroupOwners(path, matcher.getGroupOwners());
         ownersMap.addFileReviewers(path, matcher.getReviewers());
+        switch (matcher.getAutoOwnersApproved()) {
+          // We have an explicit allowance for this matcher
+          // Make sure that anything added at OWNERS level is removed
+          case InheritableBoolean.TRUE:
+            ownersMap.allowFileForAutoApproval(path);
+            break;
+          // We have an explicit ban for this matcher
+          case InheritableBoolean.FALSE:
+            ownersMap.banFileFromOwnersAutoApproval(path);
+            break;
+
+          // There is no matcher-level specification of auto-owner-approved
+          // therefore the global OWNER-level still applies
+          default:
+            break;
+        }
         matchingFound = true;
       }
     }
@@ -388,26 +417,30 @@
       String project,
       String path,
       String branch,
-      ReadOnlyPathOwnersEntry projectEntry,
+      Optional<ReadOnlyPathOwnersEntry> projectEntry,
       List<ReadOnlyPathOwnersEntry> parentsPathOwnersEntries,
       PathOwnersEntry rootEntry,
       Map<String, PathOwnersEntry> entries,
       PathOwnersEntriesCache cache)
       throws InvalidOwnersFileException, ExecutionException {
     String[] parts = path.split("/");
-    PathOwnersEntry currentEntry = rootEntry;
     StringBuilder builder = new StringBuilder();
 
     // Inherit from Project if OWNER in root enables inheritance
-    calculateCurrentEntry(rootEntry, projectEntry, currentEntry);
+    if (rootEntry.isInherited()) {
+      projectEntry.ifPresent(pe -> calculateCurrentEntry(pe, rootEntry));
+    }
 
     // Inherit from Parent Project if OWNER in Project enables inheritance
     for (ReadOnlyPathOwnersEntry parentPathOwnersEntry : parentsPathOwnersEntries) {
-      calculateCurrentEntry(projectEntry, parentPathOwnersEntry, currentEntry);
+      if (projectEntry.isEmpty() || projectEntry.get().isInherited()) {
+        calculateCurrentEntry(parentPathOwnersEntry, rootEntry);
+      }
     }
 
     // Iterate through the parent paths, not including the file name
     // itself
+    PathOwnersEntry currentEntry = rootEntry;
     for (int i = 0; i < parts.length - 1; i++) {
       String part = parts[i];
       builder.append(part).append("/");
@@ -443,6 +476,7 @@
                                   label,
                                   owners,
                                   reviewers,
+                                  Optional.of(pathFallbackEntry.isAutoOwnersApproved()),
                                   inheritedMatchers,
                                   groupOwners);
                             })
@@ -454,24 +488,23 @@
   }
 
   private void calculateCurrentEntry(
-      ReadOnlyPathOwnersEntry rootEntry,
-      ReadOnlyPathOwnersEntry projectEntry,
-      PathOwnersEntry currentEntry) {
-    if (rootEntry.isInherited()) {
-      for (Matcher matcher : projectEntry.getMatchers().values()) {
-        if (!currentEntry.hasMatcher(matcher.getPath())) {
-          currentEntry.addMatcher(matcher);
-        }
+      ReadOnlyPathOwnersEntry projectEntry, PathOwnersEntry currentEntry) {
+    for (Matcher matcher : projectEntry.getMatchers().values()) {
+      if (!currentEntry.hasMatcher(matcher.getPath())) {
+        currentEntry.addMatcher(matcher);
       }
-      if (currentEntry.getOwners().isEmpty()) {
-        currentEntry.setOwners(projectEntry.getOwners());
-      }
-      if (currentEntry.getOwnersPath() == null) {
-        currentEntry.setOwnersPath(projectEntry.getOwnersPath());
-      }
-      if (currentEntry.getLabel().isEmpty()) {
-        currentEntry.setLabel(projectEntry.getLabel());
-      }
+    }
+    if (currentEntry.getOwners().isEmpty()) {
+      currentEntry.setOwners(projectEntry.getOwners());
+    }
+    if (currentEntry.getOwnersPath() == null) {
+      currentEntry.setOwnersPath(projectEntry.getOwnersPath());
+    }
+    if (currentEntry.getLabel().isEmpty()) {
+      currentEntry.setLabel(projectEntry.getLabel());
+    }
+    if (!currentEntry.hasExplicitAutoOwnersApproved()) {
+      currentEntry.setAutoOwnersApproved(projectEntry.isAutoOwnersApproved());
     }
   }
 
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntry.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntry.java
index 8e4c53c..7b42709 100644
--- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntry.java
+++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntry.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import java.util.Collection;
 import java.util.Map;
 import java.util.Optional;
@@ -32,8 +33,6 @@
  * <p>Used internally by PathOwners to represent and compute the owners at a specific path.
  */
 class PathOwnersEntry extends ReadOnlyPathOwnersEntry {
-  static final ReadOnlyPathOwnersEntry EMPTY = new ReadOnlyPathOwnersEntry(true) {};
-
   public PathOwnersEntry() {
     super(true);
   }
@@ -45,9 +44,11 @@
       Optional<LabelDefinition> inheritedLabel,
       Set<Account.Id> inheritedOwners,
       Set<Account.Id> inheritedReviewers,
+      Optional<Boolean> autoOwnersApproved,
       Collection<Matcher> inheritedMatchers,
       Set<String> inheritedGroupOwners) {
     super(config.isInherited());
+    this.explicitAutoOwnersApproved = config.getAutoOwnersApproved() != InheritableBoolean.INHERIT;
     this.ownersPath = path;
     this.owners =
         config.getOwners().stream()
@@ -67,12 +68,19 @@
       this.owners.addAll(inheritedOwners);
       this.groupOwners.addAll(inheritedGroupOwners);
       this.reviewers.addAll(inheritedReviewers);
+      if (config.getAutoOwnersApproved() == InheritableBoolean.INHERIT) {
+        autoOwnersApproved.ifPresent(this::setAutoOwnersApproved);
+      } else {
+        setAutoOwnersApproved(config.getAutoOwnersApproved() == InheritableBoolean.TRUE);
+      }
       for (Matcher matcher : inheritedMatchers) {
         addMatcher(matcher);
       }
       this.label = config.getLabel().or(() -> inheritedLabel);
     } else {
       this.label = config.getLabel();
+      // Default to true unless the OWNERS file explicitly sets it to false.
+      this.setAutoOwnersApproved(config.getAutoOwnersApproved() != InheritableBoolean.FALSE);
     }
   }
 
@@ -121,6 +129,8 @@
   protected String ownersPath;
   protected Map<String, Matcher> matchers = Maps.newHashMap();
   protected Set<String> groupOwners = Sets.newHashSet();
+  protected boolean autoOwnersApproved = true;
+  protected boolean explicitAutoOwnersApproved;
 
   protected ReadOnlyPathOwnersEntry(boolean inherited) {
     this.inherited = inherited;
@@ -155,6 +165,18 @@
     return label;
   }
 
+  public boolean isAutoOwnersApproved() {
+    return autoOwnersApproved;
+  }
+
+  public boolean hasExplicitAutoOwnersApproved() {
+    return explicitAutoOwnersApproved;
+  }
+
+  public void setAutoOwnersApproved(boolean autoOwnersApproved) {
+    this.autoOwnersApproved = autoOwnersApproved;
+  }
+
   public boolean hasMatcher(String path) {
     return this.matchers.containsKey(path);
   }
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PluginSettings.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PluginSettings.java
index 1fd7403..951b133 100644
--- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PluginSettings.java
+++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PluginSettings.java
@@ -87,7 +87,9 @@
     return expandGroups;
   }
 
-  /** @return <code>true</code> when OWNERS file should be evaluated through the submit rule */
+  /**
+   * @return <code>true</code> when OWNERS file should be evaluated through the submit rule
+   */
   public boolean enableSubmitRequirement() {
     return enableSubmitRequirement;
   }
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/RegExMatcher.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/RegExMatcher.java
index 6d1df24..d7920a8 100644
--- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/RegExMatcher.java
+++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/RegExMatcher.java
@@ -17,6 +17,7 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Account.Id;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import java.util.Set;
 import java.util.regex.Pattern;
 
@@ -24,8 +25,12 @@
   Pattern pattern;
 
   public RegExMatcher(
-      String path, Set<Account.Id> owners, Set<Account.Id> reviewers, Set<String> groupOwners) {
-    super(path, owners, reviewers, groupOwners);
+      String path,
+      Set<Account.Id> owners,
+      Set<Account.Id> reviewers,
+      Set<String> groupOwners,
+      InheritableBoolean autoOwnersApproved) {
+    super(path, owners, reviewers, groupOwners, autoOwnersApproved);
     pattern = Pattern.compile(path);
   }
 
@@ -35,7 +40,11 @@
   }
 
   @Override
-  protected Matcher clone(Set<Id> owners, Set<Id> reviewers, Set<String> groupOwners) {
-    return new RegExMatcher(path, owners, reviewers, groupOwners);
+  protected Matcher clone(
+      Set<Id> owners,
+      Set<Id> reviewers,
+      Set<String> groupOwners,
+      InheritableBoolean autoOwnersApproved) {
+    return new RegExMatcher(path, owners, reviewers, groupOwners, autoOwnersApproved);
   }
 }
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/SuffixMatcher.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/SuffixMatcher.java
index 5dec4c5..b9a2fdb 100644
--- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/SuffixMatcher.java
+++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/SuffixMatcher.java
@@ -17,12 +17,17 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Account.Id;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import java.util.Set;
 
 public class SuffixMatcher extends Matcher {
   public SuffixMatcher(
-      String path, Set<Account.Id> owners, Set<Account.Id> reviewers, Set<String> groupOwners) {
-    super(path, owners, reviewers, groupOwners);
+      String path,
+      Set<Account.Id> owners,
+      Set<Account.Id> reviewers,
+      Set<String> groupOwners,
+      InheritableBoolean autoOwnersApproved) {
+    super(path, owners, reviewers, groupOwners, autoOwnersApproved);
   }
 
   @Override
@@ -31,7 +36,11 @@
   }
 
   @Override
-  protected Matcher clone(Set<Id> owners, Set<Id> reviewers, Set<String> groupOwners) {
-    return new SuffixMatcher(path, owners, reviewers, groupOwners);
+  protected Matcher clone(
+      Set<Id> owners,
+      Set<Id> reviewers,
+      Set<String> groupOwners,
+      InheritableBoolean autoOwnersApproved) {
+    return new SuffixMatcher(path, owners, reviewers, groupOwners, autoOwnersApproved);
   }
 }
diff --git a/owners-common/src/test/java/com/googlesource/gerrit/owners/common/MatcherMergeTest.java b/owners-common/src/test/java/com/googlesource/gerrit/owners/common/MatcherMergeTest.java
new file mode 100644
index 0000000..a36383a
--- /dev/null
+++ b/owners-common/src/test/java/com/googlesource/gerrit/owners/common/MatcherMergeTest.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2026 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.googlesource.gerrit.owners.common;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import java.util.Set;
+import org.junit.Test;
+
+public class MatcherMergeTest {
+  @Test
+  public void mergeAutoOwnersApprovedKeepsCurrentWhenOtherUnset() {
+    assertThatMatchersMergeAs(
+        InheritableBoolean.TRUE, InheritableBoolean.INHERIT, InheritableBoolean.TRUE);
+  }
+
+  @Test
+  public void mergeAutoOwnersApprovedUsesOtherWhenOtherIsFalse() {
+    assertThatMatchersMergeAs(
+        InheritableBoolean.INHERIT, InheritableBoolean.FALSE, InheritableBoolean.FALSE);
+  }
+
+  @Test
+  public void mergeAutoOwnersApprovedUsesOtherWhenOtherIsTrue() {
+    assertThatMatchersMergeAs(
+        InheritableBoolean.FALSE, InheritableBoolean.TRUE, InheritableBoolean.TRUE);
+  }
+
+  @Test
+  public void mergeUnsetAutoOwnersApprovedIsUnset() {
+    assertThatMatchersMergeAs(
+        InheritableBoolean.INHERIT, InheritableBoolean.INHERIT, InheritableBoolean.INHERIT);
+  }
+
+  private void assertThatMatchersMergeAs(
+      InheritableBoolean baseMatcherBool,
+      InheritableBoolean otherMatcherBool,
+      InheritableBoolean mergedMatcherBool) {
+    Matcher baseTrue = new TestMatcher(baseMatcherBool);
+    Matcher otherUnset = new TestMatcher(otherMatcherBool);
+    Matcher mergedUnsetOther = baseTrue.merge(otherUnset);
+    assertThat(mergedUnsetOther.getAutoOwnersApproved()).isEqualTo(mergedMatcherBool);
+  }
+
+  private static class TestMatcher extends Matcher {
+    private TestMatcher(InheritableBoolean autoOwnersApproved) {
+      super(
+          "test",
+          java.util.Set.of(Account.id(1)),
+          java.util.Set.of(Account.id(2)),
+          java.util.Set.of("grp"),
+          autoOwnersApproved);
+    }
+
+    @Override
+    public boolean matches(String pathToMatch) {
+      return true;
+    }
+
+    @Override
+    protected Matcher clone(
+        Set<Account.Id> owners,
+        Set<Account.Id> reviewers,
+        Set<String> groupOwners,
+        InheritableBoolean autoOwnersApproval) {
+      return new TestMatcher(autoOwnersApproval);
+    }
+  }
+}
diff --git a/owners-common/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersTest.java b/owners-common/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersTest.java
index 7ab294f..ca09d57 100644
--- a/owners-common/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersTest.java
+++ b/owners-common/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersTest.java
@@ -15,7 +15,6 @@
 package com.googlesource.gerrit.owners.common;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.googlesource.gerrit.owners.common.MatcherConfig.suffixMatcher;
 import static java.util.Collections.emptyList;
 import static org.easymock.EasyMock.eq;
@@ -26,6 +25,7 @@
 import static org.junit.Assert.assertTrue;
 import static org.powermock.api.easymock.PowerMock.replayAll;
 
+import com.google.common.truth.Truth8;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -91,7 +91,7 @@
     assertTrue(ownersSet.contains(USER_A_ID));
     assertTrue(ownersSet.contains(USER_B_ID));
     assertTrue(owners.expandGroups());
-    assertThat(owners.getLabel()).isEmpty();
+    Truth8.assertThat(owners.getLabel()).isEmpty();
   }
 
   @Test
@@ -110,7 +110,7 @@
             "foo",
             CACHE_MOCK,
             Optional.of(EXPECTED_LABEL_DEFINITION));
-    assertThat(owners.getLabel()).hasValue(EXPECTED_LABEL_DEFINITION);
+    Truth8.assertThat(owners.getLabel()).hasValue(EXPECTED_LABEL_DEFINITION);
   }
 
   @Test
@@ -188,7 +188,7 @@
 
     // expect that classic configuration takes precedence over `OWNERS` file for the label
     // definition
-    assertThat(owners2.getLabel().map(LabelDefinition::getName)).hasValue(EXPECTED_LABEL);
+    Truth8.assertThat(owners2.getLabel().map(LabelDefinition::getName)).hasValue(EXPECTED_LABEL);
   }
 
   @Test
@@ -210,7 +210,7 @@
             "foo",
             CACHE_MOCK,
             Optional.of(EXPECTED_LABEL_DEFINITION));
-    assertThat(owners2.getLabel()).hasValue(EXPECTED_LABEL_DEFINITION);
+    Truth8.assertThat(owners2.getLabel()).hasValue(EXPECTED_LABEL_DEFINITION);
   }
 
   @Test
@@ -232,7 +232,7 @@
             "foo",
             CACHE_MOCK,
             Optional.of(EXPECTED_LABEL_DEFINITION));
-    assertThat(owners2.getLabel()).hasValue(EXPECTED_LABEL_DEFINITION);
+    Truth8.assertThat(owners2.getLabel()).hasValue(EXPECTED_LABEL_DEFINITION);
   }
 
   @Test
@@ -270,7 +270,7 @@
     assertEquals(2, ownersSet.size());
     assertTrue(ownersSet.contains(USER_A_ID));
     assertTrue(ownersSet.contains(USER_B_ID));
-    assertThat(owners.getLabel().map(LabelDefinition::getName)).hasValue(EXPECTED_LABEL);
+    Truth8.assertThat(owners.getLabel().map(LabelDefinition::getName)).hasValue(EXPECTED_LABEL);
   }
 
   @Test
@@ -319,7 +319,7 @@
 
     // expect that `master` configuration overwrites the label definition of both `refs/meta/config`
     // and parent repo
-    assertThat(owners.getLabel().map(LabelDefinition::getName)).hasValue(EXPECTED_LABEL);
+    Truth8.assertThat(owners.getLabel().map(LabelDefinition::getName)).hasValue(EXPECTED_LABEL);
   }
 
   @Test
@@ -371,7 +371,7 @@
     assertTrue(ownersSet2.contains(USER_B_ID));
 
     // expect that closer parent (parentRepository1) overwrites the label definition
-    assertThat(owners.getLabel().map(LabelDefinition::getName)).hasValue(EXPECTED_LABEL);
+    Truth8.assertThat(owners.getLabel().map(LabelDefinition::getName)).hasValue(EXPECTED_LABEL);
   }
 
   private void mockParentRepository(Project.NameKey repositoryName, Repository repository)
@@ -411,7 +411,7 @@
     assertTrue(ownersSet.contains(USER_C_ID));
 
     // expect that more specific configuration overwrites the label definition
-    assertThat(owners.getLabel().map(LabelDefinition::getName)).hasValue(EXPECTED_LABEL);
+    Truth8.assertThat(owners.getLabel().map(LabelDefinition::getName)).hasValue(EXPECTED_LABEL);
   }
 
   @Test
@@ -421,11 +421,11 @@
     OwnersConfig ownersConfig = getOwnersConfig(yamlString);
 
     assertTrue(ownersConfig.isInherited());
-    assertThat(ownersConfig.getLabel()).isPresent();
+    Truth8.assertThat(ownersConfig.getLabel()).isPresent();
 
     LabelDefinition label = ownersConfig.getLabel().get();
     assertThat(label.getName()).isEqualTo(EXPECTED_LABEL);
-    assertThat(label.getScore()).hasValue(1);
+    Truth8.assertThat(label.getScore()).hasValue(1);
 
     Set<String> owners = ownersConfig.getOwners();
     assertEquals(1, owners.size());
@@ -439,11 +439,11 @@
     OwnersConfig ownersConfig = getOwnersConfig(yamlString);
 
     assertTrue(ownersConfig.isInherited());
-    assertThat(ownersConfig.getLabel()).isPresent();
+    Truth8.assertThat(ownersConfig.getLabel()).isPresent();
 
     LabelDefinition label = ownersConfig.getLabel().get();
     assertThat(label.getName()).isEqualTo(EXPECTED_LABEL);
-    assertThat(label.getScore()).isEmpty();
+    Truth8.assertThat(label.getScore()).isEmpty();
 
     Set<String> owners = ownersConfig.getOwners();
     assertEquals(1, owners.size());
@@ -493,6 +493,138 @@
     assertThat(cacheMock.hit).isEqualTo(expectedCacheCalls);
   }
 
+  @Test
+  public void testAutoOwnersMisconfigured() throws Exception {
+    expectConfig("OWNERS", "inherited: true\nauto-owners-approved: \"some wrong value\"");
+
+    replayAll();
+
+    PathOwners owners =
+        new PathOwners(
+            accounts,
+            repositoryManager,
+            repository,
+            emptyList(),
+            branch,
+            Set.of("file.txt"),
+            EXPAND_GROUPS,
+            "foo",
+            CACHE_MOCK,
+            Optional.empty());
+
+    assertThat(owners.getFileOwnersBannedAutoApproval()).isEmpty();
+    assertThat(owners.getFileOwners()).isEmpty();
+  }
+
+  @Test
+  public void testAutoOwnersApprovedInheritedFromRoot() throws Exception {
+    expectConfig(
+        "OWNERS",
+        "inherited: true\nauto-owners-approved: false\nowners:\n- " + USER_A_EMAIL_COM + "\n");
+    expectConfig("dir/OWNERS", "inherited: true\nowners:\n- " + USER_B_EMAIL_COM + "\n");
+
+    replayAll();
+
+    PathOwners owners =
+        new PathOwners(
+            accounts,
+            repositoryManager,
+            repository,
+            emptyList(),
+            branch,
+            Set.of("dir/file.txt"),
+            EXPAND_GROUPS,
+            "foo",
+            CACHE_MOCK,
+            Optional.empty());
+
+    assertThat(owners.getFileOwnersBannedAutoApproval()).contains("dir/file.txt");
+  }
+
+  @Test
+  public void testAutoOwnersApprovedDefaultsWhenInheritanceStopped() throws Exception {
+    expectConfig(
+        "OWNERS",
+        "inherited: true\nauto-owners-approved: false\nowners:\n- " + USER_A_EMAIL_COM + "\n");
+    expectConfig("dir/OWNERS", "inherited: false\nowners:\n- " + USER_B_EMAIL_COM + "\n");
+
+    replayAll();
+
+    PathOwners owners =
+        new PathOwners(
+            accounts,
+            repositoryManager,
+            repository,
+            emptyList(),
+            branch,
+            Set.of("dir/file.txt"),
+            EXPAND_GROUPS,
+            "foo",
+            CACHE_MOCK,
+            Optional.empty());
+
+    assertThat(owners.getFileOwnersBannedAutoApproval()).isEmpty();
+  }
+
+  @Test
+  public void testAutoOwnersApprovedInheritedFromParentProjectOwners() throws Exception {
+    expectConfig("OWNERS", "master", createConfig(true, owners()));
+    expectConfig("OWNERS", RefNames.REFS_CONFIG, repository, createConfig(true, owners()));
+    expectConfig(
+        "OWNERS",
+        RefNames.REFS_CONFIG,
+        parentRepository1,
+        "inherited: true\nauto-owners-approved: false\nowners:\n- " + USER_A_EMAIL_COM + "\n");
+
+    mockParentRepository(parentRepository1NameKey, parentRepository1);
+    replayAll();
+
+    PathOwners owners =
+        new PathOwners(
+            accounts,
+            repositoryManager,
+            repository,
+            Arrays.asList(parentRepository1NameKey),
+            branch,
+            Set.of("file.txt"),
+            EXPAND_GROUPS,
+            "foo",
+            CACHE_MOCK,
+            Optional.empty());
+
+    assertThat(owners.getFileOwnersBannedAutoApproval()).contains("file.txt");
+  }
+
+  @Test
+  public void testExplicitAutoOwnersApprovedInRootOverridesProjectOwners() throws Exception {
+    expectConfig(
+        "OWNERS",
+        "master",
+        "inherited: true\nauto-owners-approved: false\nowners:\n- " + USER_A_EMAIL_COM + "\n");
+    expectConfig(
+        "OWNERS",
+        RefNames.REFS_CONFIG,
+        repository,
+        "inherited: true\nauto-owners-approved: true\nowners:\n- " + USER_B_EMAIL_COM + "\n");
+
+    replayAll();
+
+    PathOwners owners =
+        new PathOwners(
+            accounts,
+            repositoryManager,
+            repository,
+            emptyList(),
+            branch,
+            Set.of("file.txt"),
+            EXPAND_GROUPS,
+            "foo",
+            CACHE_MOCK,
+            Optional.empty());
+
+    assertThat(owners.getFileOwnersBannedAutoApproval()).contains("file.txt");
+  }
+
   private void mockOwners(String... owners) throws IOException {
     expectNoConfig("OWNERS");
     expectConfig(CLASSIC_OWNERS, createConfig(false, owners(owners)));
diff --git a/owners-common/src/test/java/com/googlesource/gerrit/owners/common/RegexMatcherTest.java b/owners-common/src/test/java/com/googlesource/gerrit/owners/common/RegexMatcherTest.java
index 67c4ab6..d157ebb 100644
--- a/owners-common/src/test/java/com/googlesource/gerrit/owners/common/RegexMatcherTest.java
+++ b/owners-common/src/test/java/com/googlesource/gerrit/owners/common/RegexMatcherTest.java
@@ -23,18 +23,18 @@
 public class RegexMatcherTest {
   @Test
   public void testRegex() {
-    RegExMatcher matcher = new RegExMatcher(".*/a.*", null, null, null);
+    RegExMatcher matcher = new RegExMatcher(".*/a.*", null, null, null, null);
     assertTrue(matcher.matches("xxxxxx/axxxx"));
     assertFalse(matcher.matches("axxxx"));
     assertFalse(matcher.matches("xxxxx/bxxxx"));
 
-    RegExMatcher matcher2 = new RegExMatcher("a.*.sql", null, null, null);
+    RegExMatcher matcher2 = new RegExMatcher("a.*.sql", null, null, null, null);
     assertFalse(matcher2.matches("xxxxxx/alfa.sql"));
   }
 
   @Test
   public void testFloatingRegex() {
-    PartialRegExMatcher matcher = new PartialRegExMatcher("a.*.sql", null, null, null);
+    PartialRegExMatcher matcher = new PartialRegExMatcher("a.*.sql", null, null, null, null);
     assertTrue(matcher.matches("xxxxxxx/alfa.sql"));
     assertTrue(matcher.matches("alfa.sqlxxxxx"));
     assertFalse(matcher.matches("alfa.bar"));
diff --git a/owners-common/src/test/java/com/googlesource/gerrit/owners/common/RegexTest.java b/owners-common/src/test/java/com/googlesource/gerrit/owners/common/RegexTest.java
index 821db6a..a6e10a6 100644
--- a/owners-common/src/test/java/com/googlesource/gerrit/owners/common/RegexTest.java
+++ b/owners-common/src/test/java/com/googlesource/gerrit/owners/common/RegexTest.java
@@ -15,7 +15,6 @@
 package com.googlesource.gerrit.owners.common;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.googlesource.gerrit.owners.common.MatcherConfig.exactMatcher;
 import static com.googlesource.gerrit.owners.common.MatcherConfig.genericMatcher;
 import static com.googlesource.gerrit.owners.common.MatcherConfig.partialRegexMatcher;
@@ -28,6 +27,7 @@
 import static org.powermock.api.easymock.PowerMock.replayAll;
 
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
@@ -271,4 +271,36 @@
     Set<String> ownedFiles = owners.getFileOwners().keySet();
     assertThat(ownedFiles).containsExactly("project/file.sql");
   }
+
+  @Test
+  public void testParsingAutoOwnersApprovedFlags() throws Exception {
+    replayAll();
+
+    String configString =
+        "inherited: true\n"
+            + "auto-owners-approved: false\n"
+            + "owners:\n"
+            + "- a\n"
+            + "matchers:\n"
+            + "- suffix: .sql\n"
+            + "  auto-owners-approved: true\n"
+            + "  owners:\n"
+            + "  - b\n"
+            + "- exact: project/a.txt\n"
+            + "  auto-owners-approved: false\n"
+            + "  owners:\n"
+            + "  - c\n"
+            + "- regex: .*\\.md\n"
+            + "  owners:\n"
+            + "  - d\n";
+
+    OwnersConfig config = getOwnersConfig(configString);
+
+    assertThat(config.getMatchers().get(".sql").getAutoOwnersApproved())
+        .isEqualTo(InheritableBoolean.TRUE);
+    assertThat(config.getMatchers().get("project/a.txt").getAutoOwnersApproved())
+        .isEqualTo(InheritableBoolean.FALSE);
+    assertThat(config.getMatchers().get(".*\\.md").getAutoOwnersApproved())
+        .isEqualTo(InheritableBoolean.INHERIT);
+  }
 }
diff --git a/owners/BUILD b/owners/BUILD
index c1dac46..59a8c5d 100644
--- a/owners/BUILD
+++ b/owners/BUILD
@@ -13,10 +13,16 @@
     srcs = PROLOG_PREDICATES,
     deps = [
         "//owners-common",
-        "@prolog-runtime//jar:neverlink",
+        ":prolog-runtime-neverlink",
     ] + PLUGIN_DEPS_NEVERLINK,
 )
 
+java_library(
+    name = "prolog-runtime-neverlink",
+    neverlink = 1,
+    exports = ["@external_deps//:com_googlecode_prolog_cafe_prolog_runtime"],
+)
+
 prolog_cafe_library(
     name = "gerrit-owners-prolog-rules",
     srcs = glob(["src/main/prolog/*.pl"]),
diff --git a/owners/src/main/java/com/googlesource/gerrit/owners/AlreadyApprovedByPredicate.java b/owners/src/main/java/com/googlesource/gerrit/owners/AlreadyApprovedByPredicate.java
index bf4aede..e74e402 100644
--- a/owners/src/main/java/com/googlesource/gerrit/owners/AlreadyApprovedByPredicate.java
+++ b/owners/src/main/java/com/googlesource/gerrit/owners/AlreadyApprovedByPredicate.java
@@ -21,6 +21,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
@@ -75,6 +76,8 @@
       Project.NameKey project = ctx.changeData().project();
       PatchSet targetPatchSet = ctx.targetPatchSet();
       PatchSet sourcePatchSet = ctx.changeNotes().getPatchSets().get(ctx.sourcePatchSetId());
+      Account.Id changeOwner = ctx.changeNotes().getChange().getOwner();
+      Account.Id uploader = targetPatchSet.uploader();
 
       checkState(
           predicateField == UserInPredicate.Field.APPROVER,
@@ -91,8 +94,18 @@
           project);
 
       Map<String, FileDiffOutput> priorVsCurrent =
-          diffOperations.listModifiedFiles(
-              project, sourcePatchSet.commitId(), targetPatchSet.commitId(), DO_NOT_IGNORE_REBASE);
+          diffOperations
+              .listModifiedFiles(
+                  project,
+                  sourcePatchSet.commitId(),
+                  targetPatchSet.commitId(),
+                  DO_NOT_IGNORE_REBASE)
+              .entrySet()
+              .stream()
+              // COMMIT_MSG has never an owner, we don't ever want to consider it, even if it
+              // was modified as part of this patch-set.
+              .filter(entry -> !Patch.COMMIT_MSG.equals(entry.getKey()))
+              .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
 
       // We can't simply look at keys because it won't contain the old name of renamed-files.
       Set<String> allFilePathsInDiff =
@@ -102,12 +115,20 @@
               .map(Optional::get)
               .collect(Collectors.toSet());
 
+      String branch = ctx.changeData().branchOrThrow().branch();
       Set<String> filesOwnedByApprover =
-          getFilesOwners.filterFilesOwnedBy(
-              currentApprover,
-              allFilePathsInDiff,
-              project,
-              ctx.changeData().branchOrThrow().branch());
+          getFilesOwners.filterFilesOwnedBy(currentApprover, allFilePathsInDiff, project, branch);
+
+      if (isApproverAlsoOwnerAndUploader(currentApprover, changeOwner, uploader)
+          && allTouchedFilesAreOwned(filesOwnedByApprover, allFilePathsInDiff)
+          && getFilesOwners.noOwnedFileIsBannedFromAutoApproval(
+              filesOwnedByApprover, project, branch)) {
+        logger.atFinest().log(
+            "Approver '%s' is change owner and uploader. only owned files have been modified and"
+                + " none of them has auto-owners-approved=false. Label WILL be copied.",
+            currentApprover);
+        return true;
+      }
 
       if (!filesOwnedByApprover.isEmpty()) {
         logger.atFinest().log(
@@ -198,6 +219,17 @@
     return !d.oldPath().equals(d.newPath());
   }
 
+  private static boolean isApproverAlsoOwnerAndUploader(
+      Account.Id currentApprover, Account.Id changeOwner, Account.Id uploader) {
+    return currentApprover.equals(changeOwner) && currentApprover.equals(uploader);
+  }
+
+  private static boolean allTouchedFilesAreOwned(
+      Set<String> filesOwnedByApprover, Set<String> allFilePathsInDiff) {
+    return !filesOwnedByApprover.isEmpty()
+        && filesOwnedByApprover.size() == allFilePathsInDiff.size();
+  }
+
   private int getParentNum(ObjectId objectId, RevWalk revWalk) {
     try {
       RevCommit commit = revWalk.parseCommit(objectId);
diff --git a/owners/src/main/java/com/googlesource/gerrit/owners/OwnersSubmitRequirement.java b/owners/src/main/java/com/googlesource/gerrit/owners/OwnersSubmitRequirement.java
index 64638bc..33a4b3f 100644
--- a/owners/src/main/java/com/googlesource/gerrit/owners/OwnersSubmitRequirement.java
+++ b/owners/src/main/java/com/googlesource/gerrit/owners/OwnersSubmitRequirement.java
@@ -251,7 +251,8 @@
         .or(
             () -> {
               logger.atSevere().log(
-                  "OWNERS label '%s' is not configured for '%s' project. Change is not submittable.",
+                  "OWNERS label '%s' is not configured for '%s' project. Change is not"
+                      + " submittable.",
                   label, project);
               return Optional.empty();
             });
diff --git a/owners/src/main/java/com/googlesource/gerrit/owners/restapi/GetFilesOwners.java b/owners/src/main/java/com/googlesource/gerrit/owners/restapi/GetFilesOwners.java
index c395179..af62387 100644
--- a/owners/src/main/java/com/googlesource/gerrit/owners/restapi/GetFilesOwners.java
+++ b/owners/src/main/java/com/googlesource/gerrit/owners/restapi/GetFilesOwners.java
@@ -108,6 +108,15 @@
         .collect(Collectors.toSet());
   }
 
+  public boolean noOwnedFileIsBannedFromAutoApproval(
+      Set<String> ownedPaths, Project.NameKey project, String branch)
+      throws IOException, InvalidOwnersFileException {
+    PathOwners owners = getPathOwners(project, branch, ownedPaths);
+    Set<String> filesBannedFromAutoOwnersApproval = owners.getFileOwnersBannedAutoApproval();
+
+    return ownedPaths.stream().noneMatch(filesBannedFromAutoOwnersApproval::contains);
+  }
+
   @Override
   public Response<FilesOwnersResponse> apply(RevisionResource revision)
       throws AuthException, BadRequestException, ResourceConflictException, Exception {
diff --git a/owners/src/main/resources/Documentation/config.md b/owners/src/main/resources/Documentation/config.md
index 4b94ac3..553e540 100644
--- a/owners/src/main/resources/Documentation/config.md
+++ b/owners/src/main/resources/Documentation/config.md
@@ -18,8 +18,9 @@
     ```
 
 owners.expandGroups
-:   Expand owners and groups into account ids. If set to `false` all owners are left untouched, apart from e-mail
-    addresses which have the domain dropped. Defaults to `true`.
+:   Expand owners and groups into account ids. If set to `false` all owners are left untouched,
+apart from e-mail
+addresses which have the domain dropped. Defaults to `true`.
 
     Example:
 
@@ -30,8 +31,8 @@
 
 owners.label
 :   Global override for the label and score, separated by a comma, to use by
-    the owners of changes for approving them. When defined, it overrides any
-    other label definition set by the OWNERS at any level in any project.
+the owners of changes for approving them. When defined, it overrides any
+other label definition set by the OWNERS at any level in any project.
 
 > **NOTE:** Compulsory when the selected label's function is NoBlock/NoOp.
 
@@ -44,10 +45,10 @@
 
 <a name="owners.enableSubmitRequirement">owners.enableSubmitRequirement</a>
 :   If set to `true` the approvals are evaluated through the owners plugin
-    default submit requirement, named "Code-Review-from-Owners", without a need of
-    prolog predicate being added to a project or submit requirement configured
-    in the `project.config` as it is automatically applied to all projects.
-    Defaults to `false`.
+default submit requirement, named "Code-Review-from-Owners", without a need of
+prolog predicate being added to a project or submit requirement configured
+in the `project.config` as it is automatically applied to all projects.
+Defaults to `false`.
 
     Example:
 
@@ -80,19 +81,18 @@
     >   submittableIf = has:approval_owners
     > ```
 
-
 cache."owners.path_owners_entries".memoryLimit
 :   The cache is used to hold the parsed version of `OWNERS` files in the
-    repository so that when submit rules are calculated (either through prolog
-    or through submit requirements) it is not read over and over again. The
-    cache entry gets invalidated when `OWNERS` file branch is updated.
-    By default it follows default Gerrit's cache memory limit but it makes
-    sense to adjust it as a function of number of project that use the `owners`
-    plugin multiplied by average number of active branches (plus 1 for the
-    refs/meta/config) and average number of directories (as directory hierarchy
-    back to root is checked for the `OWNERS` file existence).
-    _Note that in opposite to the previous settings the modification needs to be
-    performed in the `$GERRIT_SITE/etc/gerrit.config` file._
+repository so that when submit rules are calculated (either through prolog
+or through submit requirements) it is not read over and over again. The
+cache entry gets invalidated when `OWNERS` file branch is updated.
+By default it follows default Gerrit's cache memory limit but it makes
+sense to adjust it as a function of number of project that use the `owners`
+plugin multiplied by average number of active branches (plus 1 for the
+refs/meta/config) and average number of directories (as directory hierarchy
+back to root is checked for the `OWNERS` file existence).
+_Note that in opposite to the previous settings the modification needs to be
+performed in the `$GERRIT_SITE/etc/gerrit.config` file._
 
     Example
 
@@ -112,20 +112,20 @@
 inherited: true
 label: Code-Review, 1
 owners:
-- some.email@example.com
-- User Name
-- group/Group of Users
+  - some.email@example.com
+  - User Name
+  - group/Group of Users
 matchers:
-- suffix: .java
-  owners:
-      [...]
-- regex: .*/README.*
-  owners:
-      [...]
-- partial_regex: example
-  owners:
-      [...]
-- exact: path/to/file.txt
+  - suffix: .java
+    owners:
+        [ ... ]
+  - regex: .*/README.*
+    owners:
+        [ ... ]
+  - partial_regex: example
+    owners:
+        [ ... ]
+  - exact: path/to/file.txt
       [...]
 ```
 
@@ -190,9 +190,9 @@
 
 ```yaml
 matchers:
-- suffix: .config
-  owners:
-  - Configuration Managers
+  - suffix: .config
+    owners:
+      - Configuration Managers
 ```
 
 Global refs/meta/config OWNERS configuration is inherited only when the OWNERS file
@@ -204,6 +204,61 @@
 If the global project OWNERS has the 'inherited: true', it will check for a global project OWNERS
 in all parent projects up to All-Projects.
 
+## auto-owners-approved
+
+The `auto-owners-approved` field controls a specific exception to the default
+`approverin:already-approved-by_owners` behavior. It applies when a new patch-set updates only files
+that are owned by the change owner or patch-set committer, in a situation where the normal
+`approverin:already-approved-by_owners` logic would otherwise drop that owner's previous vote.
+
+The rationale is simple: if an owner already approved a change that stays entirely within code they
+own, and the next patch set is uploaded by that same owner, forcing that same person to re-apply
+the same vote adds little review value.
+
+See [copy-conditions.md](copy-conditions.md) for predicate evaluation details.
+
+This field can be configured at `OWNERS` file level and on individual matchers.
+If it is not set, it defaults to `true`.
+
+If `auto-owners-approved` is `false` for any touched file, the predicate does not use that
+self-update shortcut for the patch set. The usual `approverin:already-approved-by_owners` logic
+still applies.
+
+When a matcher defines `auto-owners-approved`, that matcher-specific value takes precedence for the
+files it matches over the surrounding `OWNERS` value.
+
+### Inheritance
+
+The usual `OWNERS` [inheritance](#global-project-owners) logic applies to `auto-owners-approved` as
+well. This includes directory `OWNERS` lookup, project `refs/meta/config` `OWNERS`, and
+parent project `OWNERS` when inheritance continues up the project hierarchy.
+
+### auto-owners-approved example
+
+Disable at `OWNERS` level:
+
+    inherited: true
+    auto-owners-approved: false
+
+With this setting, the predicate will not copy an owner's vote just because the owner is updating
+only files they own on their own change. Paths under that `OWNERS` file still participate in the
+normal copy-condition behavior.
+
+Override that setting for matched files:
+
+```yaml
+inherited: true
+auto-owners-approved: false
+matchers:
+  - suffix: .java
+    auto-owners-approved: true
+    owners:
+      - user-backend
+```
+
+Here, `.java` files matched by that rule use `auto-owners-approved: true` even though the enclosing
+`OWNERS` file sets it to `false`.
+
 ## Example 1 - OWNERS file without matchers
 
 Given an OWNERS configuration of:
@@ -211,8 +266,8 @@
 ```yaml
 inherited: true
 owners:
-- John Doe
-- Doug Smith
+  - John Doe
+  - Doug Smith
 ```
 
 In this case the owners plugin will assume the default label configuration,`Code-Review
@@ -222,6 +277,7 @@
 change, you can then either enable `owners.enableSubmitRequirement = true` in
 your `gerrit.config` or define a submit requirement in your `project.config` that
 uses the `has:approval_owners` in the `submittableIf` section, like so:
+
 ```
 [submit-requirement "Owner-Approval"]
        description = Files needs to be approved by owners
@@ -263,8 +319,8 @@
 inherited: true
 label: Owner-Approved, 1
 owners:
-- John Doe
-- Doug Smith
+  - John Doe
+  - Doug Smith
 ```
 
 This will mean that, a change cannot be submitted until 'John Doe' or 'Doug
@@ -279,6 +335,7 @@
 
 If you no longer wish to require a `Code-Review +2` and would rather only use
 the custom submit requirement, you have two options:
+
 - change the definition of the `Code-Review` label in `All-Projects`'s
   `project.config` so that `function = NoOp`.
 - set an `overrideIf` clause in your custom submit requirement definition
@@ -302,13 +359,13 @@
 ```yaml
 inherited: true
 matchers:
-- suffix: .sql
-  owners:
-  - Mister Dba
-- regex: .*Test.*
-  owners:
-  - John Bug
-  - Matt Free
+  - suffix: .sql
+    owners:
+      - Mister Dba
+  - regex: .*Test.*
+    owners:
+      - John Bug
+      - Matt Free
 ```
 
 You can then either enable `owners.enableSubmitRequirement = true` in your
diff --git a/owners/src/main/resources/Documentation/copy-conditions.md b/owners/src/main/resources/Documentation/copy-conditions.md
index b7f617b..b318028 100644
--- a/owners/src/main/resources/Documentation/copy-conditions.md
+++ b/owners/src/main/resources/Documentation/copy-conditions.md
@@ -118,3 +118,13 @@
     value = +2 Looks good to me, approved
     copyCondition = approverin:already-approved-by_owners
 ```
+
+## Customizing the copy condition behaviour with the `auto-owners-approved` in `OWNERS`
+
+The `approverin:already-approved-by_owners` can be fine-grained enabled or disabled using
+`auto-owners-approved` in `OWNERS`, and more narrowly on individual matchers.
+When both are present, the matcher value wins for the files that matcher selects.
+See the examples in [config.md](./config.md#auto-owners-approved).
+
+Details on the `auto-owners-approved` behaviour can be
+found [here](./config.md#auto-owners-approved).
diff --git a/owners/src/test/java/com/googlesource/gerrit/owners/AlreadyApprovedByCopyConditionIT.java b/owners/src/test/java/com/googlesource/gerrit/owners/AlreadyApprovedByCopyConditionIT.java
index ec0e92e..70ded46 100644
--- a/owners/src/test/java/com/googlesource/gerrit/owners/AlreadyApprovedByCopyConditionIT.java
+++ b/owners/src/test/java/com/googlesource/gerrit/owners/AlreadyApprovedByCopyConditionIT.java
@@ -33,7 +33,7 @@
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Permission;
@@ -63,10 +63,14 @@
 
   private TestAccount FRONTEND_FILES_OWNER;
   private TestAccount BACKEND_FILES_OWNER;
+  private TestAccount NON_OWNER;
 
+  private static final String BACKEND_OWNED_FILE_PATH = "path/to/";
   private static final String FRONTEND_OWNED_FILE = "foo.js";
-  private static final String BACKEND_OWNED_FILE = "foo.java";
+  private static final String BACKEND_OWNED_FILE = BACKEND_OWNED_FILE_PATH + "foo.java";
   private static final String FILE_WITH_NO_OWNERS = "foo.txt";
+  private static final boolean AUTO_OWNERS_APPROVED_ENABLED = true;
+  private static final boolean AUTO_OWNERS_APPROVAL_DISABLED = false;
 
   private static final String FILE_CONTENT =
       IntStream.rangeClosed(1, 10)
@@ -88,6 +92,7 @@
 
     FRONTEND_FILES_OWNER = accountCreator.create("user-frontend");
     BACKEND_FILES_OWNER = accountCreator.create("user-backend");
+    NON_OWNER = accountCreator.create("user-non-owner");
 
     addOwnerFileWithMatchersToRoot(
         Map.of(
@@ -99,105 +104,71 @@
 
   @Test
   public void shouldCopyOwnerApprovalOnlyWhenOwnedFilesAreUnchanged() throws Exception {
-    ChangeIdentifier changeId =
-        changeOperations
-            .newChange()
-            .project(project)
-            .file(FRONTEND_OWNED_FILE)
-            .content("some frontend change")
-            .create();
+    ChangeIdentifier changeId = createChange(FRONTEND_OWNED_FILE, "some frontend change");
 
     vote(FRONTEND_FILES_OWNER, changeId.toString(), 2);
     vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
 
-    changeOperations
-        .change(changeId)
-        .newPatchset()
-        .file(BACKEND_OWNED_FILE)
-        .content("some java content")
-        .create();
+    createPatchSet(changeId, BACKEND_OWNED_FILE, "some java content");
 
-    ChangeInfo c = detailedChange(changeId.toString());
-    assertVotes(c, FRONTEND_FILES_OWNER, 2);
-    assertVotes(c, BACKEND_FILES_OWNER, 0);
+    assertVote(changeId, FRONTEND_FILES_OWNER, 2);
+    assertVote(changeId, BACKEND_FILES_OWNER, 0);
   }
 
   @Test
   public void shouldNotCopyApprovalForOwnerWhenNoOwnedFileExists() throws Exception {
-    ChangeIdentifier changeId =
-        changeOperations
-            .newChange()
-            .project(project)
-            .file(FILE_WITH_NO_OWNERS)
-            .content("file with no owners")
-            .create();
+    ChangeIdentifier changeId = createChange(FILE_WITH_NO_OWNERS, "file with no owners");
 
     vote(FRONTEND_FILES_OWNER, changeId.toString(), 2);
 
-    changeOperations
-        .change(changeId)
-        .newPatchset()
-        .file(FILE_WITH_NO_OWNERS)
-        .content("updated text")
-        .create();
+    createPatchSet(changeId, FILE_WITH_NO_OWNERS, "updated text");
 
-    ChangeInfo c = detailedChange(changeId.toString());
-
-    assertVotes(c, FRONTEND_FILES_OWNER, 0);
+    assertVote(changeId, FRONTEND_FILES_OWNER, 0);
   }
 
   @Test
   public void shouldNotCopyApprovalWhenOwnedFilesAreIntroducedInLaterPatchSet() throws Exception {
-    ChangeIdentifier changeId =
-        changeOperations
-            .newChange()
-            .project(project)
-            .file(FILE_WITH_NO_OWNERS)
-            .content("file with no owners")
-            .create();
+    ChangeIdentifier changeId = createChange(FILE_WITH_NO_OWNERS, "file with no owners");
+
+    vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
+
+    createPatchSet(changeId, BACKEND_OWNED_FILE, "some java content");
+
+    assertVote(changeId, BACKEND_FILES_OWNER, 0);
+  }
+
+  @Test
+  public void shouldCopyApprovalWhenOnlyCommitMessageChangesInNextPatchSet() throws Exception {
+    String ownedFile = "file.txt";
+    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER));
+
+    ChangeIdentifier changeId = createChange(ownedFile, "java content");
 
     vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
 
     changeOperations
         .change(changeId)
         .newPatchset()
-        .file(BACKEND_OWNED_FILE)
-        .content("some java content")
+        .commitMessage("Updated commit message")
         .create();
 
-    ChangeInfo c = detailedChange(changeId.toString());
-
-    assertVotes(c, BACKEND_FILES_OWNER, 0);
+    assertVote(changeId, BACKEND_FILES_OWNER, 2);
   }
 
   @Test
   public void shouldNotCopyApprovalWhenOwnedFileIsDeleted() throws Exception {
-    ChangeIdentifier changeId =
-        changeOperations
-            .newChange()
-            .project(project)
-            .file(BACKEND_OWNED_FILE)
-            .content("java content")
-            .create();
+    ChangeIdentifier changeId = createChange(BACKEND_OWNED_FILE, "java content");
 
     vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
 
     changeOperations.change(changeId).newPatchset().file(BACKEND_OWNED_FILE).delete().create();
 
-    ChangeInfo c = detailedChange(changeId.toString());
-
-    assertVotes(c, BACKEND_FILES_OWNER, 0);
+    assertVote(changeId, BACKEND_FILES_OWNER, 0);
   }
 
   @Test
   public void shouldNotCopyApprovalWhenOwnedFileIsRenamedToOwnedFile() throws Exception {
-    ChangeIdentifier changeId =
-        changeOperations
-            .newChange()
-            .project(project)
-            .file(BACKEND_OWNED_FILE)
-            .content("java content")
-            .create();
+    ChangeIdentifier changeId = createChange(BACKEND_OWNED_FILE, "java content");
 
     vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
 
@@ -208,20 +179,12 @@
         .renameTo("renamed-" + BACKEND_OWNED_FILE)
         .create();
 
-    ChangeInfo c = detailedChange(changeId.toString());
-
-    assertVotes(c, BACKEND_FILES_OWNER, 0);
+    assertVote(changeId, BACKEND_FILES_OWNER, 0);
   }
 
   @Test
   public void shouldNotCopyApprovalWhenOwnedFileIsRenamedToNonOwnedFile() throws Exception {
-    ChangeIdentifier changeId =
-        changeOperations
-            .newChange()
-            .project(project)
-            .file(BACKEND_OWNED_FILE)
-            .content("java content")
-            .create();
+    ChangeIdentifier changeId = createChange(BACKEND_OWNED_FILE, "java content");
 
     vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
 
@@ -232,9 +195,7 @@
         .renameTo(FILE_WITH_NO_OWNERS)
         .create();
 
-    ChangeInfo c = detailedChange(changeId.toString());
-
-    assertVotes(c, BACKEND_FILES_OWNER, 0);
+    assertVote(changeId, BACKEND_FILES_OWNER, 0);
   }
 
   @Test
@@ -250,8 +211,7 @@
 
     rebaseChangeOn(amendL3.getChangeId(), amendL7.getCommit().getId());
 
-    ChangeInfo c = detailedChange(amendL3.getChangeId());
-    assertVotes(c, BACKEND_FILES_OWNER, 2);
+    assertVote(amendL3.getChangeId(), BACKEND_FILES_OWNER, 2);
   }
 
   @Test
@@ -300,8 +260,7 @@
             amendL3.getChangeId(), "Rebased with changes", "unrelated.txt", "Unrelated change");
     rebasedL30WithUnrelatedChanges.assertOkStatus();
 
-    ChangeInfo c = detailedChange(amendL3.getChangeId());
-    assertVotes(c, BACKEND_FILES_OWNER, 2);
+    assertVote(amendL3.getChangeId(), BACKEND_FILES_OWNER, 2);
   }
 
   @Test
@@ -327,8 +286,7 @@
             "Line 1\n");
     rebasedL30PlusOtherChangesToOwnedFile.assertOkStatus();
 
-    ChangeInfo c = detailedChange(amendL3.getChangeId());
-    assertVotes(c, FRONTEND_FILES_OWNER, 0);
+    assertVote(amendL3.getChangeId(), FRONTEND_FILES_OWNER, 0);
   }
 
   @Test
@@ -353,8 +311,7 @@
             FILE_CONTENT.replace("Line 5\n", "Line five\n"));
     rebasedL3WithUnrelatedChanges.assertOkStatus();
 
-    ChangeInfo c = detailedChange(amendL3.getChangeId());
-    assertVotes(c, BACKEND_FILES_OWNER, 0);
+    assertVote(amendL3.getChangeId(), BACKEND_FILES_OWNER, 0);
   }
 
   @Test
@@ -362,38 +319,405 @@
     createInitialContentFor(BACKEND_OWNED_FILE);
     ObjectId latestMasterCommitId = createInitialContentFor("another-owned.java");
 
-    ChangeIdentifier removedFileChangeId =
-        changeOperations.newChange().project(project).file("another-owned.java").delete().create();
+    ChangeIdentifier removedFileChangeId = newChangeDeletingFile("another-owned.java");
 
     vote(BACKEND_FILES_OWNER, removedFileChangeId.toString(), 2);
 
     testRepo.reset(latestMasterCommitId);
-    ChangeIdentifier baseRemovedFileChangeId =
-        changeOperations.newChange().project(project).file(BACKEND_OWNED_FILE).delete().create();
+    ChangeIdentifier baseRemovedFileChangeId = newChangeDeletingFile(BACKEND_OWNED_FILE);
 
     rebaseChangeOn(removedFileChangeId.toString(), commitOf(baseRemovedFileChangeId));
 
-    ChangeInfo c = detailedChange(removedFileChangeId.toString());
-    assertVotes(c, BACKEND_FILES_OWNER, 2);
+    assertVote(removedFileChangeId, BACKEND_FILES_OWNER, 2);
   }
 
   @Test
   public void shouldCopyApprovalWhenRemovingOwnedFileAndRebaseOnChangeThatRemovesTheSameFile()
       throws Exception {
     ObjectId initialCommitId = createInitialContentFor(BACKEND_OWNED_FILE);
-    ChangeIdentifier removedFileChangeId =
-        changeOperations.newChange().project(project).file(BACKEND_OWNED_FILE).delete().create();
+    ChangeIdentifier removedFileChangeId = newChangeDeletingFile(BACKEND_OWNED_FILE);
 
     vote(BACKEND_FILES_OWNER, removedFileChangeId.toString(), 2);
 
     testRepo.reset(initialCommitId);
-    ChangeIdentifier baseRemovedFileChangeId =
-        changeOperations.newChange().project(project).file(BACKEND_OWNED_FILE).delete().create();
+    ChangeIdentifier baseRemovedFileChangeId = newChangeDeletingFile(BACKEND_OWNED_FILE);
 
     rebaseChangeOn(removedFileChangeId.toString(), commitOf(baseRemovedFileChangeId));
 
-    ChangeInfo c = detailedChange(removedFileChangeId.toString());
-    assertVotes(c, BACKEND_FILES_OWNER, 2);
+    assertVote(removedFileChangeId, BACKEND_FILES_OWNER, 2);
+  }
+
+  @Test
+  public void shouldCopyApprovalWhenAllModifiedFilesAreOwnedAndAutoOwnersApprovedIsDefault()
+      throws Exception {
+    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER));
+
+    ChangeIdentifier changeId =
+        createChange(BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "java content");
+
+    vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
+    createPatchSet(changeId, BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "updated java content");
+
+    assertVote(changeId, BACKEND_FILES_OWNER, 2);
+  }
+
+  @Test
+  public void
+      shouldCopyApprovalWhenAllModifiedFilesAreOwnedAndAutoOwnersApprovedIsDisabledOnRepoAndEnabledOnRoot()
+          throws Exception {
+    pushOwnersToRef(
+        "inherited: true\nauto-owners-approved: false\n", "OWNERS", RefNames.REFS_CONFIG);
+
+    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVED_ENABLED));
+
+    ChangeIdentifier changeId =
+        createChange(BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "java content");
+
+    vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
+    createPatchSet(changeId, BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "updated java content");
+
+    assertVote(changeId, BACKEND_FILES_OWNER, 2);
+  }
+
+  @Test
+  public void
+      shouldCopyApprovalWhenAllModifiedFilesAreOwnedAndAutoOwnersApprovedIsDisabledOnRootAndEnabledOnPath()
+          throws Exception {
+    pushOwnersToRef(
+        "inherited: true\nauto-owners-approved: false\n", "OWNERS", RefNames.fullName("master"));
+
+    pushOwnersToRef(
+        ownersConfigFor(BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVED_ENABLED),
+        BACKEND_OWNED_FILE_PATH + "OWNERS",
+        RefNames.fullName("master"));
+
+    ChangeIdentifier changeId =
+        createChange(BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "java content");
+
+    vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
+    createPatchSet(changeId, BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "updated java content");
+
+    assertVote(changeId, BACKEND_FILES_OWNER, 2);
+  }
+
+  @Test
+  public void shouldNotCopyApprovalWhenAllModifiedFilesAreOwnedButApproverIsNotChangeOwner()
+      throws Exception {
+    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER));
+
+    ChangeIdentifier changeId = createChange(NON_OWNER.id(), BACKEND_OWNED_FILE, "java content");
+
+    vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
+    createPatchSet(changeId, BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "updated java content");
+
+    assertVote(changeId, BACKEND_FILES_OWNER, 0);
+  }
+
+  @Test
+  public void shouldNotCopyApprovalWhenAllModifiedFilesAreOwnedButUploaderNotOwner()
+      throws Exception {
+    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER));
+
+    ChangeIdentifier changeId =
+        createChange(BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "java content");
+
+    vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
+    createPatchSet(changeId, NON_OWNER.id(), BACKEND_OWNED_FILE, "updated java content");
+
+    assertVote(changeId, BACKEND_FILES_OWNER, 0);
+  }
+
+  @Test
+  public void shouldNotCopyApprovalWhenAllModifiedFilesAreOwnedButAutoOwnersApprovedIsFalse()
+      throws Exception {
+    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVAL_DISABLED));
+
+    ChangeIdentifier changeId =
+        createChange(BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "java content");
+
+    vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
+    createPatchSet(changeId, BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "updated java content");
+
+    assertVote(changeId, BACKEND_FILES_OWNER, 0);
+  }
+
+  @Test
+  public void shouldCopyApprovalWhenAllModifiedFilesAreOwnedAndAutoOwnersApprovedIsTrue()
+      throws Exception {
+    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVED_ENABLED));
+
+    ChangeIdentifier changeId =
+        createChange(BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "java content");
+
+    vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
+    createPatchSet(changeId, BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "updated java content");
+
+    assertVote(changeId, BACKEND_FILES_OWNER, 2);
+  }
+
+  @Test
+  public void shouldCopyApprovalWhenAutoOwnersApprovedIsFalseButOwnedEditsAreRebaseOnly()
+      throws Exception {
+    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVAL_DISABLED));
+
+    ObjectId initialCommitId = createInitialContentFor(BACKEND_OWNED_FILE);
+    PushOneCommit.Result amendL3 =
+        createChangeWithReplacedContent(BACKEND_OWNED_FILE, "Line 3\n", "Line three\n");
+    vote(BACKEND_FILES_OWNER, amendL3.getChangeId(), 2);
+
+    testRepo.reset(initialCommitId);
+    PushOneCommit.Result amendL7 =
+        createChangeWithReplacedContent(BACKEND_OWNED_FILE, "Line 7\n", "Line seven\n");
+
+    rebaseChangeOn(amendL3.getChangeId(), amendL7.getCommit().getId());
+
+    assertVote(amendL3.getChangeId(), BACKEND_FILES_OWNER, 2);
+  }
+
+  @Test
+  public void shouldCopyApprovalWhenMatcherEnablesAutoOwnersApproved() throws Exception {
+    pushOwnersToMaster(
+        matcherOwnersConfig(
+            suffixMatcherConfig(".java", BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVED_ENABLED)));
+
+    ChangeIdentifier changeId =
+        createChange(BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "java content");
+    vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
+
+    createPatchSet(changeId, BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "updated java content");
+
+    assertVote(changeId, BACKEND_FILES_OWNER, 2);
+  }
+
+  @Test
+  public void shouldNotCopyApprovalWhenMatcherDisablesAutoOwnersApproved() throws Exception {
+    pushOwnersToMaster(
+        matcherOwnersConfig(
+            suffixMatcherConfig(".java", BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVAL_DISABLED)));
+
+    ChangeIdentifier changeId =
+        createChange(BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "java content");
+    vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
+
+    createPatchSet(changeId, BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "updated java content");
+
+    assertVote(changeId, BACKEND_FILES_OWNER, 0);
+  }
+
+  @Test
+  public void shouldCopyApprovalWhenMatcherOverridesRootFalse() throws Exception {
+    pushOwnersToMaster(
+        matcherOwnersConfig(
+            AUTO_OWNERS_APPROVAL_DISABLED,
+            suffixMatcherConfig(".java", BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVED_ENABLED)));
+
+    ChangeIdentifier changeId =
+        createChange(BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "java content");
+    vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
+
+    createPatchSet(changeId, BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "updated java content");
+
+    assertVote(changeId, BACKEND_FILES_OWNER, 2);
+  }
+
+  @Test
+  public void shouldNotCopyApprovalWhenMatcherOverridesRootTrue() throws Exception {
+    pushOwnersToMaster(
+        matcherOwnersConfig(
+            AUTO_OWNERS_APPROVED_ENABLED,
+            suffixMatcherConfig(".java", BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVAL_DISABLED)));
+
+    ChangeIdentifier changeId =
+        createChange(BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "java content");
+    vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
+
+    createPatchSet(changeId, BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "updated java content");
+
+    assertVote(changeId, BACKEND_FILES_OWNER, 0);
+  }
+
+  @Test
+  public void shouldCopyApprovalWhenChildMatcherInheritsParentTrue() throws Exception {
+    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVED_ENABLED));
+    pushOwnersToRef(
+        matcherOwnersConfig(suffixMatcherConfig(".java", BACKEND_FILES_OWNER)),
+        BACKEND_OWNED_FILE_PATH + "OWNERS",
+        RefNames.fullName("master"));
+
+    ChangeIdentifier changeId =
+        createChange(BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "java content");
+    vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
+
+    createPatchSet(changeId, BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "updated java content");
+
+    assertVote(changeId, BACKEND_FILES_OWNER, 2);
+  }
+
+  @Test
+  public void shouldNotCopyApprovalWhenChildMatcherInheritsParentFalse() throws Exception {
+    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVAL_DISABLED));
+    pushOwnersToRef(
+        matcherOwnersConfig(suffixMatcherConfig(".java", BACKEND_FILES_OWNER)),
+        BACKEND_OWNED_FILE_PATH + "OWNERS",
+        RefNames.fullName("master"));
+
+    ChangeIdentifier changeId =
+        createChange(BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "java content");
+    vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
+
+    createPatchSet(changeId, BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "updated java content");
+
+    assertVote(changeId, BACKEND_FILES_OWNER, 0);
+  }
+
+  @Test
+  public void shouldCopyApprovalWhenChildMatcherOverridesParentFalse() throws Exception {
+    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVAL_DISABLED));
+    pushOwnersToRef(
+        matcherOwnersConfig(
+            suffixMatcherConfig(".java", BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVED_ENABLED)),
+        BACKEND_OWNED_FILE_PATH + "OWNERS",
+        RefNames.fullName("master"));
+
+    ChangeIdentifier changeId =
+        createChange(BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "java content");
+    vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
+
+    createPatchSet(changeId, BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "updated java content");
+
+    assertVote(changeId, BACKEND_FILES_OWNER, 2);
+  }
+
+  @Test
+  public void shouldNotCopyApprovalWhenChildMatcherOverridesParentTrue() throws Exception {
+    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVED_ENABLED));
+    pushOwnersToRef(
+        matcherOwnersConfig(
+            suffixMatcherConfig(".java", BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVAL_DISABLED)),
+        BACKEND_OWNED_FILE_PATH + "OWNERS",
+        RefNames.fullName("master"));
+
+    ChangeIdentifier changeId =
+        createChange(BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "java content");
+    vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
+
+    createPatchSet(changeId, BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "updated java content");
+
+    assertVote(changeId, BACKEND_FILES_OWNER, 0);
+  }
+
+  @Test
+  public void shouldCopyApprovalWhenChildMatcherOverridesParentMatcherFalse() throws Exception {
+    pushOwnersToMaster(
+        matcherOwnersConfig(
+            suffixMatcherConfig(".java", BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVAL_DISABLED)));
+    pushOwnersToRef(
+        matcherOwnersConfig(
+            suffixMatcherConfig(".java", BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVED_ENABLED)),
+        BACKEND_OWNED_FILE_PATH + "OWNERS",
+        RefNames.fullName("master"));
+
+    ChangeIdentifier changeId =
+        createChange(BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "java content");
+    vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
+
+    createPatchSet(changeId, BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "updated java content");
+
+    assertVote(changeId, BACKEND_FILES_OWNER, 2);
+  }
+
+  @Test
+  public void shouldNotCopyApprovalWhenChildMatcherOverridesParentMatcherTrue() throws Exception {
+    pushOwnersToMaster(
+        matcherOwnersConfig(
+            suffixMatcherConfig(".java", BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVED_ENABLED)));
+    pushOwnersToRef(
+        matcherOwnersConfig(
+            suffixMatcherConfig(".java", BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVAL_DISABLED)),
+        BACKEND_OWNED_FILE_PATH + "OWNERS",
+        RefNames.fullName("master"));
+
+    ChangeIdentifier changeId =
+        createChange(BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "java content");
+    vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
+
+    createPatchSet(changeId, BACKEND_FILES_OWNER.id(), BACKEND_OWNED_FILE, "updated java content");
+
+    assertVote(changeId, BACKEND_FILES_OWNER, 0);
+  }
+
+  @Test
+  public void shouldNotCopyApprovalWhenPathAndMatcherFilesDoNotAllAllowAutoOwnersApproved()
+      throws Exception {
+    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVAL_DISABLED));
+    pushOwnersToRef(
+        matcherOwnersConfig(
+            suffixMatcherConfig(".java", BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVED_ENABLED)),
+        BACKEND_OWNED_FILE_PATH + "OWNERS",
+        RefNames.fullName("master"));
+
+    String directoryOwnedFile = "directory-owned.txt";
+    ChangeIdentifier changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(BACKEND_FILES_OWNER.id())
+            .file(directoryOwnedFile)
+            .content("foo")
+            .file(BACKEND_OWNED_FILE)
+            .content("bar")
+            .create();
+
+    vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .uploader(BACKEND_FILES_OWNER.id())
+        .file(directoryOwnedFile)
+        .content("updated foo")
+        .file(BACKEND_OWNED_FILE)
+        .content("updated bar")
+        .create();
+
+    assertVote(changeId, BACKEND_FILES_OWNER, 0);
+  }
+
+  @Test
+  public void shouldCopyApprovalWhenPathAndMatcherFilesAllAllowAutoOwnersApproved()
+      throws Exception {
+    pushOwnersToMaster(ownersConfigFor(BACKEND_FILES_OWNER, AUTO_OWNERS_APPROVED_ENABLED));
+    pushOwnersToRef(
+        matcherOwnersConfig(
+            AUTO_OWNERS_APPROVED_ENABLED, suffixMatcherConfig(".java", BACKEND_FILES_OWNER)),
+        BACKEND_OWNED_FILE_PATH + "OWNERS",
+        RefNames.fullName("master"));
+
+    String directoryOwnedFile = "directory-owned.txt";
+    ChangeIdentifier changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .owner(BACKEND_FILES_OWNER.id())
+            .file(directoryOwnedFile)
+            .content("foo")
+            .file(BACKEND_OWNED_FILE)
+            .content("bar")
+            .create();
+
+    vote(BACKEND_FILES_OWNER, changeId.toString(), 2);
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .uploader(BACKEND_FILES_OWNER.id())
+        .file(directoryOwnedFile)
+        .content("updated foo")
+        .file(BACKEND_OWNED_FILE)
+        .content("updated bar")
+        .create();
+
+    assertVote(changeId, BACKEND_FILES_OWNER, 2);
   }
 
   private PushOneCommit.Result createChangeWithReplacedContent(
@@ -416,8 +740,7 @@
   }
 
   private ObjectId commitOf(ChangeIdentifier changeId) throws Exception {
-    RevisionInfo currentRevision =
-        gApi.changes().id(changeId).get().getCurrentRevision();
+    RevisionInfo currentRevision = gApi.changes().id(changeId).get().getCurrentRevision();
     return ObjectId.fromString(currentRevision.commit.commit);
   }
 
@@ -446,6 +769,84 @@
     assertThat(vote).isEqualTo(expectedVote);
   }
 
+  private void assertVote(ChangeIdentifier changeId, TestAccount user, int expectedVote)
+      throws Exception {
+    assertVotes(detailedChange(changeId.toString()), user, expectedVote);
+  }
+
+  private void assertVote(String changeId, TestAccount user, int expectedVote) throws Exception {
+    assertVotes(detailedChange(changeId), user, expectedVote);
+  }
+
+  private ChangeIdentifier createChange(String file, String content) throws Exception {
+    return changeOperations.newChange().project(project).file(file).content(content).create();
+  }
+
+  private ChangeIdentifier createChange(Account.Id owner, String file, String content)
+      throws Exception {
+    return changeOperations
+        .newChange()
+        .project(project)
+        .owner(owner)
+        .file(file)
+        .content(content)
+        .create();
+  }
+
+  private ChangeIdentifier newChangeDeletingFile(String file) throws Exception {
+    return changeOperations.newChange().project(project).file(file).delete().create();
+  }
+
+  private void createPatchSet(ChangeIdentifier changeId, String file, String content)
+      throws Exception {
+    changeOperations.change(changeId).newPatchset().file(file).content(content).create();
+  }
+
+  private void createPatchSet(
+      ChangeIdentifier changeId, Account.Id uploader, String file, String content)
+      throws Exception {
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .uploader(uploader)
+        .file(file)
+        .content(content)
+        .create();
+  }
+
+  private String ownersConfigFor(TestAccount owner) {
+    return String.format("inherited: true\nowners:\n- %s\n", owner.username());
+  }
+
+  private String ownersConfigFor(TestAccount owner, boolean autoOwnersApproved) {
+    return String.format(
+        "inherited: true\nauto-owners-approved: %s\nowners:\n- %s\n",
+        autoOwnersApproved, owner.username());
+  }
+
+  private String matcherOwnersConfig(String... matchers) {
+    return matcherOwnersConfig(null, matchers);
+  }
+
+  private String matcherOwnersConfig(Boolean autoOwnersApproved, String... matchers) {
+    String rootAutoOwnersApproved =
+        autoOwnersApproved == null
+            ? ""
+            : String.format("auto-owners-approved: %s\n", autoOwnersApproved);
+    return String.format(
+        "inherited: true\n%smatchers:\n%s", rootAutoOwnersApproved, String.join("", matchers));
+  }
+
+  private String suffixMatcherConfig(String suffix, TestAccount owner, boolean autoOwnersApproved) {
+    return String.format(
+        "- suffix: %s\n  auto-owners-approved: %s\n  owners:\n   - %s\n",
+        suffix, autoOwnersApproved, owner.username());
+  }
+
+  private String suffixMatcherConfig(String suffix, TestAccount owner) {
+    return String.format("- suffix: %s\n  owners:\n   - %s\n", suffix, owner.username());
+  }
+
   private void vote(TestAccount user, String changeId, int vote) throws Exception {
     requestScopeOperations.setApiUser(user.id());
     gApi.changes()
@@ -493,10 +894,38 @@
     pushOwnersToMaster(String.format("inherited: %s\nmatchers:\n%s", true, matchersYaml));
   }
 
+  private void addOwnerFileWithMatchersToRoot(
+      Map<String, List<TestAccount>> ownersBySuffix, boolean autoOwnersApproved) throws Exception {
+    String matchersYaml =
+        ownersBySuffix.entrySet().stream()
+            .map(
+                entry -> {
+                  String suffix = entry.getKey();
+                  List<TestAccount> users = entry.getValue();
+                  String ownersYaml =
+                      users.stream()
+                          .map(user -> String.format("   - %s\n", user.username()))
+                          .collect(joining());
+                  return String.format("- suffix: %s\n  owners:\n%s", suffix, ownersYaml);
+                })
+            .collect(joining());
+
+    pushOwnersToMaster(
+        String.format(
+            "inherited: %s\nauto-owners-approved: %s\nmatchers:\n%s",
+            true, autoOwnersApproved, matchersYaml));
+  }
+
   private void pushOwnersToMaster(String owners) throws Exception {
+    pushOwnersToRef(owners, "OWNERS", RefNames.fullName("master"));
+  }
+
+  private void pushOwnersToRef(String owners, String ownerPath, String ref) throws Exception {
+    testRepo.git().fetch().setRemote("origin").setRefSpecs(ref + ":" + ref).call();
+    testRepo.reset(ref);
     pushFactory
-        .create(admin.newIdent(), testRepo, "Add OWNER file", "OWNERS", owners)
-        .to(RefNames.fullName("master"))
+        .create(admin.newIdent(), testRepo, "Add OWNER file", ownerPath, owners)
+        .to(ref)
         .assertOkStatus();
   }
 }
diff --git a/owners/src/test/java/com/googlesource/gerrit/owners/OwnersSubmitRequirementITAbstract.java b/owners/src/test/java/com/googlesource/gerrit/owners/OwnersSubmitRequirementITAbstract.java
index e7baba6..c318fe7 100644
--- a/owners/src/test/java/com/googlesource/gerrit/owners/OwnersSubmitRequirementITAbstract.java
+++ b/owners/src/test/java/com/googlesource/gerrit/owners/OwnersSubmitRequirementITAbstract.java
@@ -39,7 +39,6 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.SubmitRecordInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.inject.Inject;
 import com.googlesource.gerrit.owners.common.LabelDefinition;
 import java.util.Collection;
diff --git a/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersITAbstract.java b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersITAbstract.java
index 7682434..ad46da6 100644
--- a/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersITAbstract.java
+++ b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersITAbstract.java
@@ -18,6 +18,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.Sets;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
@@ -44,7 +45,6 @@
 import com.googlesource.gerrit.owners.restapi.GetFilesOwners.LabelNotFoundException;
 import java.util.Map;
 import javax.servlet.http.HttpServletResponse;
-import org.apache.commons.compress.utils.Sets;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.transport.FetchResult;
diff --git a/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersSubmitRequirementsIT.java b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersSubmitRequirementsIT.java
index c316757..3915997 100644
--- a/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersSubmitRequirementsIT.java
+++ b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersSubmitRequirementsIT.java
@@ -19,7 +19,7 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
-import com.google.gerrit.acceptance.TestAccount;
+import com.google.common.collect.Sets;
 import com.google.gerrit.acceptance.TestPlugin;
 import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -37,7 +37,6 @@
 import java.nio.file.Files;
 import java.nio.file.StandardOpenOption;
 import java.util.Map;
-import org.apache.commons.compress.utils.Sets;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
diff --git a/owners/web/eslint.config.js b/owners/web/eslint.config.js
new file mode 100644
index 0000000..69ddb56
--- /dev/null
+++ b/owners/web/eslint.config.js
@@ -0,0 +1,18 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+const {defineConfig} = require('eslint/config');
+
+// eslint-disable-next-line no-undef
+__plugindir = 'owners/web';
+
+const gerritEslint = require('../../eslint.config.js');
+
+module.exports = defineConfig([
+  {
+    extends: [gerritEslint],
+  },
+]);