Add mirror replication option for JGit client

Remove branches and tags when this option is set to true.

This option is currently only implemented for JGit.

Bug: Issue 40015295
Change-Id: I3aa62c58f36f0d095bff40fe03688677b6a113d3
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/transport/TransportProvider.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/transport/TransportProvider.java
index c52db82..ec7f655 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/transport/TransportProvider.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/transport/TransportProvider.java
@@ -56,6 +56,7 @@
       throws NotSupportedException, TransportException {
     Transport tn = Transport.open(local, uri);
     tn.applyConfig(remoteConfig);
+    tn.setRemoveDeletedRefs(remoteConfig.isMirror());
     if (tn instanceof TransportHttp && bearerToken.isPresent()) {
       ((TransportHttp) tn)
           .setAdditionalHeaders(ImmutableMap.of(HDR_AUTHORIZATION, "Bearer " + bearerToken.get()));
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 0c3e02c..b7db2c0 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -523,6 +523,14 @@
 
 	By default, false, do *not* replicate project deletions.
 
+remote.NAME.mirror
+:	If true, replication will remove local branches and tags that are
+absent remotely or invisible to the replication (for example read access
+denied via `authGroup` option). Note that this option is currently
+implemented for the JGit client only.
+
+	By default, false, do not remove remote branches or tags.
+
 remote.NAME.authGroup
 :	Specifies the name of a group that the remote should use to
 	access the repositories. Multiple authGroups may be specified
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/JGitFetchIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/JGitFetchIT.java
index 76ff02b..e5cd33d 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/JGitFetchIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/JGitFetchIT.java
@@ -14,11 +14,21 @@
 
 package com.googlesource.gerrit.plugins.replication.pull;
 
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.GitUtil.deleteRef;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+
 import com.google.common.collect.Lists;
+import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.SkipProjectClone;
 import com.google.gerrit.acceptance.TestPlugin;
 import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.inject.Inject;
 import com.google.inject.Scopes;
 import com.google.inject.assistedinject.FactoryModuleBuilder;
 import com.googlesource.gerrit.plugins.replication.AutoReloadSecureCredentialsFactoryDecorator;
@@ -30,12 +40,20 @@
 import com.googlesource.gerrit.plugins.replication.pull.fetch.FetchFactory;
 import com.googlesource.gerrit.plugins.replication.pull.fetch.JGitFetch;
 import com.googlesource.gerrit.plugins.replication.pull.fetch.PermanentTransportException;
+import com.googlesource.gerrit.plugins.replication.pull.fetch.RefUpdateState;
+import java.io.IOException;
 import java.net.URISyntaxException;
+import java.util.List;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.transport.RemoteConfig;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.eclipse.jgit.transport.URIish;
+import org.junit.Before;
 import org.junit.Test;
 
 @SkipProjectClone
@@ -46,6 +64,17 @@
 public class JGitFetchIT extends FetchITBase {
   private static final String TEST_REPLICATION_SUFFIX = "suffix1";
   private static final String TEST_TASK_ID = "taskid";
+  private static final RefSpec ALL_REFS = new RefSpec("+refs/*:refs/*");
+
+  @Inject private ProjectOperations projectOperations;
+
+  @Before
+  public void allowRefDeletion() {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allow(Permission.DELETE).ref("refs/*").group(adminGroupUuid()))
+        .update();
+  }
 
   @Test(expected = PermanentTransportException.class)
   public void shouldThrowPermanentTransportExceptionWhenRefDoesNotExists() throws Exception {
@@ -59,12 +88,67 @@
     }
   }
 
+  @Test
+  public void shouldPruneRefsWhenMirrorIsTrue() throws Exception {
+    testRepo = cloneProject(createTestProject(project + TEST_REPLICATION_SUFFIX));
+    String branchName = "anyBranch";
+    String branchRef = Constants.R_HEADS + branchName;
+    String tagName = "anyTag";
+    String tagRef = Constants.R_TAGS + tagName;
+
+    PushOneCommit.Result branchPush = pushFactory.create(user.newIdent(), testRepo).to(branchRef);
+    branchPush.assertOkStatus();
+
+    PushResult tagPush = pushHead(testRepo, tagRef, false, false);
+    assertOkStatus(tagPush, tagRef);
+
+    try (Repository localRepo = repoManager.openRepository(project)) {
+      List<RefUpdateState> fetchCreated = fetchAllRefs(localRepo);
+      assertThat(fetchCreated.toString())
+          .contains(new RefUpdateState(branchRef, RefUpdate.Result.NEW).toString());
+      assertThat(fetchCreated.toString())
+          .contains(new RefUpdateState(tagRef, RefUpdate.Result.NEW).toString());
+
+      assertThat(getRef(localRepo, branchRef)).isNotNull();
+
+      PushResult deleteBranchResult = deleteRef(testRepo, branchRef);
+      assertOkStatus(deleteBranchResult, branchRef);
+
+      PushResult deleteTagResult = deleteRef(testRepo, tagRef);
+      assertOkStatus(deleteTagResult, tagRef);
+
+      List<RefUpdateState> fetchDeleted = fetchAllRefs(localRepo);
+      assertThat(fetchDeleted.toString())
+          .contains(new RefUpdateState(branchRef, RefUpdate.Result.FORCED).toString());
+      assertThat(getRef(localRepo, branchRef)).isNull();
+
+      assertThat(fetchDeleted.toString())
+          .contains(new RefUpdateState(tagRef, RefUpdate.Result.FORCED).toString());
+      assertThat(getRef(localRepo, tagRef)).isNull();
+    }
+  }
+
+  private List<RefUpdateState> fetchAllRefs(Repository localRepo)
+      throws URISyntaxException, IOException {
+    Fetch fetch = fetchFactory.create(TEST_TASK_ID, new URIish(testRepoPath.toString()), localRepo);
+    return fetch.fetch(Lists.newArrayList(ALL_REFS));
+  }
+
+  private static void assertOkStatus(PushResult result, String ref) {
+    RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
+    assertThat(refUpdate).isNotNull();
+    assertWithMessage(refUpdate.getMessage())
+        .that(refUpdate.getStatus())
+        .isEqualTo(RemoteRefUpdate.Status.OK);
+  }
+
   @SuppressWarnings("unused")
   private static class TestModule extends FactoryModule {
     @Override
     protected void configure() {
       Config cf = new Config();
       cf.setInt("remote", "test_config", "timeout", 0);
+      cf.setBoolean("remote", "test_config", "mirror", true);
       try {
         RemoteConfig remoteConfig = new RemoteConfig(cf, "test_config");
         SourceConfiguration sourceConfig = new SourceConfiguration(remoteConfig, cf);