FetchOne: consider order of create/delete events when replicating deletions

The previous logic for detecting which refs had to be deleted as a result
of a replication queue refspecs to be replicated did not take into
consideration the order of the create/delete events for the same ref.

As a result, the multiple create+delete of the same ref was potentially
causing missed replication events as it was only resulting into a
deletion.

Example:
1. create refs/foo/bar + delete refs/foo/bar => delete refs/foo/bar
2. delete refs/foo/bar + create refs/foo/bar => create refs/foo/bar

The step 2. was resulting into a delete refs/foo/bar instead of a create.

Make the extraction of the refs to delete more intelligent so that
all operations are considered and properly calculated in order for
getting the final results of the create + delete of the same ref.

Bug: Issue 394760809
Change-Id: I83d3b38cbb8e3391ca7af2af8ddea245258c4413
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchOne.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchOne.java
index e75c4c3..5e7fda2 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchOne.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchOne.java
@@ -17,6 +17,7 @@
 import static com.googlesource.gerrit.plugins.replication.pull.PullReplicationLogger.repLog;
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Throwables;
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
@@ -476,11 +477,7 @@
     try {
       List<FetchRefSpec> toFetch =
           fetchRefSpecs.stream().filter(rs -> rs.getSource() != null).toList();
-      Set<String> toDelete =
-          fetchRefSpecs.stream()
-              .filter(rs -> rs.getSource() == null)
-              .map(RefSpec::getDestination)
-              .collect(Collectors.toSet());
+      Set<String> toDelete = refsToDelete(fetchRefSpecs);
       updateStates(fetch.fetch(toFetch));
 
       // JGit doesn't support a fetch of <empty> to a ref (e.g. :refs/to/delete) therefore we have
@@ -512,6 +509,21 @@
     return fetchRefSpecs;
   }
 
+  @VisibleForTesting
+  static Set<String> refsToDelete(List<FetchRefSpec> fetchRefSpecs) {
+    final Set<String> refsToDelete = new HashSet<>();
+    fetchRefSpecs.forEach(
+        rs -> {
+          String refName = rs.refName();
+          if (rs.isDelete()) {
+            refsToDelete.add(refName);
+          } else {
+            refsToDelete.remove(refName);
+          }
+        });
+    return refsToDelete;
+  }
+
   /**
    * Return the list of refSpecs to fetch, possibly after having been filtered.
    *
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchRefSpec.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchRefSpec.java
index f85f22a..e41e194 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchRefSpec.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchRefSpec.java
@@ -44,6 +44,10 @@
     return MoreObjects.firstNonNull(getSource(), getDestination());
   }
 
+  public boolean isDelete() {
+    return getSource() == null;
+  }
+
   @Override
   public String toString() {
     return getSource() == null ? "<<DELETED>>:" + getDestination() : super.toString();
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/FetchOneTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/FetchOneTest.java
index 49e8d6f..73715d6 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/FetchOneTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/FetchOneTest.java
@@ -15,6 +15,7 @@
 package com.googlesource.gerrit.plugins.replication.pull;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.googlesource.gerrit.plugins.replication.pull.FetchOne.refsToDelete;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.anyList;
 import static org.mockito.Mockito.argThat;
@@ -850,6 +851,36 @@
     assertThat(testMetricMaker.getTimer("replication_latency")).isGreaterThan(0);
   }
 
+  @Test
+  public void shouldSkipDeletionWhenDeleteAndCreateOfSameRef() {
+    List<FetchRefSpec> fetchRefSpecs =
+        List.of(
+            FetchRefSpec.fromRef(":refs/something/someref"),
+            FetchRefSpec.fromRef("refs/something/someref"));
+    assertThat(refsToDelete(fetchRefSpecs)).isEmpty();
+  }
+
+  @Test
+  public void shouldDeleteWhenCreateAndDeleteOfSameRef() {
+    List<FetchRefSpec> fetchRefSpecs =
+        List.of(
+            FetchRefSpec.fromRef("refs/something/someref"),
+            FetchRefSpec.fromRef(":refs/something/someref"));
+    assertThat(refsToDelete(fetchRefSpecs)).isEqualTo(Set.of("refs/something/someref"));
+  }
+
+  @Test
+  public void shouldNotDeleteWhenCreateRef() {
+    List<FetchRefSpec> fetchRefSpecs = List.of(FetchRefSpec.fromRef("refs/something/someref"));
+    assertThat(refsToDelete(fetchRefSpecs)).isEmpty();
+  }
+
+  @Test
+  public void shouldDeleteWhenDeleteRef() {
+    List<FetchRefSpec> fetchRefSpecs = List.of(FetchRefSpec.fromRef(":refs/something/someref"));
+    assertThat(refsToDelete(fetchRefSpecs)).isEqualTo(Set.of("refs/something/someref"));
+  }
+
   private void setupRequestScopeMock() {
     when(scoper.scope(any()))
         .thenAnswer(