Auto-repair replication on missing necessary objects error

When a replication push fails because the remote git-receive-pack
reports 'missing necessary objects', the destination is likely corrupt
and requires manual intervention. Detect that error and, when enabled,
schedule an asynchronous repair.

Repair only runs for plain SSH destinations (not gerrit+ssh, http, or
git protocols) since rsync over SSH is required. Attempts are tracked
per project+destination and gated by three new config options:

  * autoRepairMaxAttempts (default 0, disabled): maximum number of
    auto-repair attempts per project on each destination URL
  * autoRepairInterval (default 3days): minimum spacing between
    attempts for the same (project, destination)
  * autoRepairConcurrencyLimit (default 2): maximum number of
    auto-repair tasks allowed to run concurrently

Tracker state is in-memory and resets on plugin reload or Gerrit
restart, so the cap is best-effort rather than persistent. Enabling
auto-repair in a clustered deployment can lead to redundant repairs as
state is not shared across nodes. This could be improved later if
desired, perhaps by persisting the state using the replication task
storage.

Auto-repair deliberately uses the failing destination URI instead of
round-robin failover, since repairs target corruption errors and are
more likely to succeed on the URL that just reported the problem.

Release-Notes: Auto-repair replication on missing objects errors
Change-Id: I815f620af93b484fa801d0c953d56b6a6b00be66
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 0c77b16..e202729 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java
@@ -23,6 +23,7 @@
 import com.google.inject.Singleton;
 import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
 import java.nio.file.Path;
+import java.time.Duration;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
@@ -130,6 +131,21 @@
   }
 
   @Override
+  public Duration getAutoRepairInterval() {
+    return currentConfig.getAutoRepairInterval();
+  }
+
+  @Override
+  public int getAutoRepairMaxAttempts() {
+    return currentConfig.getAutoRepairMaxAttempts();
+  }
+
+  @Override
+  public int getAutoRepairConcurrencyLimit() {
+    return currentConfig.getAutoRepairConcurrencyLimit();
+  }
+
+  @Override
   public Config getConfig() {
     return currentConfig.getConfig();
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoRepairHandler.java b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoRepairHandler.java
new file mode 100644
index 0000000..1be8758
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoRepairHandler.java
@@ -0,0 +1,117 @@
+// 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 static com.googlesource.gerrit.plugins.replication.ReplicationQueue.repLog;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
+import com.googlesource.gerrit.plugins.replication.events.dispatcher.EventDispatcher;
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import org.eclipse.jgit.transport.URIish;
+
+@Singleton
+public class AutoRepairHandler {
+  static final String MISSING_NECESSARY_OBJECTS = "missing necessary objects";
+
+  private final AutoRepairTracker tracker;
+  private final ProjectRepairer projectRepairer;
+  private final ReplicationStarter replicationStarter;
+  private final EventDispatcher eventDispatcher;
+  private final ScheduledExecutorService executor;
+
+  @Inject
+  AutoRepairHandler(
+      AutoRepairTracker tracker,
+      ProjectRepairer projectRepairer,
+      ReplicationStarter replicationStarter,
+      EventDispatcher eventDispatcher,
+      WorkQueue workQueue,
+      ReplicationConfig replicationConfig,
+      @PluginName String pluginName) {
+    this.tracker = tracker;
+    this.projectRepairer = projectRepairer;
+    this.replicationStarter = replicationStarter;
+    this.eventDispatcher = eventDispatcher;
+    this.executor =
+        workQueue.createQueue(
+            replicationConfig.getAutoRepairConcurrencyLimit(), pluginName + "_auto-repair");
+  }
+
+  public static boolean isMissingNecessaryObjectsError(String message) {
+    return message != null && message.contains(MISSING_NECESSARY_OBJECTS);
+  }
+
+  public void handle(
+      Project.NameKey project,
+      URIish uri,
+      String remoteName,
+      UrlDistributionStrategy urlDistributionStrategy) {
+    if (!tracker.tryBeginRepair(project, uri, remoteName, urlDistributionStrategy)) {
+      return;
+    }
+    repLog.atInfo().log("Scheduling auto-repair for project %s to %s", project.get(), uri);
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError = executor.submit(new AutoRepairTask(project, uri));
+  }
+
+  private class AutoRepairTask implements Runnable {
+    private final Project.NameKey project;
+    private final URIish uri;
+
+    AutoRepairTask(Project.NameKey project, URIish uri) {
+      this.project = project;
+      this.uri = uri;
+    }
+
+    @Override
+    public void run() {
+      ByteArrayOutputStream buf = new ByteArrayOutputStream();
+      boolean isRepaired = projectRepairer.repair(project, uri, buf, true);
+      (isRepaired ? repLog.atInfo() : repLog.atWarning())
+          .log(
+              "Auto-repair %s for project %s to %s:%s",
+              isRepaired ? "succeeded" : "failed",
+              project.get(),
+              uri,
+              buf.toString(StandardCharsets.UTF_8));
+      if (isRepaired) {
+        replicationStarter.start(
+            uri.toString(),
+            Set.of(),
+            new ReplicationFilter(List.of(project.get()), Collections.emptyList()),
+            /* now= */ true,
+            /* wait= */ false,
+            new PushResultProcessing.GitUpdateProcessing(eventDispatcher));
+        repLog.atInfo().log("Scheduled full replication of %s to %s", project.get(), uri);
+      }
+    }
+
+    @Override
+    public String toString() {
+      return "auto-repair " + project.get() + " to " + uri;
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoRepairTracker.java b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoRepairTracker.java
new file mode 100644
index 0000000..53d9c9e
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoRepairTracker.java
@@ -0,0 +1,118 @@
+// 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 static com.googlesource.gerrit.plugins.replication.ReplicationQueue.repLog;
+
+import com.google.gerrit.entities.Project;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import org.eclipse.jgit.transport.URIish;
+
+/**
+ * Tracks per-project, per-destination auto-repair attempts and enforces interval and attempt
+ * limits.
+ *
+ * <p>State is kept in memory and is reset whenever the plugin is reloaded or Gerrit restarts.
+ */
+@Singleton
+public class AutoRepairTracker {
+  private final ReplicationConfig replicationConfig;
+  private final ConcurrentMap<DestinationKey, State> statesByDestination =
+      new ConcurrentHashMap<>();
+
+  @Inject
+  AutoRepairTracker(ReplicationConfig replicationConfig) {
+    this.replicationConfig = replicationConfig;
+  }
+
+  public boolean isEnabled() {
+    return replicationConfig.getAutoRepairMaxAttempts() > 0;
+  }
+
+  /**
+   * Returns whether a new auto-repair attempt is allowed for the project on the given destination.
+   *
+   * <p>If allowed, records the attempt before returning {@code true}.
+   */
+  public boolean tryBeginRepair(
+      Project.NameKey project,
+      URIish uri,
+      String remoteName,
+      UrlDistributionStrategy urlDistributionStrategy) {
+    if (!isEnabled()) {
+      return false;
+    }
+
+    if (!ProjectRepairer.canCopy(uri)) {
+      repLog.atWarning().log(
+          "Skipping auto-repair for %s to %s: only plain SSH destinations are supported",
+          project.get(), uri);
+      return false;
+    }
+
+    DestinationKey key = DestinationKey.create(project, uri, remoteName, urlDistributionStrategy);
+    return statesByDestination.computeIfAbsent(key, k -> new State()).isRepairAllowed(project, uri);
+  }
+
+  private record DestinationKey(Project.NameKey project, String destination) {
+    static DestinationKey create(
+        Project.NameKey project,
+        URIish uri,
+        String remoteName,
+        UrlDistributionStrategy urlDistributionStrategy) {
+      return new DestinationKey(
+          project,
+          urlDistributionStrategy == UrlDistributionStrategy.ROUND_ROBIN
+              ? remoteName
+              : uri.toString());
+    }
+  }
+
+  private class State {
+    private int repairAttemptCount;
+    private Instant lastAttempt = Instant.EPOCH;
+
+    synchronized boolean isRepairAllowed(Project.NameKey project, URIish uri) {
+      int maxAttempts = replicationConfig.getAutoRepairMaxAttempts();
+      if (repairAttemptCount >= maxAttempts) {
+        repLog.atWarning().log(
+            "Skipping auto-repair for %s to %s: reached max attempts (%d)",
+            project.get(), uri, maxAttempts);
+        return false;
+      }
+      Duration interval = replicationConfig.getAutoRepairInterval();
+      Instant now = Instant.now();
+      if (!interval.isZero()) {
+        Instant nextAllowed = lastAttempt.plus(interval);
+        if (now.isBefore(nextAllowed)) {
+          repLog.atInfo().log(
+              "Skipping auto-repair for %s to %s: interval not elapsed."
+                  + " Next attempt allowed at %s",
+              project.get(), uri, nextAllowed);
+          return false;
+        }
+      }
+      lastAttempt = now;
+      repairAttemptCount++;
+      return true;
+    }
+  }
+}
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 c57f256..867ffa4 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
@@ -873,6 +873,10 @@
     return config.getRemoteConfig().getName();
   }
 
+  UrlDistributionStrategy getUrlDistributionStrategy() {
+    return config.getUrlDistributionStrategy();
+  }
+
   public int getMaxRetries() {
     return config.getMaxRetries();
   }
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 0e04e1e..232aa23 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
@@ -142,6 +142,7 @@
   private final CreateProjectTask.Factory createProjectFactory;
   private final AtomicBoolean canceledWhileRunning;
   private final TransportFactory transportFactory;
+  private final AutoRepairHandler autoRepairHandler;
   private DynamicItem<ReplicationPushFilter> replicationPushFilter;
 
   @Inject
@@ -159,6 +160,7 @@
       ProjectCache pc,
       CreateProjectTask.Factory cpf,
       TransportFactory tf,
+      AutoRepairHandler autoRepairHandler,
       @Assisted Project.NameKey d,
       @Assisted URIish u) {
     gitManager = grm;
@@ -181,6 +183,7 @@
     canceledWhileRunning = new AtomicBoolean(false);
     maxRetries = p.getMaxRetries();
     transportFactory = tf;
+    this.autoRepairHandler = autoRepairHandler;
   }
 
   @Inject(optional = true)
@@ -824,6 +827,7 @@
       throws UpdateRefFailureException {
     Set<String> doneRefs = new HashSet<>();
     boolean anyRefFailed = false;
+    boolean autoRepairNeeded = false;
     RemoteRefUpdate.Status lastRefStatusError = RemoteRefUpdate.Status.OK;
 
     for (RemoteRefUpdate u : refUpdates) {
@@ -869,6 +873,10 @@
               || UPDATE_REF_FAILURE.equals(u.getMessage())) {
             throw new UpdateRefFailureException(uri, u.getMessage());
           } else {
+            if (!autoRepairNeeded
+                && AutoRepairHandler.isMissingNecessaryObjectsError(u.getMessage())) {
+              autoRepairNeeded = true;
+            }
             stateLog.error(
                 String.format(
                     "Failed replicate of %s to %s, reason: %s",
@@ -903,6 +911,10 @@
                 projectName.get(), entry.getKey(), uri, RefPushResult.NOT_ATTEMPTED, null);
       }
     }
+    if (autoRepairNeeded) {
+      autoRepairHandler.handle(
+          projectName, uri, pool.getRemoteConfigName(), pool.getUrlDistributionStrategy());
+    }
     stateMap.clear();
   }
 
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 f625232..72cd417 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java
@@ -24,11 +24,15 @@
 import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
 import java.nio.file.Path;
+import java.time.Duration;
 import org.eclipse.jgit.lib.Config;
 
 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 static final Duration DEFAULT_AUTO_REPAIR_INTERVAL = Duration.ofDays(3);
+  private static final int DEFAULT_AUTO_REPAIR_MAX_ATTEMPTS = 0;
+  private static final int DEFAULT_AUTO_REPAIR_CONCURRENCY_LIMIT = 2;
 
   private final SitePaths site;
   private final MergedConfigResource configResource;
@@ -39,6 +43,9 @@
   private final int maxRefsToShow;
   private int sshCommandTimeout;
   private int sshConnectionTimeout;
+  private final Duration autoRepairInterval;
+  private final int autoRepairMaxAttempts;
+  private final int autoRepairConcurrencyLimit;
   private final Path pluginDataDir;
   private final Config config;
 
@@ -63,6 +70,24 @@
                 "sshConnectionTimeout",
                 DEFAULT_SSH_CONNECTION_TIMEOUT_MS,
                 MILLISECONDS);
+    this.autoRepairInterval =
+        Duration.ofSeconds(
+            ConfigUtil.getTimeUnit(
+                config,
+                "replication",
+                null,
+                "autoRepairInterval",
+                DEFAULT_AUTO_REPAIR_INTERVAL.getSeconds(),
+                SECONDS));
+    this.autoRepairMaxAttempts =
+        config.getInt("replication", "autoRepairMaxAttempts", DEFAULT_AUTO_REPAIR_MAX_ATTEMPTS);
+    this.autoRepairConcurrencyLimit =
+        Math.max(
+            1,
+            config.getInt(
+                "replication",
+                "autoRepairConcurrencyLimit",
+                DEFAULT_AUTO_REPAIR_CONCURRENCY_LIMIT));
     this.pluginDataDir = pluginDataDir;
     this.useLegacyCredentials = config.getBoolean("gerrit", "useLegacyCredentials", false);
   }
@@ -152,4 +177,19 @@
     String rsyncPath = getConfig().getString("replication", null, "rsyncPath");
     return Strings.isNullOrEmpty(rsyncPath) ? DEFAULT_RSYNC_PATH : rsyncPath;
   }
+
+  @Override
+  public Duration getAutoRepairInterval() {
+    return autoRepairInterval;
+  }
+
+  @Override
+  public int getAutoRepairMaxAttempts() {
+    return autoRepairMaxAttempts;
+  }
+
+  @Override
+  public int getAutoRepairConcurrencyLimit() {
+    return autoRepairConcurrencyLimit;
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
index 60c0254..711228b 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
@@ -110,6 +110,8 @@
     bind(ReplicationQueue.class).in(Scopes.SINGLETON);
     bind(ReplicationDestinations.class).to(DestinationsCollection.class);
 
+    bind(AutoRepairHandler.class).in(Scopes.SINGLETON);
+    bind(AutoRepairTracker.class).in(Scopes.SINGLETON);
     bind(ProjectRepairer.class).in(Scopes.SINGLETON);
 
     install(new FactoryModuleBuilder().build(Destination.Factory.class));
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationStarter.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationStarter.java
index 7202eab..e715747 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationStarter.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationStarter.java
@@ -42,8 +42,8 @@
       ReplicationFilter filter,
       boolean now,
       boolean wait,
-      SshOutputCommand sink) {
-    ReplicationState state = new ReplicationState(new CommandProcessing(sink));
+      PushResultProcessing processing) {
+    ReplicationState state = new ReplicationState(processing);
 
     Future<?> future =
         pushFactory
@@ -68,11 +68,32 @@
         try {
           state.waitForReplication();
         } catch (InterruptedException e) {
-          sink.writeStdErrSync("We are interrupted while waiting replication to complete");
+          processing.writeStdErr("We are interrupted while waiting replication to complete");
         }
       } else {
-        sink.writeStdOutSync("Nothing to replicate");
+        processing.writeStdOut("Nothing to replicate");
       }
     }
   }
+
+  void start(
+      @Nullable String urlMatch,
+      String refName,
+      Set<String> remotesToConsider,
+      ReplicationFilter filter,
+      boolean now,
+      boolean wait,
+      SshOutputCommand sink) {
+    start(urlMatch, refName, remotesToConsider, filter, now, wait, new CommandProcessing(sink));
+  }
+
+  void start(
+      @Nullable String urlMatch,
+      Set<String> remotesToConsider,
+      ReplicationFilter filter,
+      boolean now,
+      boolean wait,
+      PushResultProcessing processing) {
+    start(urlMatch, PushOne.ALL_REFS, remotesToConsider, filter, now, wait, processing);
+  }
 }
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 1714c1a..6860098 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
@@ -15,6 +15,7 @@
 package com.googlesource.gerrit.plugins.replication.api;
 
 import java.nio.file.Path;
+import java.time.Duration;
 import org.eclipse.jgit.lib.Config;
 
 /** Configuration of all the replication end points. */
@@ -97,6 +98,30 @@
   String getRsyncPath();
 
   /**
+   * Minimum interval between automatic repair attempts for the same project on the same
+   * destination.
+   *
+   * @return interval as a {@link Duration}, {@link Duration#ZERO} for no minimum interval between
+   *     attempts.
+   */
+  Duration getAutoRepairInterval();
+
+  /**
+   * Maximum number of automatic repair attempts per project on each destination.
+   *
+   * @return maximum attempts, zero to disable auto-repair.
+   */
+  int getAutoRepairMaxAttempts();
+
+  /**
+   * Maximum number of automatic repair tasks allowed to run concurrently. Changing this value
+   * requires a plugin reload to take effect.
+   *
+   * @return concurrency limit, minimum 1.
+   */
+  int getAutoRepairConcurrencyLimit();
+
+  /**
    * 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/config.md b/src/main/resources/Documentation/config.md
index cef009c..cf1b944 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -282,6 +282,47 @@
 
 	Default: `rsync` (resolved via the Gerrit runtime user's `PATH`)
 
+replication.autoRepairInterval
+:	Minimum interval between automatic repair attempts for the same
+	project on the same destination. Values are expressed with a time
+	unit suffix, e.g. `2h`, `30m`, `3d`. If no unit is given the value is
+	interpreted as seconds.
+
+	Auto-repair is event-driven and each attempt is initiated by a failing
+	replication push and is not retried on its own. When a push fails with
+	a `missing necessary objects` error from `git-receive-pack`, the plugin
+	schedules a repair attempt for the affected SSH destination. Unlike
+	normal replication failures (which the plugin retries automatically),
+	repair attempts are not retried by themselves. If a repair completes
+	but the destination is still broken, the next repair runs only when
+	another replication push to the same destination fails with the
+	same missing objects error, and only after this interval has elapsed.
+
+	Default: `3d`
+
+replication.autoRepairMaxAttempts
+:	Maximum number of automatic repair attempts per project on each
+	destination. After this limit is reached for a given project and
+	destination, further `missing necessary objects` errors for that pair
+	are logged but no additional automatic repairs are attempted.
+
+	Auto-repair state is kept in memory and is reset whenever the plugin is
+	reloaded or Gerrit restarts. Enabling auto-repair in a clustered deployment
+	can lead to redundant repairs as the state is not shared between them.
+
+	Default: `0` (auto-repair disabled)
+
+replication.autoRepairConcurrencyLimit
+:	Maximum number of automatic repair tasks allowed to run concurrently.
+	Each running repair occupies one slot for the duration of its repair
+	and the follow-up full replication, so this caps the load auto-repair
+	can put on the host and on destination SSH endpoints at any one time.
+	Increase it when many destinations are expected to need auto-repair
+	in parallel. The value is read once at plugin start, so changes only
+	take effect after a plugin reload.
+
+	Minimum: `1`. Default: `2`.
+
 remote.NAME.url
 :	Address of the remote server to push to.  Multiple URLs may be
 	specified within a single remote block, listing different
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/AutoRepairTrackerTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/AutoRepairTrackerTest.java
new file mode 100644
index 0000000..865ed23
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/AutoRepairTrackerTest.java
@@ -0,0 +1,114 @@
+// 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 static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.when;
+
+import com.google.gerrit.entities.Project;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
+import java.time.Duration;
+import org.eclipse.jgit.transport.URIish;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+public class AutoRepairTrackerTest {
+  private static final Project.NameKey PROJECT = Project.nameKey("foo");
+  private static final String REMOTE = "mirror";
+
+  @Mock private ReplicationConfig replicationConfig;
+
+  private AutoCloseable mocks;
+  private AutoRepairTracker tracker;
+  private URIish uri1;
+  private URIish uri2;
+
+  @Before
+  public void setUp() throws Exception {
+    mocks = MockitoAnnotations.openMocks(this);
+    when(replicationConfig.getAutoRepairInterval()).thenReturn(Duration.ofDays(3));
+    when(replicationConfig.getAutoRepairMaxAttempts()).thenReturn(5);
+
+    tracker = new AutoRepairTracker(replicationConfig);
+    uri1 = new URIish("ssh://mirror1.example.com/foo.git");
+    uri2 = new URIish("ssh://mirror2.example.com/foo.git");
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    mocks.close();
+  }
+
+  @Test
+  public void allowsRepairUpToMaxAttemptsPerDestination() {
+    when(replicationConfig.getAutoRepairInterval()).thenReturn(Duration.ZERO);
+    for (int i = 0; i < 5; i++) {
+      assertThat(tryRepair(uri1, UrlDistributionStrategy.ALL)).isTrue();
+    }
+    assertThat(tryRepair(uri1, UrlDistributionStrategy.ALL)).isFalse();
+  }
+
+  @Test
+  public void tracksAttemptsSeparatelyPerDestination() {
+    when(replicationConfig.getAutoRepairInterval()).thenReturn(Duration.ZERO);
+    when(replicationConfig.getAutoRepairMaxAttempts()).thenReturn(1);
+
+    assertThat(tryRepair(uri1, UrlDistributionStrategy.ALL)).isTrue();
+    assertThat(tryRepair(uri1, UrlDistributionStrategy.ALL)).isFalse();
+    assertThat(tryRepair(uri2, UrlDistributionStrategy.ALL)).isTrue();
+  }
+
+  @Test
+  public void roundRobinSharesAttemptsAcrossUrls() {
+    when(replicationConfig.getAutoRepairInterval()).thenReturn(Duration.ZERO);
+    when(replicationConfig.getAutoRepairMaxAttempts()).thenReturn(1);
+
+    assertThat(tryRepair(uri1, UrlDistributionStrategy.ROUND_ROBIN)).isTrue();
+    assertThat(tryRepair(uri2, UrlDistributionStrategy.ROUND_ROBIN)).isFalse();
+  }
+
+  @Test
+  public void allowsRepairWithoutIntervalWhenIntervalIsZero() {
+    when(replicationConfig.getAutoRepairInterval()).thenReturn(Duration.ZERO);
+    assertThat(tryRepair(uri1, UrlDistributionStrategy.ALL)).isTrue();
+    assertThat(tryRepair(uri1, UrlDistributionStrategy.ALL)).isTrue();
+  }
+
+  @Test
+  public void disabledWhenMaxAttemptsIsZero() {
+    when(replicationConfig.getAutoRepairMaxAttempts()).thenReturn(0);
+    assertThat(tracker.isEnabled()).isFalse();
+    assertThat(tryRepair(uri1, UrlDistributionStrategy.ALL)).isFalse();
+  }
+
+  @Test
+  public void enforcesIntervalBetweenAttempts() {
+    assertThat(tryRepair(uri1, UrlDistributionStrategy.ALL)).isTrue();
+    assertThat(tryRepair(uri1, UrlDistributionStrategy.ALL)).isFalse();
+  }
+
+  @Test
+  public void rejectsNonCopyableDestination() throws Exception {
+    URIish httpUri = new URIish("http://mirror.example.com/foo.git");
+    assertThat(tryRepair(httpUri, UrlDistributionStrategy.ALL)).isFalse();
+  }
+
+  private boolean tryRepair(URIish uri, UrlDistributionStrategy strategy) {
+    return tracker.tryBeginRepair(PROJECT, uri, REMOTE, strategy);
+  }
+}
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 339123a..6def78f 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/PushOneTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/PushOneTest.java
@@ -423,6 +423,7 @@
             projectCacheMock,
             createProjectTaskFactoryMock,
             transportFactoryMock,
+            mock(AutoRepairHandler.class),
             projectNameKey,
             urish);