Add SSH command to repair projects

Introduce a new 'replication repair PROJECT --copy-packs' SSH command
that copies the pack files for a single project to desired destinations
and then triggers a 'replication start'.

Change-Id: Ic9f55e42f195a1261ae076c9fdc9a320b75b292a
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java
index 5e2c758..0c77b16 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java
@@ -125,6 +125,11 @@
   }
 
   @Override
+  public String getRsyncPath() {
+    return currentConfig.getRsyncPath();
+  }
+
+  @Override
   public Config getConfig() {
     return currentConfig.getConfig();
   }
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 9c1bfba..273f7e8 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
@@ -910,7 +910,7 @@
     return config.getGitPath();
   }
 
-  private static boolean matches(URIish uri, String urlMatch) {
+  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/DestinationsCollection.java b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java
index 471a408..6eb4d3f 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java
@@ -30,6 +30,7 @@
 import com.google.common.eventbus.EventBus;
 import com.google.common.eventbus.Subscribe;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Inject;
@@ -83,7 +84,10 @@
 
   @Override
   public Multimap<Destination, URIish> getURIs(
-      Optional<String> remoteName, Project.NameKey projectName, FilterType filterType) {
+      Optional<String> remoteName,
+      Project.NameKey projectName,
+      FilterType filterType,
+      @Nullable String urlMatch) {
     if (getAll(filterType).isEmpty()) {
       return ImmutableMultimap.of();
     }
@@ -113,6 +117,7 @@
           continue;
         }
 
+        boolean matchesConfigUrl = Destination.matches(uri, urlMatch);
         if (!isGerrit(uri) && !isGerritHttp(uri)) {
           String path =
               replaceName(uri.getPath(), projectName.get(), config.isSingleProjectMatch());
@@ -128,12 +133,14 @@
             continue;
           }
         }
-        uris.put(config, uri);
-        adminURLUsed = true;
+        if (matchesConfigUrl || Destination.matches(uri, urlMatch)) {
+          uris.put(config, uri);
+          adminURLUsed = true;
+        }
       }
 
       if (!adminURLUsed) {
-        for (URIish uri : config.getURIs(projectName, "*")) {
+        for (URIish uri : config.getURIs(projectName, urlMatch)) {
           uris.put(config, uri);
         }
       }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/PushResultProcessing.java b/src/main/java/com/googlesource/gerrit/plugins/replication/PushResultProcessing.java
index 6ed47d4..8d279d5 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/PushResultProcessing.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/PushResultProcessing.java
@@ -90,11 +90,17 @@
     return sb.toString();
   }
 
+  public interface SshOutputCommand {
+    void writeStdOutSync(String message);
+
+    void writeStdErrSync(String message);
+  }
+
   public static class CommandProcessing implements PushResultProcessing {
-    private WeakReference<StartCommand> sshCommand;
+    private WeakReference<SshOutputCommand> sshCommand;
     private AtomicBoolean hasError = new AtomicBoolean();
 
-    CommandProcessing(StartCommand sshCommand) {
+    CommandProcessing(SshOutputCommand sshCommand) {
       this.sshCommand = new WeakReference<>(sshCommand);
     }
 
@@ -162,7 +168,7 @@
 
     @Override
     public void writeStdOut(String message) {
-      StartCommand command = sshCommand.get();
+      SshOutputCommand command = sshCommand.get();
       if (command != null) {
         command.writeStdOutSync(message);
       }
@@ -170,7 +176,7 @@
 
     @Override
     public void writeStdErr(String message) {
-      StartCommand command = sshCommand.get();
+      SshOutputCommand command = sshCommand.get();
       if (command != null) {
         command.writeStdErrSync(message);
       }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/RepairCommand.java b/src/main/java/com/googlesource/gerrit/plugins/replication/RepairCommand.java
new file mode 100644
index 0000000..b70bb0f
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/RepairCommand.java
@@ -0,0 +1,269 @@
+// Copyright (C) 2026 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.replication;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.URIish;
+import org.eclipse.jgit.util.QuotedString;
+import org.eclipse.jgit.util.io.StreamCopyThread;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+
+@RequiresCapability(StartReplicationCapability.START_REPLICATION)
+@CommandMetaData(name = "repair", description = "Repair a project on replication destinations")
+final class RepairCommand extends SshCommand implements PushResultProcessing.SshOutputCommand {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  @Argument(index = 0, required = true, metaVar = "PROJECT", usage = "project name")
+  private String projectName;
+
+  @Option(
+      name = "--url",
+      metaVar = "SUBSTRING",
+      usage = "substring URL must match (or * to match everything)")
+  private String urlMatch;
+
+  @Option(
+      name = "--copy-packs",
+      usage = "rsync objects/pack files to SSH destinations before triggering replication")
+  private boolean copyPacks;
+
+  @Option(name = "--full", usage = "run all supported repair actions (default)")
+  private boolean full;
+
+  @Inject private GitRepositoryManager gitManager;
+  @Inject private ProjectCache projectCache;
+  @Inject private ReplicationDestinations destinations;
+  @Inject private ReplicationStarter replicationStarter;
+  @Inject private ReplicationConfig replicationConfig;
+
+  private final Object outputLock = new Object();
+
+  @Override
+  protected void run() throws Failure {
+    Project.NameKey project = Project.nameKey(projectName);
+    try {
+      if (projectCache.get(project).isEmpty()) {
+        throw die("Project with name " + projectName + " not found.");
+      }
+    } catch (StorageException e) {
+      throw die(e);
+    }
+
+    if (!copyPacks) {
+      full = true;
+    }
+
+    Set<URIish> failedUris = new HashSet<>();
+    if (full || copyPacks) {
+      failedUris.addAll(copyPacks(project));
+    }
+
+    if (!failedUris.isEmpty()) {
+      throw new UnloggedFailure(1, "Repair failed for " + failedUris.size() + " destination(s)");
+    }
+
+    writeStdOutSync("\nRunning replication start for " + project.get() + " ...");
+    replicationStarter.start(
+        urlMatch,
+        Set.of(),
+        new ReplicationFilter(List.of(project.get())),
+        /* now= */ true,
+        /* wait= */ true,
+        this);
+  }
+
+  private Set<URIish> copyPacks(Project.NameKey project) throws Failure {
+    Path packDir;
+    try (Repository repo = gitManager.openRepository(project)) {
+      packDir = repo.getDirectory().toPath().resolve("objects").resolve("pack");
+    } catch (IOException e) {
+      throw die(e);
+    }
+
+    if (!Files.isDirectory(packDir)) {
+      throw die("No objects/pack directory for project " + projectName);
+    }
+
+    Set<URIish> copyTargets = new HashSet<>();
+    Collection<URIish> destUris =
+        destinations
+            .getURIs(Optional.empty(), project, ReplicationConfig.FilterType.ALL, urlMatch)
+            .values();
+    for (URIish uri : destUris) {
+      if (!canCopy(uri)) {
+        writeStdErrSync(
+            "Warning: skipping " + uri + " as copy-packs only supports plain SSH destinations");
+        continue;
+      }
+      copyTargets.add(uri);
+    }
+
+    if (copyTargets.isEmpty()) {
+      throw die("No matching destinations found");
+    }
+
+    Set<URIish> failedUris = new HashSet<>();
+    for (URIish uri : copyTargets) {
+      writeStdOutSync("Copying pack files to " + uri + " ...");
+      if (!copyInOrder(packDir, uri)) {
+        failedUris.add(uri);
+      }
+    }
+    return failedUris;
+  }
+
+  private boolean copyInOrder(Path packDir, URIish uri) throws Failure {
+    return copyWithDie(packDir, uri, "*.pack")
+        && copyWithDie(packDir, uri, "*.idx", "*.bitmap", "*.rev");
+  }
+
+  private boolean copyWithDie(Path src, URIish uri, String... includes) throws Failure {
+    int retCode;
+    try {
+      retCode = copy(src, uri, includes);
+    } catch (IOException e) {
+      throw die(e);
+    } catch (InterruptedException e) {
+      throw die("Interrupted during copy to " + uri, e);
+    }
+    if (retCode != 0) {
+      writeStdErrSync("Warning: copy to " + uri + " failed with exit code " + retCode);
+      return false;
+    }
+    return true;
+  }
+
+  private static boolean canCopy(URIish uri) {
+    return AdminApiFactory.isSSH(uri) && !AdminApiFactory.isGerrit(uri);
+  }
+
+  private int copy(Path src, URIish uri, String... includes)
+      throws IOException, InterruptedException, UnloggedFailure {
+    List<String> cmd = new ArrayList<>();
+    cmd.add(replicationConfig.getRsyncPath());
+    cmd.add("-avP");
+    cmd.add("-e");
+    cmd.add(buildSshTransport(uri));
+    for (String inc : includes) {
+      cmd.add("--include=" + inc);
+    }
+    cmd.add("--exclude=*");
+    cmd.add(src.toAbsolutePath().normalize() + "/");
+    cmd.add(buildCopyDestination(uri));
+
+    logger.atInfo().log("Running repair cmd: %s", String.join(" ", cmd));
+
+    ProcessBuilder pb = new ProcessBuilder(cmd);
+    pb.redirectErrorStream(true);
+    Process p = pb.start();
+
+    stdout.flush();
+    StreamCopyThread outStream =
+        new StreamCopyThread(p.getInputStream(), getFlushingOutputStream());
+    outStream.setName("copy-packs-output");
+    outStream.start();
+    try {
+      int code = p.waitFor();
+      outStream.join();
+      return code;
+    } catch (InterruptedException e) {
+      p.destroyForcibly();
+      outStream.halt();
+      throw e;
+    }
+  }
+
+  private OutputStream getFlushingOutputStream() {
+    return new OutputStream() {
+      @Override
+      public void write(byte[] b, int off, int len) throws IOException {
+        out.write(b, off, len);
+        out.flush();
+      }
+
+      @Override
+      public void write(int b) throws IOException {
+        out.write(b);
+        out.flush();
+      }
+    };
+  }
+
+  private String buildCopyDestination(URIish uri) throws UnloggedFailure {
+    String host = uri.getHost();
+    if (host == null || host.isEmpty()) {
+      throw die("URI has no host: " + uri);
+    }
+
+    String path = uri.getPath();
+    if (path == null || path.isEmpty()) {
+      throw die("URI has no path: " + uri);
+    }
+
+    String remotePackPath = QuotedString.BOURNE.quote(path + "/objects/pack/");
+
+    String user = uri.getUser();
+    if (user != null && !user.isEmpty()) {
+      return user + "@" + host + ":" + remotePackPath;
+    }
+    return host + ":" + remotePackPath;
+  }
+
+  private static String buildSshTransport(URIish uri) {
+    StringBuilder sb = new StringBuilder("ssh -o BatchMode=yes");
+    int port = uri.getPort();
+    if (port > 0) {
+      sb.append(" -p ").append(port);
+    }
+    return sb.toString();
+  }
+
+  @Override
+  public void writeStdOutSync(String message) {
+    synchronized (outputLock) {
+      stdout.println(message);
+      stdout.flush();
+    }
+  }
+
+  @Override
+  public void writeStdErrSync(String message) {
+    synchronized (outputLock) {
+      stderr.println(message);
+      stderr.flush();
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java
index 8f9b805..f625232 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java
@@ -28,6 +28,7 @@
 
 public class ReplicationConfigImpl implements ReplicationConfig {
   private static final int DEFAULT_SSH_CONNECTION_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
+  private static final String DEFAULT_RSYNC_PATH = "rsync";
 
   private final SitePaths site;
   private final MergedConfigResource configResource;
@@ -145,4 +146,10 @@
   public int getSshCommandTimeout() {
     return sshCommandTimeout;
   }
+
+  @Override
+  public String getRsyncPath() {
+    String rsyncPath = getConfig().getString("replication", null, "rsyncPath");
+    return Strings.isNullOrEmpty(rsyncPath) ? DEFAULT_RSYNC_PATH : rsyncPath;
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationDestinations.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationDestinations.java
index bcc07e5..91ced84 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationDestinations.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationDestinations.java
@@ -15,6 +15,7 @@
 package com.googlesource.gerrit.plugins.replication;
 
 import com.google.common.collect.Multimap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.WorkQueue;
 import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig.FilterType;
@@ -32,10 +33,19 @@
    * @param remoteName name of the replication end or empty if selecting all ends.
    * @param projectName name of the project
    * @param filterType type of filter criteria for selecting projects
+   * @param urlMatch optional substring filter on configuration or expanded URLs; null matches all
    * @return the multi-map of destinations and the associated replication URIs
    */
   Multimap<Destination, URIish> getURIs(
-      Optional<String> remoteName, Project.NameKey projectName, FilterType filterType);
+      Optional<String> remoteName,
+      Project.NameKey projectName,
+      FilterType filterType,
+      @Nullable String urlMatch);
+
+  default Multimap<Destination, URIish> getURIs(
+      Optional<String> remoteName, Project.NameKey projectName, FilterType filterType) {
+    return getURIs(remoteName, projectName, filterType, null);
+  }
 
   /**
    * List of currently active replication destinations.
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationStarter.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationStarter.java
new file mode 100644
index 0000000..b7baab9
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationStarter.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2026 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.replication;
+
+import com.google.gerrit.common.Nullable;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.replication.PushResultProcessing.CommandProcessing;
+import com.googlesource.gerrit.plugins.replication.PushResultProcessing.SshOutputCommand;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+@Singleton
+class ReplicationStarter {
+  private final PushAll.Factory pushFactory;
+  private final ReplicationStateLogger stateLog;
+
+  @Inject
+  ReplicationStarter(PushAll.Factory pushFactory, ReplicationStateLogger stateLog) {
+    this.pushFactory = pushFactory;
+    this.stateLog = stateLog;
+  }
+
+  void start(
+      @Nullable String urlMatch,
+      Set<String> remotesToConsider,
+      ReplicationFilter filter,
+      boolean now,
+      boolean wait,
+      SshOutputCommand sink) {
+    ReplicationState state = new ReplicationState(new CommandProcessing(sink));
+
+    Future<?> future =
+        pushFactory
+            .create(urlMatch, remotesToConsider, filter, state, now)
+            .schedule(0, TimeUnit.SECONDS);
+
+    if (wait) {
+      if (future != null) {
+        try {
+          future.get();
+        } catch (InterruptedException e) {
+          stateLog.error(
+              "Thread was interrupted while waiting for PushAll operation to finish", e, state);
+          return;
+        } catch (ExecutionException e) {
+          stateLog.error("An exception was thrown in PushAll operation", e, state);
+          return;
+        }
+      }
+
+      if (state.hasPushTask()) {
+        try {
+          state.waitForReplication();
+        } catch (InterruptedException e) {
+          sink.writeStdErrSync("We are interrupted while waiting replication to complete");
+        }
+      } else {
+        sink.writeStdOutSync("Nothing to replicate");
+      }
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/SshModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/SshModule.java
index a66cce6..78410f8 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/SshModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/SshModule.java
@@ -27,6 +27,7 @@
   @Override
   protected void configureCommands() {
     command(StartCommand.class);
+    command(RepairCommand.class);
     command(ListCommand.class);
   }
 }
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 2458a26..6d59283 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/StartCommand.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/StartCommand.java
@@ -18,14 +18,10 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-import com.googlesource.gerrit.plugins.replication.PushResultProcessing.CommandProcessing;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
@@ -33,9 +29,7 @@
 @CommandMetaData(
     name = "start",
     description = "Start replication for specific project or all projects")
-final class StartCommand extends SshCommand {
-  @Inject private ReplicationStateLogger stateLog;
-
+final class StartCommand extends SshCommand implements PushResultProcessing.SshOutputCommand {
   @Option(name = "--all", usage = "push all known projects")
   private boolean all;
 
@@ -58,7 +52,7 @@
   @Argument(index = 0, multiValued = true, metaVar = "PATTERN", usage = "project name pattern")
   private List<String> projectPatterns = new ArrayList<>(2);
 
-  @Inject private PushAll.Factory pushFactory;
+  @Inject private ReplicationStarter replicationStarter;
 
   private final Object lock = new Object();
 
@@ -68,47 +62,13 @@
       throw new UnloggedFailure(1, "error: cannot combine --all and PROJECT");
     }
 
-    ReplicationState state = new ReplicationState(new CommandProcessing(this));
+    ReplicationFilter projectFilter =
+        all ? ReplicationFilter.all() : new ReplicationFilter(projectPatterns);
 
-    ReplicationFilter projectFilter;
-
-    if (all) {
-      projectFilter = ReplicationFilter.all();
-    } else {
-      projectFilter = new ReplicationFilter(projectPatterns);
-    }
-
-    Future<?> future =
-        pushFactory
-            .create(urlMatch, remotesToConsider, projectFilter, state, now)
-            .schedule(0, TimeUnit.SECONDS);
-
-    if (wait) {
-      if (future != null) {
-        try {
-          future.get();
-        } catch (InterruptedException e) {
-          stateLog.error(
-              "Thread was interrupted while waiting for PushAll operation to finish", e, state);
-          return;
-        } catch (ExecutionException e) {
-          stateLog.error("An exception was thrown in PushAll operation", e, state);
-          return;
-        }
-      }
-
-      if (state.hasPushTask()) {
-        try {
-          state.waitForReplication();
-        } catch (InterruptedException e) {
-          writeStdErrSync("We are interrupted while waiting replication to complete");
-        }
-      } else {
-        writeStdOutSync("Nothing to replicate");
-      }
-    }
+    replicationStarter.start(urlMatch, remotesToConsider, projectFilter, now, wait, this);
   }
 
+  @Override
   public void writeStdOutSync(String message) {
     if (wait) {
       synchronized (lock) {
@@ -118,6 +78,7 @@
     }
   }
 
+  @Override
   public void writeStdErrSync(String message) {
     if (wait) {
       synchronized (lock) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfig.java b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfig.java
index d5ccb02..1714c1a 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfig.java
@@ -89,6 +89,14 @@
   int getSshCommandTimeout();
 
   /**
+   * Path of the {@code rsync} binary on the host running Gerrit, used by the {@code replication
+   * repair} command.
+   *
+   * @return the rsync binary path, or {@code "rsync"} to resolve via {@code PATH}.
+   */
+  String getRsyncPath();
+
+  /**
    * Current logical version string of the current configuration loaded in memory, depending on the
    * actual implementation of the configuration on the persistent storage.
    *
diff --git a/src/main/resources/Documentation/cmd-repair.md b/src/main/resources/Documentation/cmd-repair.md
new file mode 100644
index 0000000..3f95907
--- /dev/null
+++ b/src/main/resources/Documentation/cmd-repair.md
@@ -0,0 +1,100 @@
+@PLUGIN@ repair
+===============
+
+NAME
+----
+@PLUGIN@ repair - Repair a project on replication destinations
+
+SYNOPSIS
+--------
+
+```console
+ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ repair
+  [--url <PATTERN>]
+  [--full | --copy-packs]
+  <PROJECT>
+```
+
+DESCRIPTION
+-----------
+Repairs a project on its replication destinations, then runs a
+`@PLUGIN@ start` for that project (with `--now --wait`) so
+any refs that diverged during the repair are replicated. The command
+blocks until replication finishes.
+
+If no repair action flag is supplied, `--full` is assumed.
+
+REQUIREMENTS
+------------
+The Gerrit runtime user must have `ssh` on `PATH`, plus `rsync` either on
+`PATH` or pointed at via [`replication.rsyncPath`](config.md#replication.rsyncPath).
+
+ACCESS
+------
+Caller must be a member of the privileged 'Administrators' group,
+or have been granted the 'Start Replication' plugin-owned capability.
+
+SCRIPTING
+---------
+This command is intended to be used to repair repositories on the mirror.
+Exit status is non-zero if the project is missing, or if the repair fails
+for some reason.
+
+OPTIONS
+-------
+
+`--url <PATTERN>`
+: Restrict both the repair action(s) and the follow-up replication to
+replication destinations whose configuration URL contains the substring
+`PATTERN`, or whose expanded project URL contains `PATTERN`.
+
+`--full`
+: Run every supported repair action.
+
+`--copy-packs`
+: rsync regular files in `objects/pack/` whose names end with `.pack`,
+`.idx`, `.bitmap`, or `.rev` to each matching destination. For each
+remote, [remote.NAME.adminUrl](config.md#remote.NAME.adminUrl) is preferred
+when set (same as repository creation); otherwise
+[remote.NAME.url](config.md#remote.NAME.url) is used. Only plain SSH
+destinations are eligible (for example `user@host:/path/to/repo.git`).
+Destinations whose URL uses `gerrit+ssh`, HTTP(S), or a local path are
+skipped.
+
+`PROJECT`
+: Exact Gerrit project name.
+
+EXAMPLES
+--------
+Run every supported repair action for `tools/gerrit` against every
+eligible destination, then replicate refs (`--full` is implied since no
+action flag is given):
+
+```console
+  $ ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ repair tools/gerrit
+```
+
+Equivalent, with `--full` stated explicitly:
+
+```console
+  $ ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ repair --full tools/gerrit
+```
+
+Only copy packs (no other repair actions, even if more are added later):
+
+```console
+  $ ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ repair --copy-packs tools/gerrit
+```
+
+Repair only against destinations whose URL mentions `replica1`:
+
+```console
+  $ ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ repair --url replica1 tools/gerrit
+```
+
+SEE ALSO
+--------
+
+* [@PLUGIN@ start](cmd-start.md)
+* [Replication Configuration](config.md)
+* [Access Control](../../../Documentation/access-control.html)
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index a575f03..4ec1124 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -261,6 +261,14 @@
 
 	When not set, defaults to the plugin's data directory.
 
+replication.rsyncPath
+:	Path to the `rsync` binary on the host running Gerrit, used by the
+	`@PLUGIN@ repair --copy-packs` command when transferring pack files
+	to SSH destinations. Set this when the Gerrit runtime user's `PATH`
+	does not contain `rsync`, or to pin a specific build.
+
+	Default: `rsync` (resolved via the Gerrit runtime user's `PATH`)
+
 remote.NAME.url
 :	Address of the remote server to push to.  Multiple URLs may be
 	specified within a single remote block, listing different