Merge "docs/config: document inverted matches cancel each others"
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 3d7ae36..41a9a49 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
@@ -85,6 +85,7 @@
 import java.util.concurrent.locks.ReadWriteLock;
 import java.util.function.Function;
 import java.util.function.Supplier;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Ref;
@@ -671,7 +672,7 @@
   }
 
   RunwayStatus requestRunway(PushOne op) {
-    stateLock.withWriteLock(
+    return stateLock.withWriteLock(
         op.getURI(),
         () -> {
           if (op.wasCanceled()) {
@@ -684,9 +685,8 @@
           }
           op.notifyNotAttempted(op.setStartedRefs(replicationTasksStorage.get().start(op)));
           queue.inFlight.put(op.getURI(), op);
-          return null;
+          return RunwayStatus.allowed();
         });
-    return RunwayStatus.allowed();
   }
 
   void notifyFinished(PushOne op) {
@@ -890,6 +890,10 @@
     return config.replicateNoteDbMetaRefs();
   }
 
+  ImmutableList<Pattern> excludedRefsPattern() {
+    return config.excludedRefsPattern();
+  }
+
   private static boolean matches(URIish uri, String urlMatch) {
     if (urlMatch == null || urlMatch.equals("") || urlMatch.equals("*")) {
       return true;
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 4d72ff0..41ab4c4 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationConfiguration.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationConfiguration.java
@@ -20,8 +20,12 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.config.ConfigUtil;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Supplier;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.transport.RemoteConfig;
 
@@ -51,6 +55,7 @@
   private final int maxRetries;
   private final int slowLatencyThreshold;
   private final Supplier<Integer> pushBatchSize;
+  private final ImmutableList<Pattern> excludedRefsPattern;
 
   protected DestinationConfiguration(RemoteConfig remoteConfig, Config cfg) {
     this.remoteConfig = remoteConfig;
@@ -116,6 +121,7 @@
               }
               return 0;
             });
+    excludedRefsPattern = getExcludedRefsPattern(cfg, name);
   }
 
   @Override
@@ -215,4 +221,21 @@
   public int getPushBatchSize() {
     return pushBatchSize.get();
   }
+
+  @Override
+  public ImmutableList<Pattern> excludedRefsPattern() {
+    return excludedRefsPattern;
+  }
+
+  private ImmutableList<Pattern> getExcludedRefsPattern(Config cfg, String name) {
+    List<Pattern> patterns = new ArrayList<>();
+    for (String regex : cfg.getStringList("remote", name, "excludedRefsPattern")) {
+      try {
+        patterns.add(Pattern.compile(regex));
+      } catch (PatternSyntaxException e) {
+        repLog.atWarning().log("Invalid excludedRefsPattern '%s' is ignored", regex);
+      }
+    }
+    return ImmutableList.copyOf(patterns);
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java b/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
index c2c2ed8..030ba1c 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
@@ -739,8 +739,12 @@
           srcRef = git.exactRef(src);
         }
 
-        if (srcRef != null && canPushRef(src, noPerms)) {
-          push(git, cmds, spec, srcRef);
+        if (srcRef != null) {
+          if (canPushRef(src, noPerms)) {
+            push(git, cmds, spec, srcRef);
+          } else {
+            repLog.atFine().log("Skipping push of ref %s", srcRef.getName());
+          }
         } else if (config.isMirror()) {
           delete(git, cmds, spec);
         }
@@ -752,7 +756,8 @@
   private boolean canPushRef(String ref, boolean noPerms) {
     return !(noPerms && RefNames.REFS_CONFIG.equals(ref))
         && !ref.startsWith(RefNames.REFS_CACHE_AUTOMERGE)
-        && !(!pool.replicateNoteDbMetaRefs() && RefNames.isNoteDbMetaRef(ref));
+        && !(!pool.replicateNoteDbMetaRefs() && RefNames.isNoteDbMetaRef(ref))
+        && pool.excludedRefsPattern().stream().noneMatch(p -> p.matcher(ref).matches());
   }
 
   private Map<String, Ref> listRemote(Transport tn)
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 e39afd6..67d23b0 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteConfiguration.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteConfiguration.java
@@ -14,6 +14,7 @@
 package com.googlesource.gerrit.plugins.replication;
 
 import com.google.common.collect.ImmutableList;
+import java.util.regex.Pattern;
 import org.eclipse.jgit.transport.RemoteConfig;
 
 /** Remote configuration for a replication endpoint */
@@ -142,4 +143,13 @@
     }
     return ret;
   }
+
+  /**
+   * List of patterns that will be used to exclude refs from being replicated
+   *
+   * @return list of successfully compiled patterns
+   */
+  default ImmutableList<Pattern> excludedRefsPattern() {
+    return ImmutableList.of();
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationLogFile.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationLogFile.java
index 60a9523..622034a 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationLogFile.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationLogFile.java
@@ -15,19 +15,47 @@
 package com.googlesource.gerrit.plugins.replication;
 
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
+import com.google.gerrit.server.config.LogConfig;
 import com.google.gerrit.server.util.PluginLogFile;
 import com.google.gerrit.server.util.SystemLog;
+import com.google.gerrit.util.logging.JsonLayout;
+import com.google.gerrit.util.logging.JsonLogEntry;
+import com.google.gson.annotations.SerializedName;
 import com.google.inject.Inject;
 import org.apache.log4j.PatternLayout;
+import org.apache.log4j.spi.LoggingEvent;
 
 public class ReplicationLogFile extends PluginLogFile {
 
   @Inject
-  public ReplicationLogFile(SystemLog systemLog, ServerInformation serverInfo) {
+  public ReplicationLogFile(SystemLog systemLog, ServerInformation serverInfo, LogConfig config) {
     super(
         systemLog,
         serverInfo,
         ReplicationQueue.REPLICATION_LOG_NAME,
-        new PatternLayout("[%d] %m%n"));
+        new PatternLayout("[%d] %m%n"),
+        new ReplicationJsonLayout(),
+        config);
+  }
+
+  static class ReplicationJsonLayout extends JsonLayout {
+    @SuppressWarnings("unused")
+    private class ReplicationJsonLogEntry extends JsonLogEntry {
+      public String timestamp;
+      public String message;
+
+      @SerializedName("@version")
+      public final int version = 1;
+
+      public ReplicationJsonLogEntry(LoggingEvent event) {
+        timestamp = timestampFormatter.format(event.getTimeStamp());
+        message = (String) event.getMessage();
+      }
+    }
+
+    @Override
+    public JsonLogEntry toJsonLogEntry(LoggingEvent event) {
+      return new ReplicationJsonLogEntry(event);
+    }
   }
 }
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index b5c4b36..f2954f5 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -612,6 +612,13 @@
 
   By default, true.
 
+remote.NAME.excludedRefsPattern
+: Refs that match the pattern provided using this config will not be replicated.
+  This option is useful when admins want to skip replicating certain refs, for
+  example refs created by plugins. Multiple excludedRefsPattern keys can be
+  supplied, to specify multiple patterns to match against.
+
+  Do not exclude any refs pattern by default.
 
 Directory `replication`
 --------------------
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/PushOneTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/PushOneTest.java
index 47470df..339123a 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/PushOneTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/PushOneTest.java
@@ -53,6 +53,7 @@
 import java.util.concurrent.Callable;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
 import org.eclipse.jgit.errors.NotSupportedException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.errors.TransportException;
@@ -75,6 +76,7 @@
 import org.eclipse.jgit.util.FS;
 import org.junit.Before;
 import org.junit.Test;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mockito;
 import org.mockito.invocation.InvocationOnMock;
 import org.mockito.stubbing.Answer;
@@ -294,6 +296,40 @@
   }
 
   @Test
+  public void skipPushingExcludedRefs() throws InterruptedException, IOException {
+    when(destinationMock.excludedRefsPattern())
+        .thenReturn(
+            ImmutableList.of(Pattern.compile("refs/foo/.*"), Pattern.compile("refs/bar/.*")));
+    PushOne pushOne = Mockito.spy(createPushOne(null));
+
+    Ref ref1 =
+        new ObjectIdRef.Unpeeled(
+            NEW,
+            "refs/heads/master",
+            ObjectId.fromString("0000000000000000000000000000000000000002"));
+    Ref ref2 =
+        new ObjectIdRef.Unpeeled(
+            NEW, "refs/foo/test", ObjectId.fromString("0000000000000000000000000000000000000003"));
+    Ref ref3 =
+        new ObjectIdRef.Unpeeled(
+            NEW, "refs/bar/test", ObjectId.fromString("0000000000000000000000000000000000000004"));
+
+    localRefs.add(ref1);
+    localRefs.add(ref2);
+    localRefs.add(ref3);
+
+    pushOne.addRefBatch(ImmutableSet.of(ref1.getName(), ref2.getName(), ref3.getName()));
+    pushOne.run();
+
+    isCallFinished.await(10, TimeUnit.SECONDS);
+
+    ArgumentCaptor<Ref> refCaptor = ArgumentCaptor.forClass(Ref.class);
+    verify(transportMock, atLeastOnce()).push(any(), any());
+    verify(pushOne, times(1)).push(any(), any(), any(), refCaptor.capture());
+    assertThat(refCaptor.getValue().getName()).isEqualTo("refs/heads/master");
+  }
+
+  @Test
   public void shouldNotAttemptDuplicateRemoteRefUpdate() throws InterruptedException, IOException {
     PushOne pushOne = Mockito.spy(createPushOne(null));
 
@@ -459,6 +495,7 @@
   private void setupDestinationMock() {
     destinationMock = mock(Destination.class);
     when(destinationMock.requestRunway(any())).thenReturn(RunwayStatus.allowed());
+    when(destinationMock.excludedRefsPattern()).thenReturn(ImmutableList.of());
   }
 
   private void setupPermissionBackedMock() {