Add explicit project exclusion support

While it was previously possible to exclude projects with the 'projects'
keyword by creating a negative match regular expression, this required
all the excluded projects to be specified in a single ORed regex which
is very ugly and error prone. Instead, introduce an explicit keyword,
'excludeProjects', which may be specified more than once to handle
project exclusions in a straight forward manner.

Also, bump ReplicationIT test target timeout to long. It defaulted to
Bazel's 'moderate' timeout bucket (300s). With new test additions in
this change, it will reach that ceiling and fail with a timeout. Set
its 'timeout' to long (900s).

Release-Notes: Add explicit project exclusion support
Change-Id: I4e50f923f734537bc32b6a350d3268dbc286d818
diff --git a/BUILD b/BUILD
index 43abe9f..cc671d7 100644
--- a/BUILD
+++ b/BUILD
@@ -48,6 +48,7 @@
 
 [gerrit_plugin_tests(
     name = f[:f.index(".")].replace("/", "_"),
+    timeout = "long" if f.endswith("/ReplicationIT.java") else "moderate",
     srcs = [f],
     tags = ["replication"],
     visibility = ["//visibility:public"],
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
index 273f7e8..0a070eb 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
@@ -724,11 +724,12 @@
 
     // by default push all projects
     ImmutableList<String> projects = config.getProjects();
-    if (projects.isEmpty()) {
+    ImmutableList<String> excludeProjects = config.getExcludeProjects();
+    if (projects.isEmpty() && excludeProjects.isEmpty()) {
       return true;
     }
 
-    boolean matches = new ReplicationFilter(projects).matches(project);
+    boolean matches = new ReplicationFilter(projects, excludeProjects).matches(project);
     if (!matches) {
       repLog.atFine().log(
           "Skipping replication of project %s; does not match filter", project.get());
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationConfiguration.java b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationConfiguration.java
index d2b9686..fc73d15 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationConfiguration.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationConfiguration.java
@@ -50,6 +50,7 @@
   private final String remoteNameStyle;
   private final ImmutableList<String> urls;
   private final ImmutableList<String> projects;
+  private final ImmutableList<String> excludeProjects;
   private final ImmutableList<String> authGroupNames;
   private final RemoteConfig remoteConfig;
   private final int maxRetries;
@@ -69,6 +70,7 @@
     rescheduleDelay =
         Math.max(3, getInt(remoteConfig, cfg, "rescheduledelay", DEFAULT_RESCHEDULE_DELAY));
     projects = ImmutableList.copyOf(cfg.getStringList("remote", name, "projects"));
+    excludeProjects = ImmutableList.copyOf(cfg.getStringList("remote", name, "excludeProjects"));
     adminUrls = ImmutableList.copyOf(cfg.getStringList("remote", name, "adminUrl"));
     retryDelay = Math.max(0, getInt(remoteConfig, cfg, "replicationretry", 1));
     drainQueueAttempts =
@@ -175,6 +177,11 @@
   }
 
   @Override
+  public ImmutableList<String> getExcludeProjects() {
+    return excludeProjects;
+  }
+
+  @Override
   public ImmutableList<String> getAuthGroupNames() {
     return authGroupNames;
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteConfiguration.java b/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteConfiguration.java
index c725d40..bcd9ace 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteConfiguration.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteConfiguration.java
@@ -66,6 +66,13 @@
   ImmutableList<String> getProjects();
 
   /**
+   * List of repositories that should be NOT replicated
+   *
+   * @return list of excluded project strings
+   */
+  ImmutableList<String> getExcludeProjects();
+
+  /**
    * List of groups that should be used to access the repositories.
    *
    * @return list of group strings
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/RepairCommand.java b/src/main/java/com/googlesource/gerrit/plugins/replication/RepairCommand.java
index b70bb0f..70e8622 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/RepairCommand.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/RepairCommand.java
@@ -30,6 +30,7 @@
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Optional;
@@ -99,7 +100,7 @@
     replicationStarter.start(
         urlMatch,
         Set.of(),
-        new ReplicationFilter(List.of(project.get())),
+        new ReplicationFilter(List.of(project.get()), Collections.emptyList()),
         /* now= */ true,
         /* wait= */ true,
         this);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationFilter.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationFilter.java
index 28f2fba..20192ed 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationFilter.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationFilter.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.entities.Project;
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 
 public class ReplicationFilter {
   public enum PatternType {
@@ -27,7 +28,7 @@
   }
 
   public static ReplicationFilter all() {
-    return new ReplicationFilter(Collections.<String>emptyList());
+    return new ReplicationFilter(null, null);
   }
 
   public static PatternType getPatternType(String pattern) {
@@ -41,12 +42,18 @@
   }
 
   private final List<String> projectPatterns;
+  private final List<String> excludePatterns;
 
-  public ReplicationFilter(List<String> patterns) {
-    projectPatterns = patterns;
+  public ReplicationFilter(List<String> includePatterns, List<String> excludePatterns) {
+    projectPatterns = Objects.requireNonNullElse(includePatterns, Collections.emptyList());
+    this.excludePatterns = Objects.requireNonNullElse(excludePatterns, Collections.emptyList());
   }
 
   public boolean matches(Project.NameKey name) {
+    return matchesProjectPatterns(name) && !matchesExcludePatterns(name);
+  }
+
+  public boolean matchesProjectPatterns(Project.NameKey name) {
     if (projectPatterns.isEmpty()) {
       return true;
     }
@@ -60,6 +67,20 @@
     return false;
   }
 
+  public boolean matchesExcludePatterns(Project.NameKey name) {
+    if (excludePatterns.isEmpty()) {
+      return false;
+    }
+    String projectName = name.get();
+
+    for (String pattern : excludePatterns) {
+      if (matchesPattern(projectName, pattern)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
   private boolean matchesPattern(String projectName, String pattern) {
     boolean match = false;
     switch (getPatternType(pattern)) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/StartCommand.java b/src/main/java/com/googlesource/gerrit/plugins/replication/StartCommand.java
index 6d59283..57718bd 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/StartCommand.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/StartCommand.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
@@ -63,7 +64,9 @@
     }
 
     ReplicationFilter projectFilter =
-        all ? ReplicationFilter.all() : new ReplicationFilter(projectPatterns);
+        all
+            ? ReplicationFilter.all()
+            : new ReplicationFilter(projectPatterns, Collections.emptyList());
 
     replicationStarter.start(urlMatch, remotesToConsider, projectFilter, now, wait, this);
   }
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 4ec1124..821f56a 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -620,6 +620,21 @@
 	By default, replicates without matching, i.e. replicates
 	everything to all remotes.
 
+remote.NAME.excludeProjects
+:	Specifies which repositories should NOT be replicated to the
+	remote. It can be provided more than once, and supports the same
+	formats as `projects`, and may be specified in combination with
+	`projects`.
+
+	When both `projects` and `excludeProjects` are configured, a
+	project must match at least one `projects` pattern to be eligible
+	for replication, and must not match any `excludeProjects` pattern.
+	If a project matches both a `projects` pattern and an
+	`excludeProjects` pattern, it is excluded from replication.
+
+	By default, replicates without matching, i.e. replicates
+	everything to all remotes.
+
 <a name="remote.NAME.slowLatencyThreshold">remote.NAME.slowLatencyThreshold</a>
 :	the time duration after which the replication of a project to this
 	destination will be considered "slow". A slow project replication
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java
index 5013014..229808c 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java
@@ -283,7 +283,11 @@
             .getSysInjector()
             .getInstance(PushAll.Factory.class)
             .create(
-                null, Set.of(), new ReplicationFilter(Arrays.asList(project.get())), state, false)
+                null,
+                Set.of(),
+                new ReplicationFilter(Arrays.asList(project.get()), null),
+                state,
+                false)
             .schedule(0, TimeUnit.SECONDS);
 
     future.get();
@@ -304,7 +308,11 @@
             .getSysInjector()
             .getInstance(PushAll.Factory.class)
             .create(
-                null, Set.of(), new ReplicationFilter(Arrays.asList(project.get())), state, false)
+                null,
+                Set.of(),
+                new ReplicationFilter(Arrays.asList(project.get()), null),
+                state,
+                false)
             .schedule(0, TimeUnit.SECONDS);
 
     CountDownLatch latch = new CountDownLatch(1);
@@ -520,6 +528,95 @@
   }
 
   @Test
+  public void shouldReplicateWhenProjectNotExcluded() throws Exception {
+    Project.NameKey targetProject = createTestProject(project + "replica");
+
+    setReplicationDestination("foo", "replica", ALL_PROJECTS);
+    config.setString("remote", "foo", "excludeProjects", "excluded-prj");
+    config.save();
+    reloadConfig();
+
+    Result pushResult = createChange();
+    RevCommit sourceCommit = pushResult.getCommit();
+    String sourceRef = pushResult.getPatchSet().refName();
+
+    try (Repository repo = repoManager.openRepository(targetProject)) {
+      waitUntil(() -> checkedGetRef(repo, sourceRef) != null);
+
+      Ref targetBranchRef = getRef(repo, sourceRef);
+      assertThat(targetBranchRef).isNotNull();
+      assertThat(targetBranchRef.getObjectId()).isEqualTo(sourceCommit.getId());
+    }
+  }
+
+  @Test
+  public void shouldNotReplicateProjectMatchingExcludeProjects() throws Exception {
+    Project.NameKey prj1 = createTestProject("excludePrj1");
+    Project.NameKey prj2 = createTestProject("excludePrj2");
+    Project.NameKey prj3 = createTestProject("replicatePrj1");
+    Project.NameKey prj4 = createTestProject("replicatePrj2");
+
+    Project.NameKey targetPrj1 = createTestProject(prj1.get() + "replica");
+    Project.NameKey targetPrj2 = createTestProject(prj2.get() + "replica");
+    Project.NameKey targetPrj3 = createTestProject(prj3.get() + "replica");
+    Project.NameKey targetPrj4 = createTestProject(prj4.get() + "replica");
+
+    setReplicationDestination("foo", "replica", ALL_PROJECTS);
+    config.setString("remote", "foo", "excludeProjects", "^excludePrj.*");
+    config.save();
+    reloadConfig();
+
+    String newRef = "refs/heads/testBranch";
+    createNewBranchWithoutPush(prj1, newRef);
+    createNewBranchWithoutPush(prj2, newRef);
+    ObjectId replicateTip1 = createNewBranchWithoutPush(prj3, newRef);
+    ObjectId replicateTip2 = createNewBranchWithoutPush(prj4, newRef);
+
+    ReplicationQueue replicationQueue = plugin.getSysInjector().getInstance(ReplicationQueue.class);
+    ReplicationState state = new ReplicationState(NO_OP);
+    replicationQueue.scheduleFullSync(prj1, null, Set.of("foo"), state, true);
+    replicationQueue.scheduleFullSync(prj2, null, Set.of("foo"), state, true);
+    replicationQueue.scheduleFullSync(prj3, null, Set.of("foo"), state, true);
+    replicationQueue.scheduleFullSync(prj4, null, Set.of("foo"), state, true);
+
+    try (Repository excludeRepo1 = repoManager.openRepository(targetPrj1);
+        Repository excludeRepo2 = repoManager.openRepository(targetPrj2);
+        Repository replicateRepo1 = repoManager.openRepository(targetPrj3);
+        Repository replicateRepo2 = repoManager.openRepository(targetPrj4)) {
+      waitUntil(
+          () ->
+              checkedGetRef(replicateRepo1, newRef) != null
+                  && checkedGetRef(replicateRepo2, newRef) != null
+                  && checkedGetRef(excludeRepo1, newRef) == null
+                  && checkedGetRef(excludeRepo2, newRef) == null);
+
+      assertThat(getRef(replicateRepo1, newRef).getObjectId()).isEqualTo(replicateTip1);
+      assertThat(getRef(replicateRepo2, newRef).getObjectId()).isEqualTo(replicateTip2);
+      assertThat(getRef(excludeRepo1, newRef)).isNull();
+      assertThat(getRef(excludeRepo2, newRef)).isNull();
+    }
+  }
+
+  @Test
+  public void shouldNotReplicateProjectListedInProjectsAndExcludeProjects() throws Exception {
+    Project.NameKey targetProject = createTestProject(project + "replica");
+
+    setReplicationDestination("foo", "replica", Optional.of(project.get()));
+    config.setString("remote", "foo", "excludeProjects", project.get());
+    config.save();
+    reloadConfig();
+
+    Result pushResult = createChange();
+    String sourceRef = pushResult.getPatchSet().refName();
+
+    try (Repository repo = repoManager.openRepository(targetProject)) {
+      assertThrows(
+          InterruptedException.class,
+          () -> waitUntil(() -> checkedGetRef(repo, sourceRef) != null));
+    }
+  }
+
+  @Test
   public void shouldNotReplicateToNonMatchingRemote() throws Exception {
     Project.NameKey targetProject = createTestProject(project + "replica");
 
@@ -575,4 +672,25 @@
       return update.getNewObjectId();
     }
   }
+
+  private ObjectId createNewBranchWithoutPush(Project.NameKey projectKey, String newBranch)
+      throws Exception {
+    return createNewBranchWithoutPush(projectKey, "refs/heads/master", newBranch);
+  }
+
+  private ObjectId createNewBranchWithoutPush(
+      Project.NameKey projectKey, String fromBranch, String newBranch) throws Exception {
+    try (Repository repo = repoManager.openRepository(projectKey);
+        RevWalk walk = new RevWalk(repo)) {
+      Ref ref = repo.exactRef(fromBranch);
+      RevCommit tip = null;
+      if (ref != null) {
+        tip = walk.parseCommit(ref.getObjectId());
+      }
+      RefUpdate update = repo.updateRef(newBranch);
+      update.setNewObjectId(tip);
+      update.update(walk);
+      return update.getNewObjectId();
+    }
+  }
 }