Check included files in OwnersValidator

* OwnersValidator now checks included files of any name
  that could be in a given CL or the repository.
  * Previously only changed files of the name "OWNERS" or specified
    ownersFileName are checked.
  * Included files are looked up in a commit's changed file list first,
    then in the repository.
  * Included files in a different CL to be submitted together are
    not checked yet.
  * Use inner class OwnersValidator.Checker to keep all data
    during validation check of one commit event.
  * Use qualified names like p1:d1/f1.txt in error messages when
    source file f1.txt is not in the same repository.
* OwnersValidatorTest is renamed to OwnersValidatorIT
  and inherits from LightweightPluginDaemonTest
  to create files in multiple repositories.

Change-Id: If693e31048045f36633fd9f61d7afc9ee489b052
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersValidator.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersValidator.java
index c36afcb..1b890a8 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersValidator.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersValidator.java
@@ -14,6 +14,7 @@
 
 package com.googlesource.gerrit.plugins.findowners;
 
+import static com.google.common.base.Strings.isNullOrEmpty;
 import static com.googlesource.gerrit.plugins.findowners.Config.OWNERS;
 import static com.googlesource.gerrit.plugins.findowners.Config.OWNERS_FILE_NAME;
 import static com.googlesource.gerrit.plugins.findowners.Config.REJECT_ERROR_IN_OWNERS;
@@ -31,6 +32,7 @@
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
@@ -38,6 +40,7 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import java.io.BufferedReader;
+import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
@@ -49,6 +52,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.diff.RawText;
 import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
@@ -85,14 +89,17 @@
 
   private final String pluginName;
   private final PluginConfigFactory cfgFactory;
+  private final GitRepositoryManager repoManager;
   private final Emails emails;
 
   @Inject
   OwnersValidator(
       @PluginName String pluginName,
       PluginConfigFactory cfgFactory,
+      GitRepositoryManager repoManager,
       Emails emails) {
     this.pluginName = pluginName;
+    this.repoManager = repoManager;
     this.cfgFactory = cfgFactory;
     this.emails = emails;
   }
@@ -121,169 +128,269 @@
   }
 
   @Override
-  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent event)
       throws CommitValidationException {
-    List<CommitValidationMessage> messages = new ArrayList<>();
+    Checker checker = new Checker(event, false);
     try {
-      Project.NameKey project = receiveEvent.project.getNameKey();
+      Project.NameKey project = event.project.getNameKey();
       PluginConfig cfg = cfgFactory.getFromProjectConfigWithInheritance(project, pluginName);
       if (isActive(cfg)) {
-        String name = getOwnersFileName(project);
-        messages = performValidation(receiveEvent.commit, receiveEvent.revWalk, name, false);
+        checker.check(getOwnersFileName(project));
       }
     } catch (NoSuchProjectException | IOException e) {
       throw new CommitValidationException("failed to check owners files", e);
     }
-    if (hasError(messages)) {
-      add(messages, "See OWNERS file syntax document at "
+    if (checker.hasError()) {
+      checker.addError("See OWNERS file syntax document at "
           + "https://gerrit.googlesource.com/plugins/find-owners/+/"
-          + "master/src/main/resources/Documentation/syntax.md", true);
-      throw new CommitValidationException("found invalid owners file", messages);
+          + "master/src/main/resources/Documentation/syntax.md");
+      throw new CommitValidationException("found invalid owners file", checker.messages);
     }
-    return messages;
+    return checker.messages;
   }
 
-  @VisibleForTesting
-  List<CommitValidationMessage> performValidation(
-      RevCommit c, RevWalk revWalk, String ownersFileName, boolean verbose) throws IOException {
-    // Collect all messages from all files.
-    List<CommitValidationMessage> messages = new ArrayList<>();
+  class Checker {
+    // An inner class to keep needed data specific to one commit event.
+    CommitReceivedEvent event;
+    boolean verbose;
+    List<CommitValidationMessage> messages;
+    Map<String, ObjectId> allFiles; // changedFilePath => ObjectId
+    Map<String, String> readFiles; // project:file => content
+    Set<String> checkedFiles; // project:file
     // Collect all email addresses from all files and check each address only once.
-    Map<String, Set<String>> email2lines = new HashMap<>();
-    Map<String, ObjectId> content = getChangedOwners(c, revWalk, ownersFileName);
-    for (String path : content.keySet()) {
-      ObjectLoader ol = revWalk.getObjectReader().open(content.get(path));
-      try (InputStream in = ol.openStream()) {
-        if (RawText.isBinary(in)) {
-          add(messages, path + " is a binary file", true); // OWNERS files cannot be binary
-          continue;
-        }
-      }
-      checkFile(messages, email2lines, path, ol, verbose);
-    }
-    checkEmails(messages, emails, email2lines, verbose);
-    return messages;
-  }
+    Map<String, Set<String>> email2lines;
 
-  private static void checkEmails(
-      List<CommitValidationMessage> messages,
-      Emails emails,
-      Map<String, Set<String>> email2lines,
-      boolean verbose) {
-    List<String> owners = new ArrayList<>(email2lines.keySet());
-    if (verbose) {
-      for (String owner : owners) {
-        add(messages, "owner: " + owner, false);
+    Checker(CommitReceivedEvent event, boolean verbose) {
+      this.event = event;
+      this.verbose = verbose;
+      messages = new ArrayList<>();
+      readFiles = new HashMap<>();
+      checkedFiles = new HashSet<>();
+      email2lines = new HashMap<>();
+      try {
+        allFiles = getChangedFiles(event.commit, event.revWalk);
+      } catch (Exception e) {
+        allFiles = new HashMap<>();
+        addError("getChangedFiles failed.");
       }
     }
-    if (emails == null || owners.isEmpty()) {
-      return;
-    }
-    String[] ownerEmailsAsArray = new String[owners.size()];
-    owners.toArray(ownerEmailsAsArray);
-    try {
-      Multimap<String, Account.Id> email2ids = emails.getAccountsFor(ownerEmailsAsArray);
-      for (String owner : ownerEmailsAsArray) {
-        boolean wrongEmail = (email2ids == null);
-        if (!wrongEmail) {
-          try {
-            Collection<Account.Id> ids = email2ids.get(owner);
-            wrongEmail = (ids == null || ids.isEmpty());
-          } catch (Exception e) {
-            wrongEmail = true;
+
+    @VisibleForTesting
+    void check(String ownersFileName) throws IOException {
+      Map<String, ObjectId> ownerFiles =
+          allFiles.entrySet().stream()
+          .filter(e -> ownersFileName.equals(new File(e.getKey()).getName()))
+          .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue()));
+      String projectName = event.project.getName();
+      for (String path : ownerFiles.keySet()) {
+        String key = projectName + ":" + path;
+        ObjectLoader ol = event.revWalk.getObjectReader().open(ownerFiles.get(path));
+        try (InputStream in = ol.openStream()) {
+          if (RawText.isBinary(in)) {
+            addError(path + " is a binary file"); // OWNERS files cannot be binary
+            continue;
           }
         }
-        if (wrongEmail) {
-          String locations = String.join(" ", email2lines.get(owner));
-          add(messages, "unknown: " + owner + " at " + locations, true);
+        checkedFiles.add(key);
+        checkFile(projectName, path, ol);
+      }
+      checkEmails(emails);
+    }
+
+    void checkEmails(Emails emails) {
+      List<String> owners = new ArrayList<>(email2lines.keySet());
+      if (owners.isEmpty()) {
+        return;
+      }
+      if (verbose) {
+        for (String owner : owners) {
+          addMsg("owner: " + owner);
         }
       }
-    } catch (Exception e) {
-      add(messages, "checkEmails failed.", true);
-    }
-  }
-
-  private static void checkFile(
-      List<CommitValidationMessage> messages,
-      Map<String, Set<String>> email2lines,
-      String path,
-      ObjectLoader ol,
-      boolean verbose)
-      throws IOException {
-    if (verbose) {
-      add(messages, "validate: " + path, false);
-    }
-    try (BufferedReader br =
-        new BufferedReader(new InputStreamReader(ol.openStream(), StandardCharsets.UTF_8))) {
-      int line = 0;
-      for (String l = br.readLine(); l != null; l = br.readLine()) {
-        line++;
-        checkLine(messages, email2lines, path, line, l);
+      if (emails == null) {
+        addError("cannot check owner emails with null Emails cache.");
+      }
+      String[] ownerEmailsAsArray = new String[owners.size()];
+      owners.toArray(ownerEmailsAsArray);
+      try {
+        Multimap<String, Account.Id> email2ids = emails.getAccountsFor(ownerEmailsAsArray);
+        for (String owner : ownerEmailsAsArray) {
+          boolean wrongEmail = (email2ids == null);
+          if (!wrongEmail) {
+            try {
+              Collection<Account.Id> ids = email2ids.get(owner);
+              wrongEmail = (ids == null || ids.isEmpty());
+            } catch (Exception e) {
+              wrongEmail = true;
+            }
+          }
+          if (wrongEmail) {
+            String locations = String.join(" ", email2lines.get(owner));
+            addError("unknown: " + owner + " at " + locations);
+          }
+        }
+      } catch (Exception e) {
+        addError("checkEmails failed.");
       }
     }
-  }
 
-  private static void collectEmail(
-      Map<String, Set<String>> map, String email, String file, int lineNumber) {
-    if (!email.equals("*")) {
-      map.computeIfAbsent(email, (String k) -> new HashSet<>());
-      map.get(email).add(file + ":" + lineNumber);
+    void checkFile(String project, String path, String[] lines) {
+      addVerboseMsg("checking " + path);
+      int num = 0;
+      for (String line : lines) {
+        checkLine(project, path, ++num, line);
+      };
     }
-  }
 
-  private static boolean hasError(List<CommitValidationMessage> messages) {
-    for (CommitValidationMessage m : messages) {
-      if (m.isError()) {
-        return true;
+    void checkFile(String project, String path, String content) {
+      checkFile(project, path, content.split("\\R"));
+    }
+
+    void checkFile(String project, String path, ObjectLoader ol) {
+      try {
+        BufferedReader reader = new BufferedReader(
+            new InputStreamReader(ol.openStream(), StandardCharsets.UTF_8));
+        checkFile(project, path, reader.lines().toArray(String[]::new));
+      } catch (Exception e) {
+        addError("cannot open file: " + path);
       }
     }
-    return false;
-  }
 
-  private static void add(List<CommitValidationMessage> messages, String msg, boolean error) {
-    messages.add(new CommitValidationMessage(msg, error));
-  }
+    private void collectEmail(String email, String project, String file, int lineNumber) {
+      if (!email.equals("*")) {
+        email2lines.computeIfAbsent(email, (String k) -> new HashSet<>());
+        email2lines.get(email).add(qualifiedPath(project, file) + ":" + lineNumber);
+      }
+    }
 
-  private static void checkIncludeOrFile(
-      List<CommitValidationMessage> messages, String path, int num, String line) {
-    // TODO: Check if an included file exists and that it has valid syntax.
-    // An included file could be a new file added by a CL and not in the repository yet
-    add(messages, "unchecked: " + path + ":" + num + ": " + Parser.getIncludeOrFile(line), false);
-  }
-
-  private static void checkLine(
-      List<CommitValidationMessage> messages,
-      Map<String, Set<String>> email2lines,
-      String path,
-      int lineNumber,
-      String line) {
-    String email;
-    String[] emails;
-    if (Parser.isComment(line) || Parser.isNoParent(line)) {
-      // no email address to check
-    } else if ((email = Parser.parseEmail(line)) != null) {
-      collectEmail(email2lines, email, path, lineNumber);
-    } else if ((emails = Parser.parsePerFileOwners(line)) != null) {
-      for (String e : emails) {
-        if (e.startsWith("file:")) {
-          checkIncludeOrFile(messages, path, lineNumber, line);
-        } else if (!e.equals(Parser.TOK_SET_NOPARENT)) {
-          collectEmail(email2lines, e, path, lineNumber);
+    private boolean hasError() {
+      for (CommitValidationMessage m : messages) {
+        if (m.isError()) {
+          return true;
         }
       }
-    } else if (Parser.isInclude(line)) {
-      checkIncludeOrFile(messages, path, lineNumber, line);
-    } else {
-      add(messages, "syntax: " + path + ":" + lineNumber + ": " + line, true);
+      return false;
     }
-  }
+
+    void addError(String msg) {
+      messages.add(new CommitValidationMessage(msg, true));
+    }
+
+    String qualifiedPath(String project, String path) {
+      return event.project.getName().equals(project) ? path : (project + ":" + path);
+    }
+
+    void addSyntaxError(String path, int lineNumber, String line) {
+      addError("syntax: " + path + ":" + lineNumber + ": " + line);
+    }
+
+    void addMsg(String msg) {
+      messages.add(new CommitValidationMessage(msg, false));
+    }
+
+    void addVerboseMsg(String msg) {
+      if (verbose) {
+        addMsg(msg);
+      }
+    }
+
+    String normalizeChangedFilePath(String dir, String file) {
+      try {
+        if (file.startsWith("/")) {
+          file = new File(file).getCanonicalPath();
+        } else {
+          file = new File("/" + dir + "/" + file).getCanonicalPath();
+        }
+      } catch (IOException e) {
+        addError("cannot build file path " + dir + ":" + file);
+      }
+      return file.startsWith("/") ? file.substring(1) : file;
+    }
+
+    /**
+     * Check if an included file exists and with valid syntax.
+     * An included file could be (1) in the current CL, (2) in the same repository,
+     * (3) in a different repository, (4) in another CL.
+     * Case (4) is not checked yet.
+     */
+    void checkIncludeOrFile(String project, String path, int num, String line) {
+      // project is the including file's project, not necessarily the same as CL event's.
+      String directive = Parser.getIncludeOrFile(line);
+      String[] KPF = Parser.parseInclude(project, directive);
+      if (KPF == null || KPF[1] == null || KPF[2] == null) {
+        addSyntaxError(qualifiedPath(project, path), num, line);
+      }
+      String file = KPF[2];
+      String curDir = Util.getParentDir(path);
+      String repoFile = normalizeChangedFilePath(curDir, file);
+      // Check each file only once.
+      String key = KPF[1] + ":" + repoFile;
+      if (checkedFiles.contains(key)) {
+        addVerboseMsg("skip repeated include of " + key);
+        return;
+      }
+      checkedFiles.add(key);
+      if (KPF[1].equals(event.project.getName())) {
+        if (allFiles.get(repoFile) != null) {
+          // Case (1): included file is in current CL.
+          addVerboseMsg("check changed file " + key);
+          try {
+            ObjectLoader ol = event.revWalk.getObjectReader().open(allFiles.get(repoFile));
+            try (InputStream in = ol.openStream()) {
+              if (RawText.isBinary(in)) {
+                addError(path + " is a binary file"); // OWNERS files cannot be binary
+                return;
+              }
+            }
+            checkFile(KPF[1], repoFile, ol);
+          } catch (Exception e) {
+            addError("cannot open changed file: " + path);
+          }
+          return;
+        }
+      }
+      // Included file is in repository or other CL.
+      addVerboseMsg("check repo file " + key);
+      String content = OwnersDb.getRepoFile(readFiles, repoManager, KPF[1],
+          event.refName, repoFile, new ArrayList<>());
+      if (isNullOrEmpty(content)) {
+        addVerboseMsg("cannot find file: " + key);
+        // unchecked: including-file-path : line number : source line
+        addMsg("unchecked: " + qualifiedPath(project, path) + ":" + num + ": " + directive);
+      } else {
+        checkFile(KPF[1], repoFile, content);
+      }
+    }
+
+    void checkLine(String project, String path, int lineNumber, String line) {
+      String email;
+      String[] owners;
+      if (Parser.isComment(line) || Parser.isNoParent(line)) {
+        // no email address to check
+      } else if ((email = Parser.parseEmail(line)) != null) {
+        collectEmail(email, project, path, lineNumber);
+      } else if ((owners = Parser.parsePerFileOwners(line)) != null) {
+        for (String owner: owners) {
+          if (owner.startsWith("file:")) {
+            // Pass the whole line, not just owner, to report any syntax error.,
+            checkIncludeOrFile(project, path, lineNumber, line);
+          } else if (!owner.equals(Parser.TOK_SET_NOPARENT)) {
+            collectEmail(owner, project, path, lineNumber);
+          }
+        }
+      } else if (Parser.isInclude(line)) {
+        checkIncludeOrFile(project, path, lineNumber, line);
+      } else {
+        addSyntaxError(qualifiedPath(project, path), lineNumber, line);
+      }
+    }
+  } // end of inner class Checker
 
   /**
-   * Find all changed OWNERS files which differ between the commit and its parents. Return a map
-   * from "Path to the changed file" to "ObjectId of the file".
+   * Return a map from "Path to changed file" to "ObjectId of the file".
    */
-  private static Map<String, ObjectId> getChangedOwners(
-      RevCommit c, RevWalk revWalk, String ownersFileName) throws IOException {
+  private static Map<String, ObjectId> getChangedFiles(
+      RevCommit c, RevWalk revWalk) throws IOException {
     final Map<String, ObjectId> content = new HashMap<>();
     visitChangedEntries(
         c,
@@ -291,7 +398,8 @@
         new TreeWalkVisitor() {
           @Override
           public void onVisit(TreeWalk tw) {
-            if (isFile(tw) && ownersFileName.equals(tw.getNameString())) {
+            // getPathString() returns path names without leading "/"
+            if (isFile(tw)) {
               content.put(tw.getPathString(), tw.getObjectId(0));
             }
           }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/findowners/OwnersValidatorTest.java b/src/test/java/com/googlesource/gerrit/plugins/findowners/OwnersValidatorIT.java
similarity index 65%
rename from src/test/java/com/googlesource/gerrit/plugins/findowners/OwnersValidatorTest.java
rename to src/test/java/com/googlesource/gerrit/plugins/findowners/OwnersValidatorIT.java
index c294f30..8587b35 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/findowners/OwnersValidatorTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/findowners/OwnersValidatorIT.java
@@ -25,10 +25,18 @@
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.inject.Inject;
 import java.io.File;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
@@ -51,15 +59,44 @@
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.junit.runners.JUnit4;
+import org.junit.rules.TestWatcher;
+import org.junit.runner.Description;
 
 /** Test OwnersValidator, which checks syntax of changed OWNERS files. */
-@RunWith(JUnit4.class)
-public class OwnersValidatorTest {
+@TestPlugin(name = "find-owners", sysModule = "com.googlesource.gerrit.plugins.findowners.Module")
+public class OwnersValidatorIT extends LightweightPluginDaemonTest {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   @Rule public Watcher watcher = new Watcher(logger);
 
+  @Inject private ProjectOperations projectOperations;
+
+  protected Project.NameKey newProject(String name) {
+    return projectOperations.newProject().parent(new Project.NameKey("All-Projects")).
+        name(name).create();
+  }
+
+  private void switchProject(Project.NameKey p) throws Exception {
+    project = p;
+    testRepo = cloneProject(project);
+  }
+
+  private void addProjectFile(
+      String project, String file, String content) throws Exception {
+    switchProject(newProject(project));
+    PushOneCommit.Result c = createChange("c", file, content);
+    approve(c.getChangeId());
+    gApi.changes().id(c.getChangeId()).current().submit(new SubmitInput());
+  }
+
+  private static class MockedCommitReceivedEvent extends CommitReceivedEvent {
+    MockedCommitReceivedEvent(String project, RevWalk revWalk, RevCommit commit) {
+      this.project = new Project(new Project.NameKey(project));
+      this.revWalk = revWalk;
+      this.commit = commit;
+      this.refName = "master";
+    }
+  }
+
   private static class MockedEmails extends Emails {
     Set<String> registered;
 
@@ -126,25 +163,26 @@
 
   @Test
   public void testNoOwners() throws Exception {
-    try (RevWalk rw = new RevWalk(repo)) {
-      RevCommit c = makeCommit(rw, "Commit no OWNERS.", FILES_WITHOUT_OWNERS);
-      assertThat(validate(rw, c, false, ENABLED_CONFIG)).isEmpty();
-      assertThat(validate(rw, c, true, ENABLED_CONFIG)).isEmpty();
-    }
+    CommitReceivedEvent event = makeCommitEvent("myTest", "no OWNERS.", FILES_WITHOUT_OWNERS);
+    assertThat(validate(event, false, ENABLED_CONFIG)).isEmpty();
+    assertThat(validate(event, true, ENABLED_CONFIG)).isEmpty();
   }
 
   private static final String[] INCLUDE_STMTS = new String[]{
       "  include  p1/p2 : /d1/owners",
-      "include  p1/p2://d1/owners #comment",
+      "include  p2/p1://d1/owners #comment",
       "include ../d2/owners",
       " per-file *.java = file:  //d2/OWNERS.java #",
       "per-file *.cpp,*cc=file:p1/p2:/c++owners",
-      "  file:   p1/p2 : /d1/owners ",
-      "file:  p1/p2://d1/owners #comment",
-      "file: ../d2/owners",
-      "file: //OWNERS",
+      "  file:   p1/p2 : /d1/owners ",  // repeated
+      "file:  p3/p2://d1/owners #comment",
+      "file: ../d2/owners",  // repeated
+      "file: //OWNERS",  // recursive inclusion
       "file:///OWNERS.android"};
 
+  private static final ImmutableSet<String> SKIP_INCLUDE_STMTS =
+      ImmutableSet.of("  file:   p1/p2 : /d1/owners ", "file: ../d2/owners", "file: //OWNERS");
+
   private static String allIncludeStatements() {
     String statement = "";
     for (String s: INCLUDE_STMTS) {
@@ -156,8 +194,10 @@
   private static Set<String> allIncludeMsgs() {
     Set<String> msgs = new HashSet<>();
     for (int i = 0; i < INCLUDE_STMTS.length; i++) {
-      msgs.add("MSG: unchecked: OWNERS:" + (i + 1)
-          + ": " + Parser.getIncludeOrFile(INCLUDE_STMTS[i]));
+      if (!SKIP_INCLUDE_STMTS.contains(INCLUDE_STMTS[i])) {
+        msgs.add("MSG: unchecked: OWNERS:" + (i + 1)
+            + ": " + Parser.getIncludeOrFile(INCLUDE_STMTS[i]));
+      }
     }
     return msgs;
   };
@@ -178,11 +218,32 @@
 
   private static Set<String> allVerboseMsgs() {
     Set<String> msgs = allIncludeMsgs();
-    msgs.add("MSG: validate: " + OWNERS);
+    msgs.add("MSG: checking " + OWNERS);
     msgs.add("MSG: owner: u1+review@g.com");
     msgs.add("MSG: owner: u1@g.com");
     msgs.add("MSG: owner: u2.m@g.com");
     msgs.add("MSG: owner: user1@google.com");
+    String[] missing = new String[]{
+        "p1/p2:OWNERS.android",
+        "p1/p2:c++owners",
+        "p1/p2:d1/owners",
+        "p1/p2:d2/OWNERS.java",
+        "p1/p2:d2/owners",
+        "p2/p1:d1/owners",
+        "p3/p2:d1/owners",
+    };
+    for (String s : missing) {
+      msgs.add("MSG: cannot find file: " + s);
+      msgs.add("MSG: check repo file " + s);
+    }
+    String[] skips = new String[]{
+        "p1/p2:OWNERS",
+        "p1/p2:d1/owners",
+        "p1/p2:d2/owners",
+    };
+    for (String s : skips) {
+      msgs.add("MSG: skip repeated include of " + s);
+    }
     return msgs;
   };
 
@@ -193,13 +254,11 @@
 
   @Test
   public void testGoodInput() throws Exception {
-    try (RevWalk rw = new RevWalk(repo)) {
-      RevCommit c = makeCommit(rw, "Commit good files", FILES_WITH_NO_ERROR);
-      assertThat(validate(rw, c, false, ENABLED_CONFIG))
-          .containsExactlyElementsIn(EXPECTED_NON_VERBOSE_OUTPUT);
-      assertThat(validate(rw, c, true, ENABLED_CONFIG))
-          .containsExactlyElementsIn(EXPECTED_VERBOSE_OUTPUT);
-    }
+    CommitReceivedEvent event = makeCommitEvent("p1/p2", "good files", FILES_WITH_NO_ERROR);
+    assertThat(validate(event, false, ENABLED_CONFIG))
+        .containsExactlyElementsIn(EXPECTED_NON_VERBOSE_OUTPUT);
+    assertThat(validate(event, true, ENABLED_CONFIG))
+        .containsExactlyElementsIn(EXPECTED_VERBOSE_OUTPUT);
   }
 
   private static final ImmutableMap<String, String> FILES_WITH_WRONG_SYNTAX =
@@ -211,7 +270,6 @@
           "d2/" + OWNERS,
           "u1@g.com\n\nu3@g.com\n*\n");
 
-
   private static final ImmutableSet<String> EXPECTED_WRONG_SYNTAX =
       ImmutableSet.of(
           "ERROR: syntax: " + OWNERS + ":4: wrong syntax",
@@ -219,8 +277,8 @@
 
   private static final ImmutableSet<String> EXPECTED_VERBOSE_WRONG_SYNTAX =
       ImmutableSet.of(
-          "MSG: validate: d2/" + OWNERS,
-          "MSG: validate: " + OWNERS,
+          "MSG: checking d2/" + OWNERS,
+          "MSG: checking " + OWNERS,
           "MSG: owner: user1@google.com",
           "MSG: owner: u1@g.com",
           "MSG: owner: u3@g.com",
@@ -229,40 +287,51 @@
 
   @Test
   public void testWrongSyntax() throws Exception {
-    try (RevWalk rw = new RevWalk(repo)) {
-      RevCommit c = makeCommit(rw, "Commit wrong syntax", FILES_WITH_WRONG_SYNTAX);
-      assertThat(validate(rw, c, false, ENABLED_CONFIG))
-          .containsExactlyElementsIn(EXPECTED_WRONG_SYNTAX);
-      assertThat(validate(rw, c, true, ENABLED_CONFIG))
-          .containsExactlyElementsIn(EXPECTED_VERBOSE_WRONG_SYNTAX);
-    }
+    CommitReceivedEvent event = makeCommitEvent("p1/p2", "wrong syntax", FILES_WITH_WRONG_SYNTAX);
+    assertThat(validate(event, false, ENABLED_CONFIG))
+        .containsExactlyElementsIn(EXPECTED_WRONG_SYNTAX);
+    assertThat(validate(event, true, ENABLED_CONFIG))
+        .containsExactlyElementsIn(EXPECTED_VERBOSE_WRONG_SYNTAX);
   }
 
   private static final ImmutableMap<String, String> FILES_WITH_WRONG_EMAILS =
       ImmutableMap.of("d1/" + OWNERS, "u1@g.com\n", "d2/" + OWNERS_ANDROID, "u2@g.com\n");
 
   private static final ImmutableSet<String> EXPECTED_VERBOSE_DEFAULT =
-      ImmutableSet.of("MSG: validate: d1/" + OWNERS, "MSG: owner: u1@g.com");
+      ImmutableSet.of("MSG: checking d1/" + OWNERS, "MSG: owner: u1@g.com");
 
   private static final ImmutableSet<String> EXPECTED_VERBOSE_ANDROID =
-      ImmutableSet.of("MSG: validate: d2/" + OWNERS_ANDROID, "MSG: owner: u2@g.com");
+      ImmutableSet.of("MSG: checking d2/" + OWNERS_ANDROID, "MSG: owner: u2@g.com");
 
   @Test
   public void checkWrongEmails() throws Exception {
-    try (RevWalk rw = new RevWalk(repo)) {
-      RevCommit c = makeCommit(rw, "Commit Default", FILES_WITH_WRONG_EMAILS);
-      assertThat(validate(rw, c, true, ENABLED_CONFIG))
-          .containsExactlyElementsIn(EXPECTED_VERBOSE_DEFAULT);
-    }
+    CommitReceivedEvent event = makeCommitEvent("test", "default", FILES_WITH_WRONG_EMAILS);
+    assertThat(validate(event, true, ENABLED_CONFIG))
+        .containsExactlyElementsIn(EXPECTED_VERBOSE_DEFAULT);
   }
 
   @Test
   public void checkAndroidOwners() throws Exception {
-    try (RevWalk rw = new RevWalk(repo)) {
-      RevCommit c = makeCommit(rw, "Commit Android", FILES_WITH_WRONG_EMAILS);
-      assertThat(validate(rw, c, true, ANDROID_CONFIG))
-          .containsExactlyElementsIn(EXPECTED_VERBOSE_ANDROID);
-    }
+    CommitReceivedEvent event = makeCommitEvent("test", "Android", FILES_WITH_WRONG_EMAILS);
+    assertThat(validate(event, true, ANDROID_CONFIG))
+        .containsExactlyElementsIn(EXPECTED_VERBOSE_ANDROID);
+  }
+
+  @Test
+  public void simpleIncludeTest() throws Exception {
+    addProjectFile("p1", "d2/owners", "wrong\nxyz\n");
+    addProjectFile("p2", "d2/owners", "x@g.com\nerr\ninclude ../d2/owners\n");
+    ImmutableMap<String, String> files =
+        ImmutableMap.of(
+            "d1/" + OWNERS, "include ../d2/owners\ninclude /d2/owners\n"
+            + "include p1:/d2/owners\ninclude p2:/d2/owners\n");
+    ImmutableSet<String> expected = ImmutableSet.of(
+       "ERROR: unknown: x@g.com at p2:d2/owners:1",
+       "ERROR: syntax: p2:d2/owners:2: err",
+       "ERROR: syntax: d2/owners:1: wrong",
+       "ERROR: syntax: d2/owners:2: xyz");
+    CommitReceivedEvent event = makeCommitEvent("p1", "T", files);
+    assertThat(validate(event, false, ENABLED_CONFIG)).containsExactlyElementsIn(expected);
   }
 
   private static PluginConfig createEnabledConfig() {
@@ -305,13 +374,19 @@
     }
   }
 
-  private List<String> validate(RevWalk rw, RevCommit c, boolean verbose, PluginConfig cfg)
+  private CommitReceivedEvent makeCommitEvent(
+      String project, String message, Map<String, String> fileStrings) throws Exception {
+    RevWalk rw = new RevWalk(repo);
+    return new MockedCommitReceivedEvent(project, rw, makeCommit(rw, message, fileStrings));
+  }
+
+  private List<String> validate(CommitReceivedEvent event, boolean verbose, PluginConfig cfg)
       throws Exception {
-    MockedEmails myEmails = new MockedEmails();
-    OwnersValidator validator = new OwnersValidator(null, null, myEmails);
-    String ownersFileName = OwnersValidator.getOwnersFileName(cfg);
-    List<CommitValidationMessage> m = validator.performValidation(c, rw, ownersFileName, verbose);
-    return transformMessages(m);
+    OwnersValidator validator =
+        new OwnersValidator("find-owners", pluginConfig, repoManager, new MockedEmails());
+    OwnersValidator.Checker checker = validator.new Checker(event, verbose);
+    checker.check(OwnersValidator.getOwnersFileName(cfg));
+    return transformMessages(checker.messages);
   }
 
   private static String generateFilePattern(File f, Git git) {