Add validator that checks for duplicate pathnames

Project owner can now configure duplicate pathnames validator. Commits
which contain duplicate pathnames will be rejected. If y and z are
pathnames and y.equalsIgnoreCase(z), then y and z are duplicates.

Change-Id: Iaac644a07fc88e855cc9a041f0237157bda9d010
diff --git a/src/main/java/com/googlesource/gerrit/plugins/uploadvalidator/DuplicatePathnameValidator.java b/src/main/java/com/googlesource/gerrit/plugins/uploadvalidator/DuplicatePathnameValidator.java
new file mode 100644
index 0000000..bf64f63
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/uploadvalidator/DuplicatePathnameValidator.java
@@ -0,0 +1,250 @@
+// Copyright (C) 2016 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.plugins.uploadvalidator;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Function;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.config.PluginConfig;
+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;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+public class DuplicatePathnameValidator implements CommitValidationListener {
+
+  public static AbstractModule module() {
+    return new AbstractModule() {
+      private List<String> getAvailableLocales() {
+        return Lists.transform(Arrays.asList(Locale.getAvailableLocales()),
+            new Function<Locale, String>() {
+              @Override
+              public String apply(Locale input) {
+                return input.toString();
+              }
+            });
+      }
+
+      @Override
+      protected void configure() {
+        DynamicSet.bind(binder(), CommitValidationListener.class)
+            .to(DuplicatePathnameValidator.class);
+        bind(ProjectConfigEntry.class)
+            .annotatedWith(Exports.named(KEY_REJECT_DUPLICATE_PATHNAMES))
+            .toInstance(new ProjectConfigEntry("Reject Duplicate Pathnames",
+                null, ProjectConfigEntry.Type.BOOLEAN, null, false,
+                "Pushes of commits that contain duplicate pathnames, or that "
+                    + "contain duplicates of existing pathnames will be "
+                    + "rejected. Pathnames y and z are considered to be "
+                    + "duplicates if they are equal, case-insensitive."));
+        bind(ProjectConfigEntry.class)
+            .annotatedWith(Exports.named(KEY_REJECT_DUPLICATE_PATHNAMES_LOCALE))
+            .toInstance(new ProjectConfigEntry("Reject Duplicate Pathnames Locale",
+                "en", ProjectConfigEntry.Type.STRING, getAvailableLocales(), false,
+                "To avoid problems caused by comparing pathnames with different "
+                    + "locales it is possible to use a specific locale. The "
+                    + "default is English (en)."));
+      }
+    };
+  }
+
+  public static String KEY_REJECT_DUPLICATE_PATHNAMES =
+      "rejectDuplicatePathnames";
+  public static String KEY_REJECT_DUPLICATE_PATHNAMES_LOCALE =
+      "rejectDuplicatePathnamesLocale";
+
+  @VisibleForTesting
+  static boolean isActive(PluginConfig cfg) {
+    return cfg.getBoolean(KEY_REJECT_DUPLICATE_PATHNAMES, false);
+  }
+
+  @VisibleForTesting
+  static Locale getLocale(PluginConfig cfg) {
+    return Locale.forLanguageTag(
+        cfg.getString(KEY_REJECT_DUPLICATE_PATHNAMES_LOCALE, "en"));
+  }
+
+  @VisibleForTesting
+  Map<String, String> allPaths(Collection<String> leafs) {
+    Map<String, String> paths = new HashMap<>();
+    for (String cp : leafs) {
+      int n = cp.indexOf('/');
+      while (n > -1) {
+        String s = cp.substring(0, n);
+        paths.put(s.toLowerCase(locale), s);
+        n = cp.indexOf('/', n + 1);
+      }
+      paths.put(cp.toLowerCase(locale), cp);
+    }
+    return paths;
+  }
+
+  Set<String> allParentFolders(Collection<String> paths) {
+    Set<String> folders = new HashSet<>();
+    for (String cp : paths) {
+      int n = cp.indexOf('/');
+      while (n > -1) {
+        String s = cp.substring(0, n);
+        folders.add(s);
+        n = cp.indexOf('/', n + 1);
+      }
+    }
+    return folders;
+  }
+
+  @VisibleForTesting
+  static CommitValidationMessage conflict(String f1, String f2) {
+    return new CommitValidationMessage(f1 + ": pathname conflicts with " + f2,
+        true);
+  }
+
+  private static boolean isDeleted(TreeWalk tw) {
+    return FileMode.MISSING.equals(tw.getRawMode(0));
+  }
+
+  private final String pluginName;
+  private final PluginConfigFactory cfgFactory;
+  private final GitRepositoryManager repoManager;
+
+  private Locale locale;
+
+  @VisibleForTesting
+  void setLocale(Locale locale) {
+    this.locale = locale;
+  }
+
+  @Inject
+  DuplicatePathnameValidator(@PluginName String pluginName,
+      PluginConfigFactory cfgFactory, GitRepositoryManager repoManager) {
+    this.pluginName = pluginName;
+    this.cfgFactory = cfgFactory;
+    this.repoManager = repoManager;
+  }
+
+  @Override
+  public List<CommitValidationMessage> onCommitReceived(
+      CommitReceivedEvent receiveEvent) throws CommitValidationException {
+    try {
+      PluginConfig cfg = cfgFactory
+          .getFromProjectConfig(receiveEvent.project.getNameKey(), pluginName);
+      if (!isActive(cfg)) {
+        return Collections.emptyList();
+      }
+      locale = getLocale(cfg);
+      try (Repository repo =
+          repoManager.openRepository(receiveEvent.project.getNameKey())) {
+        List<CommitValidationMessage> messages =
+            performValidation(repo, receiveEvent.commit);
+        if (!messages.isEmpty()) {
+          throw new CommitValidationException("contains duplicate pathnames",
+              messages);
+        }
+      }
+    } catch (NoSuchProjectException | IOException e) {
+      throw new CommitValidationException(
+          "failed to check for duplicate pathnames", e);
+    }
+    return Collections.emptyList();
+  }
+
+  @VisibleForTesting
+  List<CommitValidationMessage> performValidation(Repository repo, RevCommit c)
+      throws IOException {
+    List<CommitValidationMessage> messages = new LinkedList<>();
+
+    Set<String> pathnames = CommitUtils.getChangedPaths(repo, c);
+    checkForDuplicatesInSet(pathnames, messages);
+    if (!messages.isEmpty() || c.getParentCount() == 0) {
+      return messages;
+    }
+
+    try (TreeWalk tw = new TreeWalk(repo)) {
+      tw.setRecursive(false);
+      tw.addTree(c.getTree());
+      checkForDuplicatesAgainstTheWholeTree(tw, pathnames, messages);
+    }
+    return messages;
+  }
+
+  @VisibleForTesting
+  void checkForDuplicatesAgainstTheWholeTree(TreeWalk tw,
+      Set<String> changed, List<CommitValidationMessage> messages)
+          throws IOException {
+    Map<String, String> all = allPaths(changed);
+
+    while (tw.next()) {
+      String currentPath = tw.getPathString();
+
+      if (isDeleted(tw)) {
+        continue;
+      }
+
+      String potentialDuplicate = all.get(currentPath.toLowerCase(locale));
+      if (potentialDuplicate == null) {
+        continue;
+      } else if (potentialDuplicate.equals(currentPath)) {
+        if (tw.isSubtree()) {
+          tw.enterSubtree();
+        }
+        continue;
+      } else {
+        messages.add(conflict(potentialDuplicate, currentPath));
+      }
+    }
+  }
+
+  private void checkForDuplicatesInSet(Set<String> files,
+      List<CommitValidationMessage> messages) {
+    Set<String> filesAndFolders = Sets.newHashSet(files);
+    filesAndFolders.addAll(allParentFolders(files));
+    Map<String, String> seen = new HashMap<>();
+    for (String file : filesAndFolders) {
+      String lc = file.toLowerCase(locale);
+      String duplicate = seen.get(lc);
+      if (duplicate != null) {
+        messages.add(conflict(duplicate, file));
+      } else {
+        seen.put(lc, file);
+      }
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/uploadvalidator/Module.java b/src/main/java/com/googlesource/gerrit/plugins/uploadvalidator/Module.java
index 5d462b2..f63a707 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/uploadvalidator/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/uploadvalidator/Module.java
@@ -23,6 +23,7 @@
     install(new PatternCacheModule());
     install(ContentTypeUtil.module());
     install(BlockedKeywordValidator.module());
+    install(DuplicatePathnameValidator.module());
     install(FileExtensionValidator.module());
     install(FooterValidator.module());
     install(InvalidFilenameValidator.module());
diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md
index dd2e4d4..5e4fdf3 100644
--- a/src/main/resources/Documentation/about.md
+++ b/src/main/resources/Documentation/about.md
@@ -6,6 +6,7 @@
 - invalid filenames
 - blocked keywords
 - blocked content types
+- reject duplicate pathnames
 - reject Windows line endings
 - symbolic links
 - reject submodules
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 702a644..faa4498 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -30,6 +30,8 @@
     maxPathLength = 200
     rejectSymlink = false
     rejectSubmodule = false
+    rejectDuplicatePathnames = false
+    rejectDuplicatePathnamesLocale = en
 ```
 
 plugin.@PLUGIN@.blockedFileExtension
@@ -177,3 +179,34 @@
 	interpreted as a blacklist.
 
 	Defined patterns are *not* inherited by child projects.
+
+plugin.@PLUGIN@.rejectDuplicatePathnames
+:	Reject duplicate pathnames.
+
+	This check looks for duplicate pathnames which only differ in case
+	in the tree of the commit as these can cause problems on case
+	insensitive filesystems commonly used e.g. on Windows or Mac. If the
+	check finds duplicate pathnames the push will be rejected.
+
+	The default value is false. This means duplicate pathnames ignoring
+	case are allowed.
+
+	This option is *not* inherited by child projects.
+
+plugin.@PLUGIN@.rejectDuplicatePathnamesLocale
+:	Reject duplicate pathnames locale.
+
+	When the validator checks for duplicate pathnames it will convert
+	the pathnames to lower case. In some cases this leads to a [problem][5].
+
+	To avoid these kind of problems, this option is used to specify a
+	locale which is used when converting a pathname to lower case.
+
+	Full list of supported locales can be found [here][6].
+
+	The default value is "en" (English).
+
+	This option is *not* inherited by child projects.
+
+[5]: http://bugs.java.com/view_bug.do?bug_id=6208680
+[6]: http://www.oracle.com/technetwork/java/javase/javase7locales-334809.html
diff --git a/src/test/java/com/googlesource/gerrit/plugins/uploadvalidator/DuplicatePathnameValidatorTest.java b/src/test/java/com/googlesource/gerrit/plugins/uploadvalidator/DuplicatePathnameValidatorTest.java
new file mode 100644
index 0000000..f3be61b
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/uploadvalidator/DuplicatePathnameValidatorTest.java
@@ -0,0 +1,197 @@
+// Copyright (C) 2016 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.plugins.uploadvalidator;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.googlesource.gerrit.plugins.uploadvalidator.DuplicatePathnameValidator.conflict;
+import static com.googlesource.gerrit.plugins.uploadvalidator.TestUtils.EMPTY_CONTENT;
+import static com.googlesource.gerrit.plugins.uploadvalidator.TestUtils.EMPTY_PLUGIN_CONFIG;
+import static com.googlesource.gerrit.plugins.uploadvalidator.TestUtils.createDirCacheEntry;
+import static com.googlesource.gerrit.plugins.uploadvalidator.TestUtils.createEmptyDirCacheEntries;
+import static com.googlesource.gerrit.plugins.uploadvalidator.TestUtils.makeCommit;
+import static com.googlesource.gerrit.plugins.uploadvalidator.TestUtils.transformMessage;
+import static com.googlesource.gerrit.plugins.uploadvalidator.TestUtils.transformMessages;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+
+import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+public class DuplicatePathnameValidatorTest extends ValidatorTestCase {
+  private static final List<String> INITIAL_PATHNAMES = ImmutableList.of(
+      "a" , "ab",
+      "f1/a", "f1/ab",
+      "f2/a", "f2/ab", "f2/sF1/a", "f2/sF1/ab");
+
+  private TestRepository<Repository> testRepo;
+  private List<String> vistedPaths = Lists.newArrayList();
+  private List<CommitValidationMessage> messages = Lists.newArrayList();
+  private Set<String> changedPaths;
+  private DuplicatePathnameValidator validator;
+
+  private void runCheck(List<String> existingTreePaths, Set<String> testPaths,
+      List<CommitValidationMessage> messages, List<String> visitedPaths)
+          throws Exception {
+    RevCommit c = makeCommit(
+        createEmptyDirCacheEntries(existingTreePaths, testRepo), testRepo);
+    try (TreeWalk tw = new TreeWalk(repo)) {
+      tw.setRecursive(false);
+      tw.addTree(c.getTree());
+      tw.setFilter(new ListVisitedPathsFilter(visitedPaths));
+      validator.checkForDuplicatesAgainstTheWholeTree(tw, testPaths, messages);
+    }
+  }
+
+  @Override
+  @Before
+  public void init() throws IOException {
+    super.init();
+    testRepo = new TestRepository<>(repo);
+    validator = new DuplicatePathnameValidator(null, null, null);
+    validator.setLocale(Locale.ENGLISH);
+  }
+
+  @Test
+  public void testSkipSubTreesWithImproperPrefix() throws Exception {
+    changedPaths = Sets.newHashSet("f1/A");
+    runCheck(INITIAL_PATHNAMES, changedPaths, messages, vistedPaths);
+    assertThat(transformMessages(messages))
+        .containsExactly(transformMessage(conflict("f1/A", "f1/a")));
+    assertThat(vistedPaths).containsExactlyElementsIn(ImmutableList.of("a",
+        "ab", "f1", "f1/a", "f1/ab", "f2"));
+  }
+
+  @Test
+  public void testFindConflictingSubtree() throws Exception {
+    changedPaths = Sets.newHashSet("F1/a");
+    runCheck(INITIAL_PATHNAMES, changedPaths, messages, vistedPaths);
+    assertThat(transformMessages(messages))
+        .containsExactly(transformMessage(conflict("F1", "f1")));
+    assertThat(vistedPaths).containsExactlyElementsIn(
+        ImmutableList.of("a", "ab", "f1", "f2"));
+  }
+
+  @Test
+  public void testFindConflictingSubtree2() throws Exception {
+    changedPaths = Sets.newHashSet("f2/sf1", "F1/a");
+    runCheck(INITIAL_PATHNAMES, changedPaths, messages, vistedPaths);
+    assertThat(transformMessages(messages)).containsExactly(
+        transformMessage(conflict("F1", "f1")),
+        transformMessage(conflict("f2/sf1", "f2/sF1")));
+    assertThat(vistedPaths).containsExactlyElementsIn(
+        ImmutableList.of("a", "ab", "f1", "f2", "f2/a", "f2/ab", "f2/sF1"));
+  }
+
+  @Test
+  public void testFindDuplicates() throws Exception {
+    changedPaths = Sets.newHashSet("AB", "f1/A", "f2/Ab");
+    runCheck(INITIAL_PATHNAMES, changedPaths, messages, vistedPaths);
+    assertThat(transformMessages(messages)).containsExactly(
+        transformMessage(conflict("AB", "ab")),
+        transformMessage(conflict("f1/A", "f1/a")),
+        transformMessage(conflict("f2/Ab", "f2/ab")));
+    assertThat(vistedPaths).containsExactlyElementsIn(
+        ImmutableList.of("a", "ab", "f1", "f1/a", "f1/ab",
+            "f2", "f2/a", "f2/ab", "f2/sF1"));
+  }
+
+  @Test
+  public void testFindNoDuplicates() throws Exception {
+    changedPaths = Sets.newHashSet("a", "ab", "f1/ab");
+    runCheck(INITIAL_PATHNAMES, changedPaths, messages, vistedPaths);
+    assertThat(messages).isEmpty();
+    assertThat(vistedPaths).containsExactlyElementsIn(ImmutableList.of("a",
+        "ab", "f1", "f1/a", "f1/ab", "f2"));
+  }
+
+  @Test
+  public void testCheckInsideOfCommit() throws Exception {
+    List<String> filenames = Lists.newArrayList(INITIAL_PATHNAMES);
+    // add files with conflicting pathnames
+    filenames.add("A");
+    filenames.add("F1/ab");
+    filenames.add("f2/sF1/aB");
+    RevCommit c =
+        makeCommit(createEmptyDirCacheEntries(filenames, testRepo), testRepo);
+    List<CommitValidationMessage> m = validator.performValidation(repo, c);
+    assertThat(m).hasSize(4);
+    // During checking inside of the commit it's unknown which file is checked
+    // first, because of that, both capabilities must be checked.
+    assertThat(transformMessages(m)).containsAnyOf(
+        transformMessage(conflict("A", "a")),
+        transformMessage(conflict("a", "A")));
+
+    assertThat(transformMessages(m)).containsAnyOf(
+        transformMessage(conflict("F1", "f1")),
+        transformMessage(conflict("f1", "F1")));
+
+    assertThat(transformMessages(m)).containsAnyOf(
+        transformMessage(conflict("F1/ab", "f1/ab")),
+        transformMessage(conflict("f1/ab", "F1/ab")));
+
+    assertThat(transformMessages(m)).containsAnyOf(
+        transformMessage(
+            conflict("f2/sF1/aB", "f2/sF1/ab")),
+        transformMessage(
+            conflict("f2/sF1/ab", "f2/sF1/aB")));
+  }
+
+  @Test
+  public void testCheckRenaming() throws Exception {
+    RevCommit c = makeCommit(
+        createEmptyDirCacheEntries(INITIAL_PATHNAMES, testRepo), testRepo);
+    DirCacheEntry[] entries = new DirCacheEntry[INITIAL_PATHNAMES.size()];
+    for (int x = 0; x < INITIAL_PATHNAMES.size(); x++) {
+      // Rename files
+      entries[x] = createDirCacheEntry(INITIAL_PATHNAMES.get(x).toUpperCase(),
+          EMPTY_CONTENT, testRepo);
+    }
+    RevCommit c1 = makeCommit(entries, testRepo, c);
+    List<CommitValidationMessage> m = validator.performValidation(repo, c1);
+    assertThat(m).isEmpty();
+  }
+
+  @Test
+  public void validatorInactiveWhenConfigEmpty() {
+    assertThat(DuplicatePathnameValidator.isActive(EMPTY_PLUGIN_CONFIG))
+        .isFalse();
+  }
+
+  @Test
+  public void defaultLocale() {
+    assertThat(DuplicatePathnameValidator.getLocale(EMPTY_PLUGIN_CONFIG))
+        .isEqualTo(Locale.ENGLISH);
+  }
+
+  @Test
+  public void testGetParentFolder() {
+    assertThat(validator.allParentFolders(INITIAL_PATHNAMES))
+        .containsExactlyElementsIn(
+            ImmutableList.of("f1", "f2", "f2/sF1"));
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/uploadvalidator/ListVisitedPathsFilter.java b/src/test/java/com/googlesource/gerrit/plugins/uploadvalidator/ListVisitedPathsFilter.java
new file mode 100644
index 0000000..282590b
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/uploadvalidator/ListVisitedPathsFilter.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2016 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.plugins.uploadvalidator;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.filter.TreeFilter;
+
+import java.io.IOException;
+import java.util.List;
+
+public class ListVisitedPathsFilter extends TreeFilter {
+  private List<String> visitedPaths = null;
+
+  public ListVisitedPathsFilter(List<String> visitedPaths) {
+    super();
+    this.visitedPaths = visitedPaths;
+  }
+
+  @Override
+  public boolean include(TreeWalk walker)
+      throws MissingObjectException, IncorrectObjectTypeException, IOException {
+    visitedPaths.add(walker.getPathString());
+    return true;
+  }
+
+  @Override
+  public boolean shouldBeRecursive() {
+    return false;
+  }
+
+  @Override
+  public TreeFilter clone() {
+    return this;
+  }
+
+  public List<String> getVisitedPaths() {
+    return visitedPaths;
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/uploadvalidator/TestUtils.java b/src/test/java/com/googlesource/gerrit/plugins/uploadvalidator/TestUtils.java
index 9590bdb..0f102fa 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/uploadvalidator/TestUtils.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/uploadvalidator/TestUtils.java
@@ -14,6 +14,7 @@
 
 package com.googlesource.gerrit.plugins.uploadvalidator;
 
+import com.google.common.base.Charsets;
 import com.google.common.base.Function;
 import com.google.common.cache.CacheBuilder;
 import com.google.common.cache.LoadingCache;
@@ -27,9 +28,12 @@
 import org.eclipse.jgit.api.RmCommand;
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.api.errors.NoFilepatternException;
+import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
 
 import java.io.File;
@@ -44,6 +48,17 @@
   public static final PluginConfig EMPTY_PLUGIN_CONFIG =
       new PluginConfig("", new Config());
 
+  protected static final byte[] EMPTY_CONTENT = "".getBytes(Charsets.UTF_8);
+
+  private static final Function<CommitValidationMessage, String> MESSAGE_TRANSFORMER =
+      new Function<CommitValidationMessage, String>() {
+        @Override
+        public String apply(CommitValidationMessage input) {
+          String pre = (input.isError()) ? "ERROR: " : "MSG: ";
+          return pre + input.getMessage();
+        }
+      };
+
   public static final LoadingCache<String, Pattern> PATTERN_CACHE =
     CacheBuilder.newBuilder().build(new PatternCacheModule.Loader());
 
@@ -110,15 +125,44 @@
     return new File(repo.getDirectory().getParent(), name);
   }
 
+  public static String transformMessage(CommitValidationMessage messages) {
+    return MESSAGE_TRANSFORMER.apply(messages);
+  }
+
   public static List<String> transformMessages(
       List<CommitValidationMessage> messages) {
-    return Lists.transform(messages,
-        new Function<CommitValidationMessage, String>() {
-          @Override
-          public String apply(CommitValidationMessage input) {
-            String pre = (input.isError()) ? "ERROR: " : "MSG: ";
-            return pre + input.getMessage();
-          }
-        });
+    return Lists.transform(messages, MESSAGE_TRANSFORMER);
+  }
+
+  public static DirCacheEntry[] createEmptyDirCacheEntries(
+      List<String> filenames, TestRepository<Repository> repo)
+      throws Exception {
+    DirCacheEntry[] entries = new DirCacheEntry[filenames.size()];
+    for (int x = 0; x < filenames.size(); x++) {
+      entries[x] = createDirCacheEntry(filenames.get(x), EMPTY_CONTENT, repo);
+    }
+    return entries;
+
+  }
+
+  public static DirCacheEntry createDirCacheEntry(String pathname,
+      byte[] content, TestRepository<Repository> repo)
+      throws Exception {
+    return repo.file(pathname, repo.blob(content));
+  }
+
+  public static RevCommit makeCommit(DirCacheEntry[] entries,
+      TestRepository<Repository> repo) throws Exception {
+    return makeCommit(entries, repo, (RevCommit[]) null);
+  }
+
+  public static RevCommit makeCommit(DirCacheEntry[] entries,
+      TestRepository<Repository> repo, RevCommit... parents)
+      throws Exception {
+    final RevTree ta = repo.tree(entries);
+    RevCommit c =
+        (parents == null) ? repo.commit(ta) : repo.commit(ta, parents);
+    repo.parseBody(c);
+    return c;
   }
 }