Introduce rename replication feature

With this change rename project can replicate rename-project operation
to other replica/mirror instances using ssh command only available for
admin users.

The feature is based on how replication plugin does replication to
other replicas. SshHelper and ReplicationSshSessionFactoryProvider are
taken and adapted from replication plugin to perform ssh commands.

Feature: Issue 14049
Change-Id: Icbe98eb6af9bffc38f4b882c149b9092f9701f3c
diff --git a/src/main/java/com/googlesource/gerrit/plugins/renameproject/Configuration.java b/src/main/java/com/googlesource/gerrit/plugins/renameproject/Configuration.java
index ec87187..1a3570d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/renameproject/Configuration.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/renameproject/Configuration.java
@@ -14,24 +14,56 @@
 
 package com.googlesource.gerrit.plugins.renameproject;
 
+import com.google.common.base.CharMatcher;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
 
 @Singleton
 public class Configuration {
+  private static final int DEFAULT_SSH_CONNECTION_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
+  private static final String URL_KEY = "url";
 
   private final int indexThreads;
+  private final int sshCommandTimeout;
+  private final int sshConnectionTimeout;
+
+  private final Set<String> urls;
 
   @Inject
   public Configuration(PluginConfigFactory pluginConfigFactory, @PluginName String pluginName) {
     PluginConfig cfg = pluginConfigFactory.getFromGerritConfig(pluginName);
     indexThreads = cfg.getInt("indexThreads", 4);
+    sshCommandTimeout = cfg.getInt("sshCommandTimeout", 0);
+    sshConnectionTimeout = cfg.getInt("sshConnectionTimeout", DEFAULT_SSH_CONNECTION_TIMEOUT_MS);
+
+    urls =
+        Arrays.stream(cfg.getStringList(URL_KEY))
+            .filter(Objects::nonNull)
+            .filter(s -> !s.isEmpty())
+            .map(s -> CharMatcher.is('/').trimTrailingFrom(s))
+            .collect(Collectors.toSet());
   }
 
   public int getIndexThreads() {
     return indexThreads;
   }
+
+  public Set<String> getUrls() {
+    return urls;
+  }
+
+  public int getSshCommandTimeout() {
+    return sshCommandTimeout;
+  }
+
+  public int getSshConnectionTimeout() {
+    return sshConnectionTimeout;
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/renameproject/Module.java b/src/main/java/com/googlesource/gerrit/plugins/renameproject/Module.java
index 6962811..093609e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/renameproject/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/renameproject/Module.java
@@ -27,6 +27,7 @@
 import com.googlesource.gerrit.plugins.renameproject.database.DatabaseRenameHandler;
 import com.googlesource.gerrit.plugins.renameproject.database.IndexUpdateHandler;
 import com.googlesource.gerrit.plugins.renameproject.fs.FilesystemRenameHandler;
+import org.eclipse.jgit.transport.SshSessionFactory;
 
 public class Module extends AbstractModule {
 
@@ -45,5 +46,6 @@
     bind(RenamePreconditions.class);
     bind(IndexUpdateHandler.class);
     bind(RevertRenameProject.class);
+    bind(SshSessionFactory.class).toProvider(RenameReplicationSshSessionFactoryProvider.class);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/renameproject/RenameCommand.java b/src/main/java/com/googlesource/gerrit/plugins/renameproject/RenameCommand.java
index 14b5898..9c57418 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/renameproject/RenameCommand.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/renameproject/RenameCommand.java
@@ -16,6 +16,7 @@
 
 import static com.googlesource.gerrit.plugins.renameproject.RenameProject.WARNING_LIMIT;
 
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
@@ -33,7 +34,9 @@
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.util.List;
+import java.util.Optional;
 import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -45,6 +48,9 @@
   @Argument(index = 1, required = true, metaVar = "NEWNAME", usage = "new name for the project")
   private String newProjectName;
 
+  @Option(name = "--replication", usage = "perform only file system rename")
+  private boolean replication;
+
   private static final Logger log = LoggerFactory.getLogger(RenameCommand.class);
   private final RenameProject renameProject;
   private final Provider<ProjectCache> projectCacheProvider;
@@ -68,16 +74,26 @@
       ProjectResource rsrc =
           new ProjectResource(
               projectCacheProvider.get().get(new Project.NameKey(projectControl)), self.get());
-      try (CommandProgressMonitor monitor = new CommandProgressMonitor(stdout)) {
-        renameProject.assertCanRename(rsrc, input, monitor);
-        List<Change.Id> changeIds = renameProject.getChanges(rsrc, monitor);
-        if (continueRename(changeIds, monitor)) {
-          renameProject.doRename(changeIds, rsrc, input, monitor);
+
+      if (replication) {
+        if (renameProject.isAdmin()) {
+          renameProject.fsRenameStep(
+              rsrc.getNameKey(), new Project.NameKey(newProjectName), Optional.empty());
         } else {
-          String cancellationMsg = "Rename operation was cancelled by user.";
-          log.debug(cancellationMsg);
-          stdout.println(cancellationMsg);
-          stdout.flush();
+          throw new AuthException("Not allowed to replicate rename");
+        }
+      } else {
+        try (CommandProgressMonitor monitor = new CommandProgressMonitor(stdout)) {
+          renameProject.assertCanRename(rsrc, input, monitor);
+          List<Change.Id> changeIds = renameProject.getChanges(rsrc, monitor);
+          if (continueRename(changeIds, monitor)) {
+            renameProject.doRename(changeIds, rsrc, input, monitor);
+          } else {
+            String cancellationMsg = "Rename operation was cancelled by user.";
+            log.debug(cancellationMsg);
+            stdout.println(cancellationMsg);
+            stdout.flush();
+          }
         }
       }
     } catch (RestApiException | OrmException | IOException e) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/renameproject/RenameProject.java b/src/main/java/com/googlesource/gerrit/plugins/renameproject/RenameProject.java
index d1f1ba5..98b365e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/renameproject/RenameProject.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/renameproject/RenameProject.java
@@ -47,9 +47,13 @@
 import com.googlesource.gerrit.plugins.renameproject.fs.FilesystemRenameHandler;
 import com.googlesource.gerrit.plugins.renameproject.monitor.ProgressMonitor;
 import java.io.IOException;
+import java.io.OutputStream;
+import java.net.URISyntaxException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.transport.URIish;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -77,6 +81,8 @@
   private final PermissionBackend permissionBackend;
   private final Cache<Change.Id, String> changeIdProjectCache;
   private final RevertRenameProject revertRenameProject;
+  private final SshHelper sshHelper;
+  private final Configuration cfg;
 
   private List<Step> stepsPerformed;
 
@@ -94,7 +100,9 @@
       RenameLog renameLog,
       PermissionBackend permissionBackend,
       @Named(CACHE_NAME) Cache<Change.Id, String> changeIdProjectCache,
-      RevertRenameProject revertRenameProject) {
+      RevertRenameProject revertRenameProject,
+      SshHelper sshHelper,
+      Configuration cfg) {
     this.dbHandler = dbHandler;
     this.fsHandler = fsHandler;
     this.cacheHandler = cacheHandler;
@@ -108,6 +116,8 @@
     this.permissionBackend = permissionBackend;
     this.changeIdProjectCache = changeIdProjectCache;
     this.revertRenameProject = revertRenameProject;
+    this.sshHelper = sshHelper;
+    this.cfg = cfg;
     this.stepsPerformed = new ArrayList<>();
   }
 
@@ -123,14 +133,22 @@
     }
   }
 
+  private PermissionBackend.WithUser getUserPermissions() {
+    return permissionBackend.user(userProvider.get());
+  }
+
   protected boolean canRename(ProjectResource rsrc) {
-    PermissionBackend.WithUser userPermission = permissionBackend.user(userProvider.get());
-    return userPermission.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER)
+    PermissionBackend.WithUser userPermission = getUserPermissions();
+    return isAdmin()
         || userPermission.testOrFalse(new PluginPermission(pluginName, RENAME_PROJECT))
         || (userPermission.testOrFalse(new PluginPermission(pluginName, RENAME_OWN_PROJECT))
             && isOwner(rsrc));
   }
 
+  boolean isAdmin() {
+    return getUserPermissions().testOrFalse(GlobalPermission.ADMINISTRATE_SERVER);
+  }
+
   private boolean isOwner(ProjectResource project) {
     try {
       permissionBackend
@@ -167,7 +185,7 @@
     Exception ex = null;
     stepsPerformed.clear();
     try {
-      fsRenameStep(oldProjectKey, newProjectKey, pm);
+      fsRenameStep(oldProjectKey, newProjectKey, Optional.of(pm));
 
       cacheRenameStep(rsrc.getNameKey(), newProjectKey);
 
@@ -184,6 +202,9 @@
       changeIdProjectCache.invalidateAll(changeIds);
 
       pluginEvent.fire(pluginName, pluginName, oldProjectKey.get() + ":" + newProjectKey.get());
+
+      // replicate rename-project operation to other replica instances
+      replicateRename(sshHelper, input, oldProjectKey);
     } catch (Exception e) {
       if (stepsPerformed.isEmpty()) {
         log.error("Renaming procedure failed. Exception caught: {}", e.toString());
@@ -212,9 +233,9 @@
   }
 
   void fsRenameStep(
-      Project.NameKey oldProjectKey, Project.NameKey newProjectKey, ProgressMonitor pm)
+      Project.NameKey oldProjectKey, Project.NameKey newProjectKey, Optional<ProgressMonitor> opm)
       throws IOException {
-    fsHandler.rename(oldProjectKey, newProjectKey, pm);
+    fsHandler.rename(oldProjectKey, newProjectKey, opm);
     logPerformedStep(Step.FILESYSTEM, newProjectKey, oldProjectKey);
   }
 
@@ -282,4 +303,22 @@
     Project.NameKey oldProjectKey = rsrc.getNameKey();
     return dbHandler.getChangeIds(oldProjectKey);
   }
+
+  void replicateRename(SshHelper sshHelper, Input input, Project.NameKey oldProjectKey) {
+    for (String url : cfg.getUrls()) {
+      try {
+        OutputStream errStream = sshHelper.newErrorBufferStream();
+        sshHelper.executeRemoteSsh(
+            new URIish(url),
+            pluginName + " " + oldProjectKey.get() + " " + input.name + " --replication",
+            errStream);
+        String errorMessage = errStream.toString();
+        if (!errorMessage.isEmpty()) {
+          throw new RenameReplicationException(errorMessage);
+        }
+      } catch (IOException | URISyntaxException | RenameReplicationException e) {
+        log.error("Failed to replicate rename to {}: {}", url, e.getMessage());
+      }
+    }
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/renameproject/RenameReplicationException.java b/src/main/java/com/googlesource/gerrit/plugins/renameproject/RenameReplicationException.java
new file mode 100644
index 0000000..0df5f29
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/renameproject/RenameReplicationException.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2021 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.renameproject;
+
+/**
+ * Thrown when trying to replicate rename to a replica and encountered an error on the replica side.
+ */
+public class RenameReplicationException extends Exception {
+
+  private static final long serialVersionUID = 1L;
+
+  public RenameReplicationException(String message) {
+    super("Exception during rename replication: " + message);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/renameproject/RenameReplicationSshSessionFactoryProvider.java b/src/main/java/com/googlesource/gerrit/plugins/renameproject/RenameReplicationSshSessionFactoryProvider.java
new file mode 100644
index 0000000..bab2b46
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/renameproject/RenameReplicationSshSessionFactoryProvider.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2021 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.renameproject;
+
+import com.google.inject.Provider;
+import org.eclipse.jgit.transport.SshSessionFactory;
+
+class RenameReplicationSshSessionFactoryProvider implements Provider<SshSessionFactory> {
+
+  @Override
+  public SshSessionFactory get() {
+    return SshSessionFactory.getInstance();
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/renameproject/RevertRenameProject.java b/src/main/java/com/googlesource/gerrit/plugins/renameproject/RevertRenameProject.java
index 5de66f2..6d46552 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/renameproject/RevertRenameProject.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/renameproject/RevertRenameProject.java
@@ -28,6 +28,7 @@
 import java.io.IOException;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -62,7 +63,7 @@
     List<Change.Id> updatedChangeIds = Collections.emptyList();
     if (stepsPerformed.contains(Step.FILESYSTEM)) {
       try {
-        fsHandler.rename(newProjectKey, oldProjectKey, pm);
+        fsHandler.rename(newProjectKey, oldProjectKey, Optional.of(pm));
         log.debug("Reverted the git repo name to {} successfully.", oldProjectKey.get());
       } catch (IOException e) {
         log.error(
diff --git a/src/main/java/com/googlesource/gerrit/plugins/renameproject/SshHelper.java b/src/main/java/com/googlesource/gerrit/plugins/renameproject/SshHelper.java
new file mode 100644
index 0000000..ae3ec9f
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/renameproject/SshHelper.java
@@ -0,0 +1,93 @@
+// Copyright (C) 2017 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.renameproject;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.io.OutputStream;
+import org.eclipse.jgit.errors.TransportException;
+import org.eclipse.jgit.transport.RemoteSession;
+import org.eclipse.jgit.transport.SshSessionFactory;
+import org.eclipse.jgit.transport.URIish;
+import org.eclipse.jgit.util.FS;
+import org.eclipse.jgit.util.io.StreamCopyThread;
+
+/** Utility class that provides SSH access to remote URIs. */
+public class SshHelper {
+  private final Provider<SshSessionFactory> sshSessionFactoryProvider;
+  private final int commandTimeout;
+  private final int connectionTimeout;
+
+  @Inject
+  protected SshHelper(
+      Configuration replicationConfig, Provider<SshSessionFactory> sshSessionFactoryProvider) {
+    this.sshSessionFactoryProvider = sshSessionFactoryProvider;
+    this.commandTimeout = replicationConfig.getSshCommandTimeout();
+    this.connectionTimeout = replicationConfig.getSshConnectionTimeout();
+  }
+
+  public int executeRemoteSsh(URIish uri, String cmd, OutputStream errStream) throws IOException {
+    RemoteSession ssh = connect(uri);
+    Process proc = ssh.exec(cmd, commandTimeout);
+    proc.getOutputStream().close();
+    StreamCopyThread out = new StreamCopyThread(proc.getInputStream(), errStream);
+    StreamCopyThread err = new StreamCopyThread(proc.getErrorStream(), errStream);
+    out.start();
+    err.start();
+    try {
+      proc.waitFor();
+      out.halt();
+      err.halt();
+    } catch (InterruptedException interrupted) {
+      // Don't wait, drop out immediately.
+    }
+    ssh.disconnect();
+    return proc.exitValue();
+  }
+
+  public OutputStream newErrorBufferStream() {
+    return new OutputStream() {
+      private final StringBuilder out = new StringBuilder();
+      private final StringBuilder line = new StringBuilder();
+
+      @Override
+      public synchronized String toString() {
+        while (out.length() > 0 && out.charAt(out.length() - 1) == '\n') {
+          out.setLength(out.length() - 1);
+        }
+        return out.toString();
+      }
+
+      @Override
+      public synchronized void write(int b) {
+        if (b == '\r') {
+          return;
+        }
+
+        line.append((char) b);
+
+        if (b == '\n') {
+          out.append(line);
+          line.setLength(0);
+        }
+      }
+    };
+  }
+
+  protected RemoteSession connect(URIish uri) throws TransportException {
+    return sshSessionFactoryProvider.get().getSession(uri, null, FS.DETECTED, connectionTimeout);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/renameproject/fs/FilesystemRenameHandler.java b/src/main/java/com/googlesource/gerrit/plugins/renameproject/fs/FilesystemRenameHandler.java
index 29a76d8..1572d53 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/renameproject/fs/FilesystemRenameHandler.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/renameproject/fs/FilesystemRenameHandler.java
@@ -26,6 +26,7 @@
 import java.nio.file.Path;
 import java.nio.file.StandardCopyOption;
 import java.util.Comparator;
+import java.util.Optional;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Repository;
@@ -45,12 +46,12 @@
   }
 
   public void rename(
-      Project.NameKey oldProjectKey, Project.NameKey newProjectKey, ProgressMonitor pm)
+      Project.NameKey oldProjectKey, Project.NameKey newProjectKey, Optional<ProgressMonitor> opm)
       throws IOException, RepositoryNotFoundException {
     Repository repository = repoManager.openRepository(oldProjectKey);
     File repoFile = repository.getDirectory();
     RepositoryCache.close(repository);
-    pm.beginTask("Renaming git repository");
+    opm.ifPresent(pm -> pm.beginTask("Renaming git repository"));
     renameGitRepository(repoFile, newProjectKey, oldProjectKey);
   }
 
diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md
index da04a05..e0bd0b6 100644
--- a/src/main/resources/Documentation/about.md
+++ b/src/main/resources/Documentation/about.md
@@ -35,14 +35,24 @@
 Replication of project renaming
 -------------------------------
 
-This plugin does not replicate any project renamings itself, but it triggers
-an event when a project is renamed. The [replication plugin]
-(https://gerrit-review.googlesource.com/#/admin/projects/plugins/replication)
-is configured to listen to the event of type `PluginEvent`, which provides
-replication plugin with the required information in order to replicate the
-project rename functionality. `Rename-project` does not provide any custom
-event. Instead, it uses the existing `PluginEvent` which allows communication
-between two plugins directly.
+This plugin can replicate project renaming by itself, if `gerrit.config` has a `url` entry at the
+plugin configuration section and if master and all other replicas have this plugin installed. Once
+configured, replication of rename will start on every successful renaming of a local project. When
+the plugin completes the renaming operation on the master instance successfully, it sends an ssh
+command to replicas' rename-project plugin using the hostname provided in the configuration file.
+Replicas then perform their own local file system rename.
+
+The caveat is if ssh rename replication fails, the plugin doesn't retry the rename replication
+operation. This results in primary and replica instances being out of sync. The admin then will have
+to consider the following steps:
+
+1. Start replication on the renamed project from the primary side. For more information, see the
+[replication start command](../../replication/Documentation/cmd-start.md).
+
+2. Confirm that the renamed project was replicated.
+
+3. Delete the original (not renamed) project repository directory contents on the replica side; this
+step is optional.
 
 Access
 ------
@@ -54,3 +64,6 @@
 is granted the 'Rename Own Project' capability (provided by this
 plugin). However, because of all the caveats of this plugin, it is not
 recommended to delegate the 'Rename Project' capability to any non-admin user.
+
+As previously described, this plugin is capable of performing only file system rename, if a user is
+an admin user and if the `--replication` option is used.
diff --git a/src/main/resources/Documentation/cmd-rename.md b/src/main/resources/Documentation/cmd-rename.md
index 08c6974..0f54eb6 100644
--- a/src/main/resources/Documentation/cmd-rename.md
+++ b/src/main/resources/Documentation/cmd-rename.md
@@ -11,6 +11,7 @@
 ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@
   <PROJECT>
   <NEWNAME>
+  [--replication]
 ```
 
 DESCRIPTION
@@ -28,6 +29,15 @@
 ---------
 This command is intended to be used in scripts.
 
+
+OPTIONS
+-------
+`--replication`
+:   To perform only file system rename. This option is used for replication of
+    rename operation to other replica instances. This command should not be used
+    towards non-replica instances. This option requires the user to have admin
+    permissions.
+
 EXAMPLES
 --------
 
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 3d5b26d..7a5faeb 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -13,3 +13,33 @@
   [plugin "@PLUGIN@"]
     indexThreads = 4
 ```
+
+Rename project replication is enabled by adding appropriate `url`'s.
+For example:
+
+```
+  [plugin "@PLUGIN@"]
+    url = ssh://admin@mirror1.us.some.org
+    url = ssh://mirror2.us.some.org:29418
+```
+
+To specify the port number, it is required to put the `ssh://` prefix followed by hostname and then
+port number after `:`. It is also possible to specify the ssh user by passing `USERNAME@` as a
+prefix for hostname.
+
+Rename replication is done over SSH, so ensure the host key of the remote system(s) is already in
+the Gerrit user's `~/.ssh/known_hosts` file. The easiest way to add the host key is to connect once
+by hand with the command line:
+
+```
+  sudo su -c 'ssh mirror1.us.some.org echo' gerrit2
+```
+
+@PLUGIN@ plugin uses the ssh rename command towards the replica(s) with `--replication` option to
+replicate the rename operation. It is possible to customize the parameters of the underlying ssh
+client doing these calls by specifying the following fields:
+
+* `sshCommandTimeout` : Timeout for SSH command execution. If 0, there is no timeout, and
+the client waits indefinitely. By default, 0.
+* `sshConnectionTimeout` : Timeout for SSH connections in minutes. If 0, there is no timeout, and
+the client waits indefinitely. By default, 2 minutes.
diff --git a/src/test/java/com/googlesource/gerrit/plugins/renameproject/RenameIT.java b/src/test/java/com/googlesource/gerrit/plugins/renameproject/RenameIT.java
index 6e23206..01a7be9 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/renameproject/RenameIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/renameproject/RenameIT.java
@@ -15,8 +15,14 @@
 package com.googlesource.gerrit.plugins.renameproject;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 import com.google.common.cache.Cache;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.TestPlugin;
@@ -28,9 +34,12 @@
 import com.google.gerrit.reviewdb.client.Project.NameKey;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.renameproject.RenameProject.Input;
+import java.io.OutputStream;
 import java.util.List;
 import javax.inject.Named;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.transport.URIish;
 import org.junit.Test;
 
 @TestPlugin(
@@ -44,6 +53,8 @@
   private static final String NEW_PROJECT_NAME = "newProject";
   private static final String NON_EXISTING_NAME = "nonExistingProject";
   private static final String CACHE_NAME = "changeid_project";
+  private static final String REPLICATION_OPTION = "--replication";
+  private static final String URL = "ssh://localhost:29418";
 
   @Inject
   @Named(CACHE_NAME)
@@ -64,6 +75,29 @@
 
   @Test
   @UseLocalDisk
+  public void testRenameReplicationViaSshNotAdminUser() throws Exception {
+    createChange();
+    userSshSession.exec(
+        PLUGIN_NAME + " " + project.get() + " " + NEW_PROJECT_NAME + " " + REPLICATION_OPTION);
+
+    userSshSession.assertFailure();
+    assertThat(userSshSession.getError()).contains("Not allowed to replicate rename");
+  }
+
+  @Test
+  @UseLocalDisk
+  public void testRenameReplicationViaSshAdminUser() throws Exception {
+    createChange();
+    adminSshSession.exec(
+        PLUGIN_NAME + " " + project.get() + " " + NEW_PROJECT_NAME + " " + REPLICATION_OPTION);
+
+    adminSshSession.assertSuccess();
+    ProjectState projectState = projectCache.get(new Project.NameKey(NEW_PROJECT_NAME));
+    assertThat(projectState).isNotNull();
+  }
+
+  @Test
+  @UseLocalDisk
   public void testRenameViaSshWithEmptyNewName() throws Exception {
     createChange();
     String newProjectName = "";
@@ -156,4 +190,25 @@
 
     assertThat(changeIdProjectCache.getIfPresent(changeID)).isNull();
   }
+
+  @Test
+  @UseLocalDisk
+  @GerritConfig(name = "plugin.rename-project.url", value = URL)
+  public void testReplicateRename() throws Exception {
+    RenameProject renameProject = plugin.getSysInjector().getInstance(RenameProject.class);
+    SshHelper sshHelper = mock(SshHelper.class);
+    OutputStream errStream = mock(OutputStream.class);
+
+    Input input = new Input();
+    input.name = NEW_PROJECT_NAME;
+    String expectedCommand =
+        PLUGIN_NAME + " " + project.get() + " " + NEW_PROJECT_NAME + " " + REPLICATION_OPTION;
+
+    when(sshHelper.newErrorBufferStream()).thenReturn(errStream);
+    when(errStream.toString()).thenReturn("");
+
+    renameProject.replicateRename(sshHelper, input, project);
+    verify(sshHelper, atLeastOnce())
+        .executeRemoteSsh(eq(new URIish(URL)), eq(expectedCommand), eq(errStream));
+  }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/renameproject/RevertRenameProjectTest.java b/src/test/java/com/googlesource/gerrit/plugins/renameproject/RevertRenameProjectTest.java
index d060414..a027c39 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/renameproject/RevertRenameProjectTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/renameproject/RevertRenameProjectTest.java
@@ -30,6 +30,7 @@
 import com.googlesource.gerrit.plugins.renameproject.RenameProject.Step;
 import com.googlesource.gerrit.plugins.renameproject.monitor.ProgressMonitor;
 import java.util.List;
+import java.util.Optional;
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.Mockito;
@@ -68,7 +69,7 @@
     Result result = createChange();
     List<Change.Id> changeIds = renameProject.getChanges(oldRsrc, pm);
 
-    renameProject.fsRenameStep(oldProjectKey, newProjectKey, pm);
+    renameProject.fsRenameStep(oldProjectKey, newProjectKey, Optional.of(pm));
     assertRenamed(result);
 
     revertRenameProject.performRevert(
@@ -82,7 +83,7 @@
     Result result = createChange();
     List<Change.Id> changeIds = renameProject.getChanges(oldRsrc, pm);
 
-    renameProject.fsRenameStep(oldProjectKey, newProjectKey, pm);
+    renameProject.fsRenameStep(oldProjectKey, newProjectKey, Optional.of(pm));
     renameProject.cacheRenameStep(oldProjectKey, newProjectKey);
     assertRenamed(result);
 
@@ -97,7 +98,7 @@
     Result result = createChange();
     List<Change.Id> changeIds = renameProject.getChanges(oldRsrc, pm);
 
-    renameProject.fsRenameStep(oldProjectKey, newProjectKey, pm);
+    renameProject.fsRenameStep(oldProjectKey, newProjectKey, Optional.of(pm));
     renameProject.cacheRenameStep(oldProjectKey, newProjectKey);
     renameProject.dbRenameStep(changeIds, oldProjectKey, newProjectKey, pm);
     assertRenamed(result);
@@ -113,7 +114,7 @@
     Result result = createChange();
     List<Change.Id> changeIds = renameProject.getChanges(oldRsrc, pm);
 
-    renameProject.fsRenameStep(oldProjectKey, newProjectKey, pm);
+    renameProject.fsRenameStep(oldProjectKey, newProjectKey, Optional.of(pm));
     renameProject.cacheRenameStep(oldProjectKey, newProjectKey);
     renameProject.dbRenameStep(changeIds, oldProjectKey, newProjectKey, pm);
     renameProject.indexRenameStep(changeIds, oldProjectKey, newProjectKey, pm);