Merge branch 'stable-3.10' into stable-3.11

* stable-3.10:
  Add a remote config to exclude replicating desired refs
  Auto-format source code using gjf

Change-Id: I0ba471d5649b5852c57e40244353210dbc330ecc
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 2c049fc..5e2c758 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java
@@ -128,4 +128,9 @@
   public Config getConfig() {
     return currentConfig.getConfig();
   }
+
+  @Override
+  public boolean useLegacyCredentials() {
+    return currentConfig.useLegacyCredentials();
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecorator.java b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecorator.java
index e9409a6..0bf9026 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecorator.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecorator.java
@@ -14,49 +14,61 @@
 
 package com.googlesource.gerrit.plugins.replication;
 
-import static com.google.gerrit.common.FileUtil.lastModified;
-
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.securestore.SecureStore;
 import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
 import java.io.IOException;
-import java.nio.file.Files;
 import java.util.concurrent.atomic.AtomicReference;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.transport.CredentialsProvider;
 
+@UsedAt(Project.PLUGIN_PULL_REPLICATION)
 public class AutoReloadSecureCredentialsFactoryDecorator implements CredentialsFactory {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final AtomicReference<SecureCredentialsFactory> secureCredentialsFactory;
-  private volatile long secureCredentialsFactoryLoadTs;
+  private final AtomicReference<CredentialsFactory> secureCredentialsFactory;
+  private final SecureStore secureStore;
   private final SitePaths site;
-  private ReplicationConfig config;
+  private final ReplicationConfig config;
 
   @Inject
-  public AutoReloadSecureCredentialsFactoryDecorator(SitePaths site, ReplicationConfig config)
+  public AutoReloadSecureCredentialsFactoryDecorator(
+      SitePaths site, SecureStore secureStore, ReplicationConfig config)
       throws ConfigInvalidException, IOException {
     this.site = site;
+    this.secureStore = secureStore;
     this.config = config;
-    this.secureCredentialsFactory = new AtomicReference<>(new SecureCredentialsFactory(site));
-    this.secureCredentialsFactoryLoadTs = getSecureConfigLastEditTs();
+    this.secureCredentialsFactory =
+        new AtomicReference<>(newSecureCredentialsFactory(site, secureStore, config));
+    if (config.useLegacyCredentials()) {
+      logger.atWarning().log(
+          "Using legacy credentials in clear text in secure.config. Please encrypt your credentials"
+              + " using 'java -jar gerrit.war passwd' for each remote, remove the"
+              + " gerrit.useLegacyCredentials in replication.config and then reload the replication"
+              + " plugin.");
+    }
   }
 
-  private long getSecureConfigLastEditTs() {
-    if (!Files.exists(site.secure_config)) {
-      return 0L;
+  private static CredentialsFactory newSecureCredentialsFactory(
+      SitePaths site, SecureStore secureStore, ReplicationConfig config)
+      throws ConfigInvalidException, IOException {
+    if (config.useLegacyCredentials()) {
+      return new LegacyCredentialsFactory(site);
     }
-    return lastModified(site.secure_config);
+    return new SecureCredentialsFactory(secureStore);
   }
 
   @Override
   public CredentialsProvider create(String remoteName) {
     try {
       if (needsReload()) {
+        secureStore.reload();
         secureCredentialsFactory.compareAndSet(
-            secureCredentialsFactory.get(), new SecureCredentialsFactory(site));
-        secureCredentialsFactoryLoadTs = getSecureConfigLastEditTs();
+            secureCredentialsFactory.get(), newSecureCredentialsFactory(site, secureStore, config));
         logger.atInfo().log("secure.config reloaded as it was updated on the file system");
       }
     } catch (Exception e) {
@@ -69,7 +81,11 @@
   }
 
   private boolean needsReload() {
-    return config.getConfig().getBoolean("gerrit", "autoReload", false)
-        && getSecureConfigLastEditTs() != secureCredentialsFactoryLoadTs;
+    return config.getConfig().getBoolean("gerrit", "autoReload", false) && secureStore.isOutdated();
+  }
+
+  @Override
+  public boolean validate(String remoteConfigName) {
+    return secureCredentialsFactory.get().validate(remoteConfigName);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/CredentialsFactory.java b/src/main/java/com/googlesource/gerrit/plugins/replication/CredentialsFactory.java
index 3bb64ab..141fb2d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/CredentialsFactory.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/CredentialsFactory.java
@@ -18,4 +18,8 @@
 public interface CredentialsFactory {
 
   CredentialsProvider create(String remoteName);
+
+  default boolean validate(String remoteConfigName) {
+    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 ceaf073..d535934 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
@@ -22,11 +22,11 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 import com.google.common.io.Files;
 import com.google.common.net.UrlEscapers;
+import com.google.common.util.concurrent.Striped;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BranchNameKey;
@@ -71,7 +71,6 @@
 import java.io.IOException;
 import java.net.URISyntaxException;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -82,7 +81,10 @@
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReadWriteLock;
 import java.util.function.Function;
+import java.util.function.Supplier;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.Constants;
@@ -95,19 +97,55 @@
 
 public class Destination {
   private static final NamedFluentLogger repLog = ReplicationQueue.repLog;
+  private static final int MAX_STRIPES = 16;
 
   private static final String PROJECT_NOT_AVAILABLE = "source project %s not available";
 
+  private final CredentialsFactory credentialsFactory;
+
   public interface Factory {
     Destination create(DestinationConfiguration config);
   }
 
+  private static class StateLock {
+    private final Striped<ReadWriteLock> stateLock;
+
+    StateLock(int numStripes) {
+      stateLock = Striped.readWriteLock(numStripes);
+    }
+
+    <V> V withWriteLock(URIish uri, Supplier<V> task) {
+      Lock lock = stateLock.get(uri).writeLock();
+      lock.lock();
+      try {
+        return task.get();
+      } finally {
+        lock.unlock();
+      }
+    }
+
+    <V> V withReadLock(URIish uri, Supplier<V> task) {
+      Lock lock = stateLock.get(uri).readLock();
+      lock.lock();
+      try {
+        return task.get();
+      } finally {
+        lock.unlock();
+      }
+    }
+  }
+
+  private static class RescheduleStatus {
+    boolean isRescheduled;
+    boolean isFailed;
+    RemoteRefUpdate.Status failedStatus;
+  }
+
   private final ReplicationStateListener stateLog;
-  private final Object stateLock = new Object();
+  private final StateLock stateLock;
   // writes are covered by the stateLock, but some reads are still
   // allowed without the lock
-  private final ConcurrentMap<URIish, PushOne> pending = new ConcurrentHashMap<>();
-  private final Map<URIish, PushOne> inFlight = new HashMap<>();
+  private final Queue queue;
   private final PushOne.Factory opFactory;
   private final DeleteProjectTask.Factory deleteProjectFactory;
   private final UpdateHeadTask.Factory updateHeadFactory;
@@ -127,13 +165,13 @@
     REPOSITORY_MISSING;
   }
 
-  public static class QueueInfo {
-    public final ImmutableMap<URIish, PushOne> pending;
-    public final ImmutableMap<URIish, PushOne> inFlight;
+  public static class Queue {
+    public final ConcurrentMap<URIish, PushOne> pending;
+    public final ConcurrentMap<URIish, PushOne> inFlight;
 
-    public QueueInfo(Map<URIish, PushOne> pending, Map<URIish, PushOne> inFlight) {
-      this.pending = ImmutableMap.copyOf(pending);
-      this.inFlight = ImmutableMap.copyOf(inFlight);
+    public Queue() {
+      this.pending = new ConcurrentHashMap<>();
+      this.inFlight = new ConcurrentHashMap<>();
     }
   }
 
@@ -150,15 +188,23 @@
       GroupIncludeCache groupIncludeCache,
       DynamicItem<EventDispatcher> eventDispatcher,
       Provider<ReplicationTasksStorage> rts,
+      CredentialsFactory credentialsFactory,
       @Assisted DestinationConfiguration cfg) {
     this.eventDispatcher = eventDispatcher;
     gitManager = gitRepositoryManager;
+    this.queue = new Queue();
     this.permissionBackend = permissionBackend;
     this.userProvider = userProvider;
     this.projectCache = projectCache;
     this.stateLog = stateLog;
     this.replicationTasksStorage = rts;
+    this.credentialsFactory = credentialsFactory;
     config = cfg;
+
+    ImmutableList<String> projects = cfg.getProjects();
+    int numStripes = projects.isEmpty() ? MAX_STRIPES : Math.min(projects.size(), MAX_STRIPES);
+    stateLock = new StateLock(numStripes);
+
     CurrentUser remoteUser;
     if (!cfg.getAuthGroupNames().isEmpty()) {
       ImmutableSet.Builder<AccountGroup.UUID> builder = ImmutableSet.builder();
@@ -217,6 +263,10 @@
     threadScoper = child.getInstance(PerThreadRequestScope.Scoper.class);
   }
 
+  public boolean validate() {
+    return (credentialsFactory == null || credentialsFactory.validate(getRemoteConfigName()));
+  }
+
   private void addRecursiveParents(
       AccountGroup.UUID g,
       ImmutableSet.Builder<AccountGroup.UUID> builder,
@@ -230,10 +280,8 @@
     }
   }
 
-  public QueueInfo getQueueInfo() {
-    synchronized (stateLock) {
-      return new QueueInfo(pending, inFlight);
-    }
+  public Queue getQueue() {
+    return queue;
   }
 
   public void start(WorkQueue workQueue) {
@@ -244,33 +292,31 @@
   public int shutdown() {
     int cnt = 0;
     if (pool != null) {
-      synchronized (stateLock) {
-        int numPending = pending.size();
-        int numInFlight = inFlight.size();
+      int numPending = queue.pending.size();
+      int numInFlight = queue.inFlight.size();
 
-        if (numPending > 0 || numInFlight > 0) {
-          repLog.atWarning().log(
-              "Cancelling replication events (pending=%d, inFlight=%d) for destination %s",
-              numPending, numInFlight, getRemoteConfigName());
+      if (numPending > 0 || numInFlight > 0) {
+        repLog.atWarning().log(
+            "Cancelling replication events (pending=%d, inFlight=%d) for destination %s",
+            numPending, numInFlight, getRemoteConfigName());
 
-          foreachPushOp(
-              pending,
-              push -> {
-                push.cancel();
-                return null;
-              });
-          pending.clear();
-          foreachPushOp(
-              inFlight,
-              push -> {
-                push.setCanceledWhileRunning();
-                return null;
-              });
-          inFlight.clear();
-        }
-        cnt = pool.shutdownNow().size();
-        pool = null;
+        foreachPushOp(
+            queue.pending,
+            push -> {
+              push.cancel();
+              return null;
+            });
+        queue.pending.clear();
+        foreachPushOp(
+            queue.inFlight,
+            push -> {
+              push.setCanceledWhileRunning();
+              return null;
+            });
+        queue.inFlight.clear();
       }
+      cnt = pool.shutdownNow().size();
+      pool = null;
     }
     return cnt;
   }
@@ -279,7 +325,7 @@
     // Callers may modify the provided opsMap concurrently, hence make a defensive copy of the
     // values to loop over them.
     for (PushOne pushOne : ImmutableList.copyOf(opsMap.values())) {
-      pushOneFunction.apply(pushOne);
+      stateLock.withWriteLock(pushOne.getURI(), () -> pushOneFunction.apply(pushOne));
     }
   }
 
@@ -421,10 +467,7 @@
     repLog.atInfo().log("scheduling replication %s:%s => %s", project, refs, uri);
 
     if (!config.replicatePermissions()) {
-      PushOne e;
-      synchronized (stateLock) {
-        e = getPendingPush(uri);
-      }
+      PushOne e = stateLock.withReadLock(uri, () -> getPendingPush(uri));
       if (e == null) {
         try (Repository git = gitManager.openRepository(project)) {
           try {
@@ -446,40 +489,43 @@
     }
 
     ImmutableSet<String> refsToSchedule = toSchedule.build();
-    PushOne task;
-    synchronized (stateLock) {
-      task = getPendingPush(uri);
-      if (task == null) {
-        task = opFactory.create(project, uri);
-        task.addRefBatch(refsToSchedule);
-        task.addState(refsToSchedule, state);
-        @SuppressWarnings("unused")
-        ScheduledFuture<?> ignored =
-            pool.schedule(task, now ? 0 : config.getDelay(), TimeUnit.SECONDS);
-        pending.put(uri, task);
-        repLog.atInfo().log(
-            "scheduled %s:%s => %s to run %s",
-            project, refsToSchedule, task, now ? "now" : "after " + config.getDelay() + "s");
-      } else {
-        boolean added = task.addRefBatch(refsToSchedule);
-        task.addState(refsToSchedule, state);
-        String message = "consolidated %s:%s => %s with an existing pending push";
-        if (added || !fromStorage) {
-          repLog.atInfo().log(message, project, refsToSchedule, task);
-        } else {
-          repLog.atFine().log(message, project, refsToSchedule, task);
-        }
-      }
-      for (String ref : refsToSchedule) {
-        state.increasePushTaskCount(project.get(), ref);
-      }
-    }
+    PushOne task =
+        stateLock.withWriteLock(
+            uri,
+            () -> {
+              PushOne t = getPendingPush(uri);
+              if (t == null) {
+                t = opFactory.create(project, uri);
+                t.addRefBatch(refsToSchedule);
+                t.addState(refsToSchedule, state);
+                @SuppressWarnings("unused")
+                ScheduledFuture<?> ignored =
+                    pool.schedule(t, now ? 0 : config.getDelay(), TimeUnit.SECONDS);
+                queue.pending.put(uri, t);
+                repLog.atInfo().log(
+                    "scheduled %s:%s => %s to run %s",
+                    project, refsToSchedule, t, now ? "now" : "after " + config.getDelay() + "s");
+              } else {
+                boolean added = t.addRefBatch(refsToSchedule);
+                t.addState(refsToSchedule, state);
+                String message = "consolidated %s:%s => %s with an existing pending push";
+                if (added || !fromStorage) {
+                  repLog.atInfo().log(message, project, refsToSchedule, t);
+                } else {
+                  repLog.atFine().log(message, project, refsToSchedule, t);
+                }
+              }
+              for (String ref : refsToSchedule) {
+                state.increasePushTaskCount(project.get(), ref);
+              }
+              return t;
+            });
     postReplicationScheduledEvent(task, refsToSchedule);
   }
 
   @Nullable
   private PushOne getPendingPush(URIish uri) {
-    PushOne e = pending.get(uri);
+    PushOne e = queue.pending.get(uri);
     if (e != null && !e.wasCanceled()) {
       return e;
     }
@@ -487,12 +533,14 @@
   }
 
   void pushWasCanceled(PushOne pushOp) {
-    Set<ImmutableSet<String>> notAttemptedRefs = Collections.emptySet();
-    synchronized (stateLock) {
-      URIish uri = pushOp.getURI();
-      pending.remove(uri);
-      notAttemptedRefs = pushOp.getRefs();
-    }
+    Set<ImmutableSet<String>> notAttemptedRefs =
+        stateLock.withWriteLock(
+            pushOp.getURI(),
+            () -> {
+              URIish uri = pushOp.getURI();
+              queue.pending.remove(uri);
+              return pushOp.getRefs();
+            });
     pushOp.notifyNotAttempted(notAttemptedRefs);
   }
 
@@ -532,124 +580,131 @@
    * @param pushOp The PushOp instance to be scheduled.
    */
   void reschedule(PushOne pushOp, RetryReason reason) {
-    boolean isRescheduled = false;
-    boolean isFailed = false;
-    RemoteRefUpdate.Status failedStatus = null;
+    RescheduleStatus status = new RescheduleStatus();
+    stateLock.withWriteLock(
+        pushOp.getURI(),
+        () -> {
+          URIish uri = pushOp.getURI();
+          PushOne pendingPushOp = getPendingPush(uri);
 
-    synchronized (stateLock) {
-      URIish uri = pushOp.getURI();
-      PushOne pendingPushOp = getPendingPush(uri);
+          if (pendingPushOp != null) {
+            // There is one PushOp instance already pending to same URI.
 
-      if (pendingPushOp != null) {
-        // There is one PushOp instance already pending to same URI.
+            if (pendingPushOp.isRetrying()) {
+              // The one pending is one already retrying, so it should
+              // maintain it and add to it the refs of the one passed
+              // as parameter to the method.
 
-        if (pendingPushOp.isRetrying()) {
-          // The one pending is one already retrying, so it should
-          // maintain it and add to it the refs of the one passed
-          // as parameter to the method.
+              // This scenario would happen if a PushOp has started running
+              // and then before it failed due transport exception, another
+              // one to same URI started. The first one would fail and would
+              // be rescheduled, being present in pending list. When the
+              // second one fails, it will also be rescheduled and then,
+              // here, find out replication to its URI is already pending
+              // for retry (blocking).
+              pendingPushOp.addRefBatches(pushOp.getRefs());
+              pendingPushOp.addStates(pushOp.getStates());
+              pushOp.removeStates();
 
-          // This scenario would happen if a PushOp has started running
-          // and then before it failed due transport exception, another
-          // one to same URI started. The first one would fail and would
-          // be rescheduled, being present in pending list. When the
-          // second one fails, it will also be rescheduled and then,
-          // here, find out replication to its URI is already pending
-          // for retry (blocking).
-          pendingPushOp.addRefBatches(pushOp.getRefs());
-          pendingPushOp.addStates(pushOp.getStates());
-          pushOp.removeStates();
-
-        } else {
-          // The one pending is one that is NOT retrying, it was just
-          // scheduled believing no problem would happen. The one pending
-          // should be canceled, and this is done by setting its canceled
-          // flag, removing it from pending list, and adding its refs to
-          // the pushOp instance that should then, later, in this method,
-          // be scheduled for retry.
-
-          // Notice that the PushOp found pending will start running and,
-          // when notifying it is starting (with pending lock protection),
-          // it will see it was canceled and then it will do nothing with
-          // pending list and it will not execute its run implementation.
-          pendingPushOp.canceledByReplication();
-          pending.remove(uri);
-
-          pushOp.addRefBatches(pendingPushOp.getRefs());
-          pushOp.addStates(pendingPushOp.getStates());
-          pendingPushOp.removeStates();
-        }
-      }
-
-      if (pendingPushOp == null || !pendingPushOp.isRetrying()) {
-        pending.put(uri, pushOp);
-        switch (reason) {
-          case COLLISION:
-            @SuppressWarnings("unused")
-            ScheduledFuture<?> ignored =
-                pool.schedule(pushOp, config.getRescheduleDelay(), TimeUnit.SECONDS);
-            break;
-          case TRANSPORT_ERROR:
-          case REPOSITORY_MISSING:
-          default:
-            failedStatus =
-                RetryReason.REPOSITORY_MISSING.equals(reason)
-                    ? NON_EXISTING
-                    : REJECTED_OTHER_REASON;
-            isFailed = true;
-            if (pushOp.setToRetry()) {
-              isRescheduled = true;
-              replicationTasksStorage.get().reset(pushOp);
-              @SuppressWarnings("unused")
-              ScheduledFuture<?> ignored2 =
-                  pool.schedule(pushOp, config.getRetryDelay(), TimeUnit.MINUTES);
             } else {
-              pushOp.canceledByReplication();
-              pushOp.retryDone();
-              pending.remove(uri);
-              stateLog.error(
-                  "Push to " + pushOp.getURI() + " cancelled after maximum number of retries",
-                  pushOp.getStatesAsArray());
+              // The one pending is one that is NOT retrying, it was just
+              // scheduled believing no problem would happen. The one pending
+              // should be canceled, and this is done by setting its canceled
+              // flag, removing it from pending list, and adding its refs to
+              // the pushOp instance that should then, later, in this method,
+              // be scheduled for retry.
+
+              // Notice that the PushOp found pending will start running and,
+              // when notifying it is starting (with pending lock protection),
+              // it will see it was canceled and then it will do nothing with
+              // pending list and it will not execute its run implementation.
+              pendingPushOp.canceledByReplication();
+              queue.pending.remove(uri);
+
+              pushOp.addRefBatches(pendingPushOp.getRefs());
+              pushOp.addStates(pendingPushOp.getStates());
+              pendingPushOp.removeStates();
             }
-            break;
-        }
-      }
+          }
+
+          if (pendingPushOp == null || !pendingPushOp.isRetrying()) {
+            queue.pending.put(uri, pushOp);
+            switch (reason) {
+              case COLLISION:
+                @SuppressWarnings("unused")
+                ScheduledFuture<?> ignored =
+                    pool.schedule(pushOp, config.getRescheduleDelay(), TimeUnit.SECONDS);
+                break;
+              case TRANSPORT_ERROR:
+              case REPOSITORY_MISSING:
+              default:
+                status.failedStatus =
+                    RetryReason.REPOSITORY_MISSING.equals(reason)
+                        ? NON_EXISTING
+                        : REJECTED_OTHER_REASON;
+                status.isFailed = true;
+                if (pushOp.setToRetry()) {
+                  status.isRescheduled = true;
+                  replicationTasksStorage.get().reset(pushOp);
+                  @SuppressWarnings("unused")
+                  ScheduledFuture<?> ignored2 =
+                      pool.schedule(pushOp, config.getRetryDelay(), TimeUnit.MINUTES);
+                } else {
+                  pushOp.canceledByReplication();
+                  pushOp.retryDone();
+                  queue.pending.remove(uri);
+                  stateLog.error(
+                      "Push to " + pushOp.getURI() + " cancelled after maximum number of retries",
+                      pushOp.getStatesAsArray());
+                }
+                break;
+            }
+          }
+          return status;
+        });
+
+    if (status.isFailed) {
+      postReplicationFailedEvent(pushOp, status.failedStatus);
     }
-    if (isFailed) {
-      postReplicationFailedEvent(pushOp, failedStatus);
-    }
-    if (isRescheduled) {
+    if (status.isRescheduled) {
       postReplicationScheduledEvent(pushOp);
     }
   }
 
   RunwayStatus requestRunway(PushOne op) {
-    synchronized (stateLock) {
-      if (op.wasCanceled()) {
-        return RunwayStatus.canceled();
-      }
-      pending.remove(op.getURI());
-      PushOne inFlightOp = inFlight.get(op.getURI());
-      if (inFlightOp != null) {
-        return RunwayStatus.denied(inFlightOp.getId());
-      }
-      op.notifyNotAttempted(op.setStartedRefs(replicationTasksStorage.get().start(op)));
-      inFlight.put(op.getURI(), op);
-    }
+    stateLock.withWriteLock(
+        op.getURI(),
+        () -> {
+          if (op.wasCanceled()) {
+            return RunwayStatus.canceled();
+          }
+          queue.pending.remove(op.getURI());
+          PushOne inFlightOp = queue.inFlight.get(op.getURI());
+          if (inFlightOp != null) {
+            return RunwayStatus.denied(inFlightOp.getId());
+          }
+          op.notifyNotAttempted(op.setStartedRefs(replicationTasksStorage.get().start(op)));
+          queue.inFlight.put(op.getURI(), op);
+          return null;
+        });
     return RunwayStatus.allowed();
   }
 
   void notifyFinished(PushOne op) {
-    synchronized (stateLock) {
-      if (!op.isRetrying()) {
-        replicationTasksStorage.get().finish(op);
-      }
-      inFlight.remove(op.getURI());
-    }
+    stateLock.withWriteLock(
+        op.getURI(),
+        () -> {
+          if (!op.isRetrying()) {
+            replicationTasksStorage.get().finish(op);
+          }
+          queue.inFlight.remove(op.getURI());
+          return null;
+        });
   }
 
   public Map<ReplicateRefUpdate, String> getTaskNamesByReplicateRefUpdate() {
     Map<ReplicateRefUpdate, String> taskNameByReplicateRefUpdate = new HashMap<>();
-    for (PushOne push : pending.values()) {
+    for (PushOne push : queue.pending.values()) {
       String taskName = push.toString();
       for (ReplicateRefUpdate refUpdate : push.getReplicateRefUpdates()) {
         taskNameByReplicateRefUpdate.put(refUpdate, taskName);
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 8a41945..471a408 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java
@@ -35,6 +35,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.replication.Destination.Queue;
 import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
 import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig.FilterType;
 import java.net.URISyntaxException;
@@ -180,6 +181,12 @@
   public synchronized void startup(WorkQueue workQueue) {
     shuttingDown = false;
     for (Destination cfg : destinations) {
+      if (!cfg.validate()) {
+        throw new IllegalStateException(
+            "Unable to start replication plugin because remote "
+                + cfg.getRemoteConfigName()
+                + " is not valid");
+      }
       cfg.start(workQueue);
     }
   }
@@ -221,8 +228,9 @@
     if (drainQueueAttempts == 0) {
       return;
     }
-    int pending = destination.getQueueInfo().pending.size();
-    int inFlight = destination.getQueueInfo().inFlight.size();
+    Queue queue = destination.getQueue();
+    int pending = queue.pending.size();
+    int inFlight = queue.inFlight.size();
     while ((inFlight > 0 || pending > 0) && drainQueueAttempts > 0) {
       try {
         logger.atInfo().log(
@@ -233,8 +241,8 @@
         logger.atWarning().withCause(ie).log(
             "Wait for replication events to drain has been interrupted");
       }
-      pending = destination.getQueueInfo().pending.size();
-      inFlight = destination.getQueueInfo().inFlight.size();
+      pending = queue.pending.size();
+      inFlight = queue.inFlight.size();
       drainQueueAttempts--;
     }
     if (pending > 0 || inFlight > 0) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResource.java b/src/main/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResource.java
index 6932c3c..b8392c7 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResource.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResource.java
@@ -17,17 +17,20 @@
 import static com.google.common.io.Files.getNameWithoutExtension;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
+import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.util.Optional;
+import java.util.List;
 import java.util.Set;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.file.FileSnapshot;
@@ -40,6 +43,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final Path remoteConfigsDirPath;
+  private final Config fanoutConfig;
 
   @Inject
   FanoutConfigResource(SitePaths site) throws IOException, ConfigInvalidException {
@@ -48,16 +52,59 @@
     removeRemotes(config);
 
     try (Stream<Path> files = Files.list(remoteConfigsDirPath)) {
-      files
-          .filter(Files::isRegularFile)
-          .filter(FanoutConfigResource::isConfig)
-          .map(FanoutConfigResource::loadConfig)
-          .filter(Optional::isPresent)
-          .map(Optional::get)
-          .filter(FanoutConfigResource::isValid)
-          .forEach(cfg -> addRemoteConfig(cfg, config));
-    } catch (IllegalStateException e) {
-      throw new ConfigInvalidException(e.getMessage());
+      Stream<String> mergedConfigs =
+          files
+              .filter(Files::isRegularFile)
+              .filter(FanoutConfigResource::isConfig)
+              .flatMap(path -> getConfigLines(path, line -> addRemoteSubsection(line, path)));
+
+      fanoutConfig = new Config(config);
+      fanoutConfig.fromText(mergedConfigs.collect(Collectors.joining("\n")));
+    }
+  }
+
+  private static String addRemoteSubsection(String line, Path path) {
+    return line.contains("[remote]")
+        ? line.replace(
+            "[remote]", "[remote \"" + getNameWithoutExtension(path.toFile().getName()) + "\"]")
+        : line;
+  }
+
+  private static Stream<String> getConfigLines(
+      Path path, Function<String, String> configLineMapping) {
+    try {
+      List<String> configLines = Files.readAllLines(path);
+      if (!isValid(path, configLines)) {
+        return Stream.empty();
+      }
+      return configLines.stream().map(configLineMapping);
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log("Unable to access replication config %s", path);
+      return Stream.empty();
+    }
+  }
+
+  @Override
+  public void update(Config updates) throws IOException {
+    super.update(filterOutRemotes(updates));
+
+    Set<String> remotes = updates.getSubsections("remote");
+    for (String remote : remotes) {
+      File remoteFile = remoteConfigsDirPath.resolve(remote + ".config").toFile();
+      FileBasedConfig remoteConfig = new FileBasedConfig(remoteFile, FS.DETECTED);
+      try {
+        remoteConfig.load();
+      } catch (ConfigInvalidException e) {
+        throw new IOException(
+            String.format("Cannot parse configuration file: %s", remoteFile.getAbsoluteFile()), e);
+      }
+      Set<String> options = updates.getNames("remote", remote);
+      for (String option : options) {
+        List<String> values = List.of(updates.getStringList("remote", remote, option));
+        remoteConfig.setStringList("remote", null, option, values);
+        config.setStringList("remote", remote, option, values);
+      }
+      remoteConfig.save();
     }
   }
 
@@ -75,42 +122,67 @@
     }
   }
 
-  private static void addRemoteConfig(FileBasedConfig source, Config destination) {
-    String remoteName = getNameWithoutExtension(source.getFile().getName());
-    for (String name : source.getNames("remote")) {
-      destination.setStringList(
-          "remote",
-          remoteName,
-          name,
-          Lists.newArrayList(source.getStringList("remote", null, name)));
-    }
+  private static Config filterOutRemotes(Config config) {
+    Config filteredConfig = new Config();
+    Set<String> sections = config.getSections();
+    sections.stream()
+        .filter(Predicate.not("remote"::equalsIgnoreCase))
+        .forEach(
+            section -> {
+              config
+                  .getNames(section)
+                  .forEach(
+                      sectionName ->
+                          filteredConfig.setStringList(
+                              section,
+                              null,
+                              sectionName,
+                              List.of(config.getStringList(section, null, sectionName))));
+              config
+                  .getSubsections(section)
+                  .forEach(
+                      subsection ->
+                          config
+                              .getNames(section, subsection)
+                              .forEach(
+                                  name ->
+                                      filteredConfig.setStringList(
+                                          section,
+                                          subsection,
+                                          name,
+                                          List.of(
+                                              config.getStringList(section, subsection, name)))));
+            });
+    return filteredConfig;
   }
 
-  private static boolean isValid(Config cfg) {
-    if (cfg.getSections().size() != 1 || !cfg.getSections().contains("remote")) {
-      logger.atSevere().log(
-          "Remote replication configuration file %s must contain only one remote section.", cfg);
-      return false;
-    }
-    if (cfg.getSubsections("remote").size() > 0) {
-      logger.atSevere().log(
-          "Remote replication configuration file %s cannot contain remote subsections.", cfg);
-      return false;
+  private static boolean isValid(Path path, List<String> remoteConfigLines) {
+    int numRemoteSections = 0;
+    int numRemoteSectionsWithName = 0;
+    boolean valid = true;
+    for (String configLine : remoteConfigLines) {
+      if (configLine.contains("[remote]")) {
+        numRemoteSections++;
+      }
+
+      if (configLine.contains("[remote \"")) {
+        numRemoteSectionsWithName++;
+      }
     }
 
-    return true;
-  }
-
-  private static Optional<FileBasedConfig> loadConfig(Path path) {
-    FileBasedConfig cfg = new FileBasedConfig(path.toFile(), FS.DETECTED);
-    try {
-      cfg.load();
-    } catch (IOException | ConfigInvalidException e) {
-      logger.atSevere().withCause(e).log(
-          "Cannot load remote replication configuration file %s.", path);
-      return Optional.empty();
+    if (numRemoteSectionsWithName > 0) {
+      logger.atSevere().log(
+          "Remote replication configuration file %s cannot contain remote subsections.", path);
+      valid = false;
     }
-    return Optional.of(cfg);
+
+    if (numRemoteSections != 1) {
+      logger.atSevere().log(
+          "Remote replication configuration file %s must contain only one remote section.", path);
+      valid = false;
+    }
+
+    return valid;
   }
 
   private static boolean isConfig(Path p) {
@@ -142,4 +214,9 @@
       return parentVersion;
     }
   }
+
+  @Override
+  public Config getConfig() {
+    return fanoutConfig;
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/FileConfigResource.java b/src/main/java/com/googlesource/gerrit/plugins/replication/FileConfigResource.java
index baccb83..7a14cd1 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/FileConfigResource.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/FileConfigResource.java
@@ -17,6 +17,7 @@
 import static com.googlesource.gerrit.plugins.replication.ReplicationQueue.repLog;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.Lists;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.server.config.SitePaths;
@@ -24,6 +25,7 @@
 import com.googlesource.gerrit.plugins.replication.api.ConfigResource;
 import java.io.IOException;
 import java.nio.file.Path;
+import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
@@ -54,6 +56,25 @@
   }
 
   @Override
+  public void update(Config updates) throws IOException {
+    for (String section : updates.getSections()) {
+      for (String subsection : updates.getSubsections(section)) {
+        for (String name : updates.getNames(section, subsection, true)) {
+          List<String> values =
+              Lists.newArrayList(updates.getStringList(section, subsection, name));
+          config.setStringList(section, subsection, name, values);
+        }
+      }
+
+      for (String name : updates.getNames(section, true)) {
+        List<String> values = Lists.newArrayList(updates.getStringList(section, null, name));
+        config.setStringList(section, null, name, values);
+      }
+    }
+    config.save();
+  }
+
+  @Override
   public String getVersion() {
     return Long.toString(config.getFile().lastModified());
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/LegacyCredentialsFactory.java b/src/main/java/com/googlesource/gerrit/plugins/replication/LegacyCredentialsFactory.java
new file mode 100644
index 0000000..a89c936
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/LegacyCredentialsFactory.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2011 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.server.config.SitePaths;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Objects;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.transport.CredentialsProvider;
+import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
+import org.eclipse.jgit.util.FS;
+
+/**
+ * Looks up a remote's password in secure.config.
+ *
+ * @deprecated This class is not secure and should no longer be used; it was allowing to record and
+ *     read credentials in clear text in secure.config even though the Gerrit administrator
+ *     installed a proper SecureStore implementation such as the secure-config.jar libModule.
+ */
+@Deprecated(forRemoval = true)
+class LegacyCredentialsFactory implements CredentialsFactory {
+  private final Config config;
+
+  @Inject
+  public LegacyCredentialsFactory(SitePaths site) throws ConfigInvalidException, IOException {
+    config = load(site);
+  }
+
+  private static Config load(SitePaths site) throws ConfigInvalidException, IOException {
+    FileBasedConfig cfg = new FileBasedConfig(site.secure_config.toFile(), FS.DETECTED);
+    if (cfg.getFile().exists() && cfg.getFile().length() > 0) {
+      try {
+        cfg.load();
+      } catch (ConfigInvalidException e) {
+        throw new ConfigInvalidException(
+            String.format("Config file %s is invalid: %s", cfg.getFile(), e.getMessage()), e);
+      } catch (IOException e) {
+        throw new IOException(
+            String.format("Cannot read %s: %s", cfg.getFile(), e.getMessage()), e);
+      }
+    }
+    return cfg;
+  }
+
+  @Override
+  public CredentialsProvider create(String remoteName) {
+    String user = Objects.toString(config.getString("remote", remoteName, "username"), "");
+    String pass = Objects.toString(config.getString("remote", remoteName, "password"), "");
+    return new UsernamePasswordCredentialsProvider(user, pass);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ListCommand.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ListCommand.java
index 8f4e0a1..213d0de 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ListCommand.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ListCommand.java
@@ -90,7 +90,7 @@
         addProperty(obj, "AdminUrl", d.getAdminUrls());
         addProperty(obj, "AuthGroup", d.getAuthGroupNames());
         addProperty(obj, "Project", d.getProjects());
-        Destination.QueueInfo q = d.getQueueInfo();
+        Destination.Queue q = d.getQueue();
         addQueueDetails(obj, "InFlight", q.inFlight.values());
         addQueueDetails(obj, "Pending", q.pending.values());
       }
@@ -115,7 +115,7 @@
           out.append("Project: ").append(project).append("\n");
         }
 
-        Destination.QueueInfo q = d.getQueueInfo();
+        Destination.Queue q = d.getQueue();
         out.append("In Flight: ").append(q.inFlight.size()).append("\n");
         addQueueDetails(out, q.inFlight.values());
         out.append("Pending: ").append(q.pending.size()).append("\n");
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/MergedConfigResource.java b/src/main/java/com/googlesource/gerrit/plugins/replication/MergedConfigResource.java
index 43d90a5..58a10f0 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/MergedConfigResource.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/MergedConfigResource.java
@@ -26,6 +26,7 @@
 import com.google.inject.util.Providers;
 import com.googlesource.gerrit.plugins.replication.api.ConfigResource;
 import com.googlesource.gerrit.plugins.replication.api.ReplicationConfigOverrides;
+import java.io.IOException;
 import java.util.function.Supplier;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -77,7 +78,15 @@
     return baseVersion + overrides.get().getVersion();
   }
 
-  private boolean noOverrides() {
+  boolean noOverrides() {
     return overrides == null || overrides.get() == null;
   }
+
+  void update(Config remotesConfig) throws IOException {
+    if (noOverrides()) {
+      base.get().update(remotesConfig);
+    } else {
+      overrides.get().update(remotesConfig);
+    }
+  }
 }
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 b516fc7..9c4ad4d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
@@ -69,6 +69,7 @@
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import org.eclipse.jgit.errors.NoRemoteRepositoryException;
 import org.eclipse.jgit.errors.NotSupportedException;
 import org.eclipse.jgit.errors.RemoteRepositoryException;
@@ -79,7 +80,6 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.CredentialsProvider;
 import org.eclipse.jgit.transport.FetchConnection;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RefSpec;
@@ -119,7 +119,7 @@
   private final Destination pool;
   private final RemoteConfig config;
   private final ReplicationConfig replConfig;
-  private final CredentialsProvider credentialsProvider;
+  private final CredentialsFactory credentialsFactory;
   private final PerThreadRequestScope.Scoper threadScoper;
 
   private final Project.NameKey projectName;
@@ -166,7 +166,7 @@
     pool = p;
     config = c;
     replConfig = rc;
-    credentialsProvider = cpFactory.create(c.getName());
+    credentialsFactory = cpFactory;
     threadScoper = ts;
     projectName = d;
     uri = u;
@@ -247,18 +247,14 @@
   protected String getLimitedRefs() {
     Set<ImmutableSet<String>> refs = getRefs();
     int maxRefsToShow = replConfig.getMaxRefsToShow();
-    if (maxRefsToShow == 0) {
-      maxRefsToShow = refs.size();
-    }
-    String refsString =
-        refs.stream()
-            .flatMap(Collection::stream)
-            .limit(maxRefsToShow)
-            .collect(Collectors.joining(" "));
-    int hiddenRefs = refs.size() - maxRefsToShow;
-    if (hiddenRefs > 0) {
-      refsString += " (+" + hiddenRefs + ")";
-    }
+
+    Stream<String> refsStream = refs.stream().flatMap(Collection::stream);
+
+    Stream<String> refsFiltered =
+        (maxRefsToShow == 0) ? refsStream : refsStream.limit(maxRefsToShow);
+
+    String refsString = refsFiltered.collect(Collectors.joining(" "));
+
     return "[" + refsString + "]";
   }
 
@@ -563,7 +559,7 @@
 
   private PushResult pushVia(Transport tn) throws IOException, PermissionBackendException {
     tn.applyConfig(config);
-    tn.setCredentialsProvider(credentialsProvider);
+    tn.setCredentialsProvider(credentialsFactory.create(config.getName()));
 
     List<RemoteRefUpdate> todo = generateUpdates(tn);
     if (todo.isEmpty()) {
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 1223db0..8f9b805 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java
@@ -31,6 +31,7 @@
 
   private final SitePaths site;
   private final MergedConfigResource configResource;
+  private final boolean useLegacyCredentials;
   private boolean replicateAllOnPluginStart;
   private boolean defaultForceUpdate;
   private int maxRefsToLog;
@@ -62,6 +63,7 @@
                 DEFAULT_SSH_CONNECTION_TIMEOUT_MS,
                 MILLISECONDS);
     this.pluginDataDir = pluginDataDir;
+    this.useLegacyCredentials = config.getBoolean("gerrit", "useLegacyCredentials", false);
   }
 
   @Nullable
@@ -125,6 +127,11 @@
   }
 
   @Override
+  public boolean useLegacyCredentials() {
+    return useLegacyCredentials;
+  }
+
+  @Override
   public String getVersion() {
     return configResource.getVersion();
   }
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 ac27280..e3e4021 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.events.HeadUpdatedListener;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.events.ProjectDeletedListener;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.events.EventTypes;
 import com.google.inject.AbstractModule;
@@ -30,6 +31,7 @@
 import com.google.inject.Scopes;
 import com.google.inject.assistedinject.FactoryModuleBuilder;
 import com.google.inject.internal.UniqueAnnotations;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationRemotesApi;
 import com.googlesource.gerrit.plugins.replication.events.ProjectDeletionReplicationDoneEvent;
 import com.googlesource.gerrit.plugins.replication.events.ProjectDeletionReplicationFailedEvent;
 import com.googlesource.gerrit.plugins.replication.events.ProjectDeletionReplicationScheduledEvent;
@@ -52,9 +54,7 @@
 
   @Override
   protected void configure() {
-    install(new FactoryModuleBuilder().build(Destination.Factory.class));
     install(configModule);
-    bind(ReplicationQueue.class).in(Scopes.SINGLETON);
     bind(ObservableQueue.class).to(ReplicationQueue.class);
     bind(LifecycleListener.class)
         .annotatedWith(UniqueAnnotations.create())
@@ -63,6 +63,7 @@
     DynamicSet.bind(binder(), GitBatchRefUpdateListener.class).to(ReplicationQueue.class);
     DynamicSet.bind(binder(), ProjectDeletedListener.class).to(ReplicationQueue.class);
     DynamicSet.bind(binder(), HeadUpdatedListener.class).to(ReplicationQueue.class);
+    DynamicItem.bind(binder(), ReplicationRemotesApi.class).to(ReplicationRemotesApiImpl.class);
 
     bind(OnStartStop.class).in(Scopes.SINGLETON);
     bind(LifecycleListener.class).annotatedWith(UniqueAnnotations.create()).to(OnStartStop.class);
@@ -77,10 +78,8 @@
         .to(StartReplicationCapability.class);
 
     install(new FactoryModuleBuilder().build(PushAll.Factory.class));
-    install(new FactoryModuleBuilder().build(ProjectDeletionState.Factory.class));
 
     bind(EventBus.class).in(Scopes.SINGLETON);
-    bind(ReplicationDestinations.class).to(DestinationsCollection.class);
     bind(ConfigParser.class).to(DestinationConfigParser.class).in(Scopes.SINGLETON);
 
     DynamicSet.setOf(binder(), ReplicationStateListener.class);
@@ -103,5 +102,11 @@
 
     bind(TransportFactory.class).to(TransportFactoryImpl.class).in(Scopes.SINGLETON);
     bind(CloseableHttpClient.class).toProvider(HttpClientProvider.class).in(Scopes.SINGLETON);
+
+    bind(ReplicationQueue.class).in(Scopes.SINGLETON);
+    bind(ReplicationDestinations.class).to(DestinationsCollection.class);
+
+    install(new FactoryModuleBuilder().build(Destination.Factory.class));
+    install(new FactoryModuleBuilder().build(ProjectDeletionState.Factory.class));
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesApiImpl.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesApiImpl.java
new file mode 100644
index 0000000..cc6bcad
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesApiImpl.java
@@ -0,0 +1,100 @@
+// Copyright (C) 2024 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.server.securestore.SecureStore;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationRemotesApi;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+class ReplicationRemotesApiImpl implements ReplicationRemotesApi {
+
+  private final SecureStore secureStore;
+  private final MergedConfigResource mergedConfigResource;
+
+  @Inject
+  ReplicationRemotesApiImpl(SecureStore secureStore, MergedConfigResource mergedConfigResource) {
+    this.secureStore = secureStore;
+    this.mergedConfigResource = mergedConfigResource;
+  }
+
+  @Override
+  public Config get(String... remoteNames) {
+    Config replicationConfig = mergedConfigResource.getConfig();
+    Config remoteConfig = new Config();
+    for (String remoteName : remoteNames) {
+      Set<String> configNames = replicationConfig.getNames("remote", remoteName);
+      for (String configName : configNames) {
+        String[] values = replicationConfig.getStringList("remote", remoteName, configName);
+        if (values.length > 0) {
+          remoteConfig.setStringList("remote", remoteName, configName, Arrays.asList(values));
+        }
+      }
+    }
+    return remoteConfig;
+  }
+
+  @Override
+  public void update(Config remoteConfig) throws IOException {
+    if (remoteConfig.getSubsections("remote").isEmpty()) {
+      throw new IllegalArgumentException(
+          "configuration update must have at least one 'remote' section");
+    }
+
+    SeparatedRemoteConfigs configs = onlyRemoteSectionsWithSeparatedCredentials(remoteConfig);
+    persistRemotesCredentials(configs);
+
+    mergedConfigResource.update(configs.remotes);
+  }
+
+  private SeparatedRemoteConfigs onlyRemoteSectionsWithSeparatedCredentials(Config configUpdates) {
+    SeparatedRemoteConfigs configs = new SeparatedRemoteConfigs();
+    for (String subSection : configUpdates.getSubsections("remote")) {
+      for (String name : configUpdates.getNames("remote", subSection)) {
+        List<String> values = List.of(configUpdates.getStringList("remote", subSection, name));
+        if ("password".equals(name) || "username".equals(name)) {
+          configs.credentials.setStringList("remote", subSection, name, values);
+        } else {
+          configs.remotes.setStringList("remote", subSection, name, values);
+        }
+      }
+    }
+
+    return configs;
+  }
+
+  private void persistRemotesCredentials(SeparatedRemoteConfigs configs) {
+    for (String subSection : configs.credentials.getSubsections("remote")) {
+      copyRemoteStringList(configs.credentials, subSection, "username");
+      copyRemoteStringList(configs.credentials, subSection, "password");
+    }
+  }
+
+  private void copyRemoteStringList(Config config, String subSection, String key) {
+    secureStore.setList(
+        "remote", subSection, key, List.of(config.getStringList("remote", subSection, key)));
+  }
+
+  private static class SeparatedRemoteConfigs {
+    private final Config remotes = new Config();
+    private final Config credentials = new Config();
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/SecureCredentialsFactory.java b/src/main/java/com/googlesource/gerrit/plugins/replication/SecureCredentialsFactory.java
index ed15b92..3e00089 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/SecureCredentialsFactory.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/SecureCredentialsFactory.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2011 The Android Open Source Project
+// Copyright (C) 2024 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.
@@ -14,46 +14,43 @@
 
 package com.googlesource.gerrit.plugins.replication;
 
-import com.google.gerrit.server.config.SitePaths;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
+import com.google.gerrit.server.securestore.SecureStore;
 import com.google.inject.Inject;
-import java.io.IOException;
 import java.util.Objects;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.transport.CredentialsProvider;
 import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
-import org.eclipse.jgit.util.FS;
 
-/** Looks up a remote's password in secure.config. */
+/** Looks up a remote's password in SecureStore */
+@UsedAt(Project.PLUGIN_PULL_REPLICATION)
 public class SecureCredentialsFactory implements CredentialsFactory {
-  private final Config config;
+  private static final FluentLogger log = FluentLogger.forEnclosingClass();
+  private final SecureStore secureStore;
 
   @Inject
-  public SecureCredentialsFactory(SitePaths site) throws ConfigInvalidException, IOException {
-    config = load(site);
-  }
-
-  private static Config load(SitePaths site) throws ConfigInvalidException, IOException {
-    FileBasedConfig cfg = new FileBasedConfig(site.secure_config.toFile(), FS.DETECTED);
-    if (cfg.getFile().exists() && cfg.getFile().length() > 0) {
-      try {
-        cfg.load();
-      } catch (ConfigInvalidException e) {
-        throw new ConfigInvalidException(
-            String.format("Config file %s is invalid: %s", cfg.getFile(), e.getMessage()), e);
-      } catch (IOException e) {
-        throw new IOException(
-            String.format("Cannot read %s: %s", cfg.getFile(), e.getMessage()), e);
-      }
-    }
-    return cfg;
+  public SecureCredentialsFactory(SecureStore secureStore) {
+    this.secureStore = secureStore;
   }
 
   @Override
   public CredentialsProvider create(String remoteName) {
-    String user = Objects.toString(config.getString("remote", remoteName, "username"), "");
-    String pass = Objects.toString(config.getString("remote", remoteName, "password"), "");
+    String user = Objects.toString(secureStore.get("remote", remoteName, "username"), "");
+    String pass = Objects.toString(secureStore.get("remote", remoteName, "password"), "");
     return new UsernamePasswordCredentialsProvider(user, pass);
   }
+
+  @Override
+  public boolean validate(String remoteName) {
+    try {
+      String unused = secureStore.get("remote", remoteName, "username");
+      unused = secureStore.get("remote", remoteName, "password");
+      return true;
+    } catch (Throwable t) {
+      log.atSevere().withCause(t).log(
+          "Credentials for replication remote %s are invalid", remoteName);
+      return false;
+    }
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ApiModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ApiModule.java
index 3764ede..44ec88c 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ApiModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ApiModule.java
@@ -22,5 +22,6 @@
   protected void configure() {
     DynamicItem.itemOf(binder(), ReplicationPushFilter.class);
     DynamicItem.itemOf(binder(), ReplicationConfigOverrides.class);
+    DynamicItem.itemOf(binder(), ReplicationRemotesApi.class);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ConfigResource.java b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ConfigResource.java
index 43733bc..0052fc2 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ConfigResource.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ConfigResource.java
@@ -14,6 +14,7 @@
 
 package com.googlesource.gerrit.plugins.replication.api;
 
+import java.io.IOException;
 import org.eclipse.jgit.lib.Config;
 
 /**
@@ -37,6 +38,16 @@
   Config getConfig();
 
   /**
+   * Update the configuration resource.
+   *
+   * <p>Allows to persist changes to the configuration resource.
+   *
+   * @param config updated configuration
+   * @throws IOException when configuration cannot be persisted
+   */
+  void update(Config config) throws IOException;
+
+  /**
    * Current logical version string of the current configuration on the persistent storage.
    *
    * @return latest logical version number on the persistent storage
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 8ef4e2b..d5ccb02 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
@@ -102,4 +102,16 @@
    * @return the config.
    */
   Config getConfig();
+
+  /**
+   * Use legacy credentials for migrating existing sites.
+   *
+   * <p>Existing installations may have used a mix of encrypted and clear text credentials in
+   * secure.config, leveraging the replication plugin bug that was not accessing it using the
+   * correct API. The legacy feature flag 'gerrit.useLegacyCredentials' allows Gerrit to still use
+   * direct access to secure.config without decrypting its values.
+   *
+   * @return true if the secure.config should be read always directly and without decryption
+   */
+  boolean useLegacyCredentials();
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationRemotesApi.java b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationRemotesApi.java
new file mode 100644
index 0000000..76d6d35
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationRemotesApi.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2024 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.api;
+
+import static com.google.gerrit.common.UsedAt.Project.PLUGIN_GITHUB;
+
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.server.securestore.SecureStore;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Config;
+
+/** Public API to update replication plugin remotes configurations programmatically. */
+@UsedAt(PLUGIN_GITHUB)
+@DynamicItem.Final(implementedByPlugin = "replication")
+public interface ReplicationRemotesApi {
+
+  /**
+   * Retrieves the configuration for a remote by name.
+   *
+   * <p>Builds a JGit {@link Config} with the remote's configuration obtained by parsing the
+   * replication configuration sources.
+   *
+   * <p>NOTE: The remotes secrets are excluded for security reasons.
+   *
+   * @param remoteNames the remote names to retrieve.
+   * @return {@link Config} associated with the remoteName(s)
+   */
+  Config get(String... remoteNames);
+
+  /**
+   * Adds or updates the remote configuration for the replication plugin.
+   *
+   * <p>Provided JGit {@link Config} object should contain at least one named <em>remote</em>
+   * section. All other configurations will be ignored.
+   *
+   * <p>NOTE: The {@code remote.$name.password} will be stored using {@link SecureStore}.
+   *
+   * @param remoteConfig remotes to add or update
+   * @throws IOException when persisting fails
+   */
+  void update(Config remoteConfig) throws IOException;
+}
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 197c0d0..6cd0db1 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -170,6 +170,14 @@
 :	Timeout for SSH connections. If 0, there is no timeout and
         the client waits indefinitely. By default, 2 minutes.
 
+gerrit.useLegacyCredentials
+:	Use legacy credentials on secure.config which did not allow encryption
+	through a SecureStore provider. Legacy credentials should not be used in
+	production and are provided purely for backward compatibility with existing
+	secure.config files. When enabling the SecureStore in Gerrit, for instance,
+	the secure-config.jar libModule, make sure that all existing replication
+	credentials are encrypted. By default, false.
+
 replication.distributionInterval
 :	Interval in seconds for running the replication distributor. When
 	run, the replication distributor will add all persisted waiting tasks
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/AbstractConfigTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/AbstractConfigTest.java
index b8bbc08..935fdb4 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/AbstractConfigTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/AbstractConfigTest.java
@@ -57,7 +57,8 @@
     public final DestinationConfiguration config;
 
     protected FakeDestination(DestinationConfiguration config) {
-      super(injectorMock(), null, null, null, null, null, null, null, null, null, null, config);
+      super(
+          injectorMock(), null, null, null, null, null, null, null, null, null, null, null, config);
       this.config = config;
     }
 
@@ -78,7 +79,7 @@
   }
 
   @Before
-  public void setup() {
+  public void setup() throws IOException {
     when(destinationFactoryMock.create(any(DestinationConfiguration.class)))
         .thenAnswer(
             new Answer<Destination>() {
@@ -103,6 +104,10 @@
     return newReplicationConfig("replication.config");
   }
 
+  protected FileBasedConfig newSecureConfig() {
+    return newReplicationConfig("secure.config");
+  }
+
   protected FileBasedConfig newReplicationConfig(String path) {
     FileBasedConfig replicationConfig =
         new FileBasedConfig(sitePaths.etc_dir.resolve(path).toFile(), FS.DETECTED);
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecoratorTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecoratorTest.java
new file mode 100644
index 0000000..c959cc9
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecoratorTest.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2024 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.server.securestore.SecureStore;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.transport.CredentialItem;
+import org.eclipse.jgit.transport.CredentialsProvider;
+import org.eclipse.jgit.transport.URIish;
+import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class AutoReloadSecureCredentialsFactoryDecoratorTest extends AbstractConfigTest {
+  private static final String REMOTE_TEST = "remote-test";
+  private static final String USERNAME_CLEARTEXT = "username-clear";
+  private static final String PASSWORD_CLEARTEXT = "password-clear";
+  private static final String USERNAME_CIPHERTEXT = "username-encrypted";
+  private static final String PASSWORD_CIPHERTEXT = "password-encrypted";
+
+  @Mock private SecureStore secureStoreMock;
+  @Mock private ReplicationConfig replicationConfigMock;
+
+  public AutoReloadSecureCredentialsFactoryDecoratorTest() throws IOException {
+    super();
+  }
+
+  @Override
+  @Before
+  public void setup() throws IOException {
+    when(replicationConfigMock.getConfig()).thenReturn(new Config());
+
+    when(secureStoreMock.get("remote", REMOTE_TEST, "username")).thenReturn(USERNAME_CIPHERTEXT);
+    when(secureStoreMock.get("remote", REMOTE_TEST, "password")).thenReturn(PASSWORD_CIPHERTEXT);
+
+    FileBasedConfig secureConfig = newSecureConfig();
+    secureConfig.setString("remote", REMOTE_TEST, "username", USERNAME_CLEARTEXT);
+    secureConfig.setString("remote", REMOTE_TEST, "password", PASSWORD_CLEARTEXT);
+    secureConfig.save();
+  }
+
+  @Test
+  public void shouldCreateLegacyCredentials() throws Exception {
+    when(replicationConfigMock.useLegacyCredentials()).thenReturn(true);
+    assertUsernamePasswordCredentials(
+        getCredentialsProvider(), USERNAME_CLEARTEXT, PASSWORD_CLEARTEXT);
+  }
+
+  @Test
+  public void shouldCreateEncryptedCredentialsByDefault() throws Exception {
+    assertUsernamePasswordCredentials(
+        getCredentialsProvider(), USERNAME_CIPHERTEXT, PASSWORD_CIPHERTEXT);
+  }
+
+  private UsernamePasswordCredentialsProvider getCredentialsProvider()
+      throws ConfigInvalidException, IOException {
+    AutoReloadSecureCredentialsFactoryDecorator credFactory =
+        new AutoReloadSecureCredentialsFactoryDecorator(
+            sitePaths, secureStoreMock, replicationConfigMock);
+    CredentialsProvider legacyCredentials = credFactory.create(REMOTE_TEST);
+    assertThat(legacyCredentials).isInstanceOf(UsernamePasswordCredentialsProvider.class);
+    return (UsernamePasswordCredentialsProvider) legacyCredentials;
+  }
+
+  private static void assertUsernamePasswordCredentials(
+      UsernamePasswordCredentialsProvider credentials, String username, String password) {
+    CredentialItem.Username usernameItem = new CredentialItem.Username();
+    CredentialItem.Password passwordItem = new CredentialItem.Password();
+    assertThat(credentials.get(new URIish(), usernameItem, passwordItem)).isTrue();
+
+    assertThat(usernameItem.getValue()).isEqualTo(username);
+    assertThat(new String(passwordItem.getValue())).isEqualTo(password);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResourceTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResourceTest.java
index 9147ea1..a3d811f 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResourceTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResourceTest.java
@@ -24,6 +24,7 @@
 import java.io.IOException;
 import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
 import org.junit.Before;
@@ -285,9 +286,73 @@
     assertThat(objectUnderTest.getVersion()).isEqualTo(replicationConfigVersion);
   }
 
+  @Test
+  public void shouldAddConfigOptionToMainConfig() throws Exception {
+    FanoutConfigResource objectUnderTest = new FanoutConfigResource(sitePaths);
+    Config update = new Config();
+    update.setString("new", null, "value", "set");
+
+    objectUnderTest.update(update);
+    Config updatedConfig = objectUnderTest.getConfig();
+
+    assertThat(updatedConfig.getString("new", null, "value")).isEqualTo("set");
+  }
+
+  @Test
+  public void shouldUpdateConfigOptionInMainConfig() throws Exception {
+    FileBasedConfig config = newReplicationConfig();
+    config.setString("updatable", null, "value", "orig");
+    config.save();
+    FanoutConfigResource objectUnderTest = new FanoutConfigResource(sitePaths);
+    Config update = new Config();
+    update.setString("updatable", null, "value", "updated");
+
+    objectUnderTest.update(update);
+    Config updatedConfig = objectUnderTest.getConfig();
+
+    assertThat(updatedConfig.getString("updatable", null, "value")).isEqualTo("updated");
+  }
+
+  @Test
+  public void shouldAddNewRemoteFile() throws Exception {
+    FanoutConfigResource objectUnderTest = new FanoutConfigResource(sitePaths);
+    Config update = new Config();
+    update.setString("remote", remoteName1, "url", remoteUrl1);
+
+    objectUnderTest.update(update);
+
+    assertThat(objectUnderTest.getConfig().getString("remote", remoteName1, "url"))
+        .isEqualTo(remoteUrl1);
+    Config actual = loadRemoteConfig(remoteName1);
+    assertThat(actual.getString("remote", null, "url")).isEqualTo(remoteUrl1);
+  }
+
+  @Test
+  public void shouldUpdateExistingRemote() throws Exception {
+    FileBasedConfig rawRemoteConfig = newRemoteConfig(remoteName1);
+    rawRemoteConfig.setString("remote", remoteName1, "url", remoteUrl1);
+    rawRemoteConfig.save();
+    FanoutConfigResource objectUnderTest = new FanoutConfigResource(sitePaths);
+    Config update = new Config();
+    update.setString("remote", remoteName1, "url", remoteUrl2);
+
+    objectUnderTest.update(update);
+
+    assertThat(objectUnderTest.getConfig().getString("remote", remoteName1, "url"))
+        .isEqualTo(remoteUrl2);
+    Config actual = loadRemoteConfig(remoteName1);
+    assertThat(actual.getString("remote", null, "url")).isEqualTo(remoteUrl2);
+  }
+
   protected FileBasedConfig newRemoteConfig(String configFileName) {
     return new FileBasedConfig(
         sitePaths.etc_dir.resolve("replication/" + configFileName + ".config").toFile(),
         FS.DETECTED);
   }
+
+  private Config loadRemoteConfig(String siteName) throws Exception {
+    FileBasedConfig config = newRemoteConfig(siteName);
+    config.load();
+    return config;
+  }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/FileConfigResourceTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/FileConfigResourceTest.java
new file mode 100644
index 0000000..92bf58d
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/FileConfigResourceTest.java
@@ -0,0 +1,121 @@
+// Copyright (C) 2024 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.io.RecursiveDeleteOption.ALLOW_INSECURE;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.io.MoreFiles;
+import com.google.gerrit.server.config.SitePaths;
+import com.googlesource.gerrit.plugins.replication.api.ConfigResource;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class FileConfigResourceTest {
+  private static final String VALUE_KEY = "value";
+  private static final String INITIAL_KEY = "initial";
+  private static final String UPDATABLE_KEY = "updatable";
+
+  private Path testDir;
+  private SitePaths sitePaths;
+  private ConfigResource configResource;
+
+  @Before
+  public void setUp() throws Exception {
+    testDir = Files.createTempDirectory("fileConfigResourceTest");
+    sitePaths = new SitePaths(testDir);
+    configResource = newFileConfigResource();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    MoreFiles.deleteRecursively(testDir, ALLOW_INSECURE);
+  }
+
+  @Test
+  public void updateEmptyFile() throws Exception {
+    Config configUpdate = newConfigUpdate();
+
+    Config beforeUpdate = configResource.getConfig();
+    assertThat(beforeUpdate.getSections()).isEmpty();
+
+    configResource.update(configUpdate);
+    Config updatedConfig = newFileConfigResource().getConfig();
+
+    assertConfigUpdate(updatedConfig);
+  }
+
+  @Test
+  public void appendOptionToConfig() throws Exception {
+    FileBasedConfig rawConfig = getRawReplicationConfig();
+    rawConfig.setInt(INITIAL_KEY, null, VALUE_KEY, 10);
+    rawConfig.save();
+    configResource = newFileConfigResource();
+    Config configUpdate = newConfigUpdate();
+
+    configResource.update(configUpdate);
+    Config updatedConfig = configResource.getConfig();
+
+    assertConfigUpdate(updatedConfig);
+    assertThat(updatedConfig.getInt(INITIAL_KEY, null, VALUE_KEY, -1)).isEqualTo(10);
+  }
+
+  @Test
+  public void updateExistingOption() throws Exception {
+    int expectedValue = 20;
+    Config configUpdate = new Config();
+    configUpdate.setInt(UPDATABLE_KEY, null, VALUE_KEY, expectedValue);
+    FileBasedConfig rawConfig = getRawReplicationConfig(newConfigUpdate());
+    rawConfig.save();
+
+    configResource.update(configUpdate);
+    Config updatedConfig = configResource.getConfig();
+
+    assertConfigUpdate(updatedConfig, expectedValue);
+  }
+
+  private FileConfigResource newFileConfigResource() {
+    return new FileConfigResource(sitePaths);
+  }
+
+  private Config newConfigUpdate() {
+    Config configUpdate = new Config();
+    configUpdate.setInt(UPDATABLE_KEY, null, VALUE_KEY, 1);
+    return configUpdate;
+  }
+
+  private void assertConfigUpdate(Config config) {
+    assertConfigUpdate(config, 1);
+  }
+
+  private void assertConfigUpdate(Config config, int expectedValue) {
+    assertThat(config.getInt(UPDATABLE_KEY, null, VALUE_KEY, -1)).isEqualTo(expectedValue);
+  }
+
+  private FileBasedConfig getRawReplicationConfig() {
+    return getRawReplicationConfig(new Config());
+  }
+
+  private FileBasedConfig getRawReplicationConfig(Config base) {
+    Path configPath = sitePaths.etc_dir.resolve(FileConfigResource.CONFIG_NAME);
+    return new FileBasedConfig(base, configPath.toFile(), FS.DETECTED);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/MergedConfigResourceTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/MergedConfigResourceTest.java
index 18bb603..cae78e3 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/MergedConfigResourceTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/MergedConfigResourceTest.java
@@ -22,6 +22,7 @@
 import com.googlesource.gerrit.plugins.replication.api.ApiModule;
 import com.googlesource.gerrit.plugins.replication.api.ConfigResource;
 import com.googlesource.gerrit.plugins.replication.api.ReplicationConfigOverrides;
+import org.apache.commons.lang3.NotImplementedException;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
@@ -78,6 +79,11 @@
     }
 
     @Override
+    public void update(Config config) {
+      throw new NotImplementedException("not implemented");
+    }
+
+    @Override
     public String getVersion() {
       return "base";
     }
@@ -93,6 +99,11 @@
     }
 
     @Override
+    public void update(Config config) {
+      throw new NotImplementedException("not implemented");
+    }
+
+    @Override
     public String getVersion() {
       return "override";
     }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesApiTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesApiTest.java
new file mode 100644
index 0000000..40d9846
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesApiTest.java
@@ -0,0 +1,212 @@
+// Copyright (C) 2024 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.io.RecursiveDeleteOption.ALLOW_INSECURE;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import com.google.common.io.MoreFiles;
+import com.google.common.truth.StringSubject;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.securestore.SecureStore;
+import com.google.inject.AbstractModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.googlesource.gerrit.plugins.replication.api.ConfigResource;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfigOverrides;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationRemotesApi;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ReplicationRemotesApiTest {
+
+  private Path testSite;
+  private SecureStore secureStoreMock;
+  private FileConfigResource baseConfig;
+  private Injector testInjector;
+  private AtomicReference<ReplicationConfigOverrides> testOverrides;
+  private String url1;
+  private String remoteName1;
+  private String url2;
+  private String remoteName2;
+
+  @Before
+  public void setUp() throws Exception {
+    testSite = Files.createTempDirectory("replicationRemotesUpdateTest");
+    secureStoreMock = mock(SecureStore.class);
+    baseConfig = new FileConfigResource(new SitePaths(testSite));
+    testOverrides = new AtomicReference<>(new TestReplicationConfigOverrides());
+    testInjector =
+        Guice.createInjector(
+            new AbstractModule() {
+              @Override
+              protected void configure() {
+                bind(ConfigResource.class).toInstance(baseConfig);
+                bind(SecureStore.class).toInstance(secureStoreMock);
+                bind(ReplicationRemotesApi.class).to(ReplicationRemotesApiImpl.class);
+                DynamicItem.itemOf(binder(), ReplicationConfigOverrides.class);
+                DynamicItem.bind(binder(), ReplicationConfigOverrides.class)
+                    .toProvider(testOverrides::get);
+              }
+            });
+    url1 = "fake_url1";
+    remoteName1 = "site1";
+    url2 = "fake_url2";
+    remoteName2 = "site2";
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    MoreFiles.deleteRecursively(testSite, ALLOW_INSECURE);
+  }
+
+  @Test
+  public void shouldThrowWhenNoRemotesInTheUpdate() {
+    Config update = new Config();
+    ReplicationRemotesApi objectUnderTest = getReplicationRemotesApi();
+
+    assertThrows(IllegalArgumentException.class, () -> objectUnderTest.update(update));
+
+    update.setString("non-remote", null, "value", "one");
+
+    assertThrows(IllegalArgumentException.class, () -> objectUnderTest.update(update));
+  }
+
+  @Test
+  public void shouldReturnEmptyConfigWhenNoRemotes() {
+    ReplicationRemotesApi objectUnderTest = getReplicationRemotesApi();
+    assertThat(objectUnderTest.get("site").getSections()).isEmpty();
+  }
+
+  @Test
+  public void addRemoteSectionsToBaseConfigWhenNoOverrides() throws Exception {
+    testOverrides.set(null);
+
+    Config update = new Config();
+    setRemoteSite(update, remoteName1, "url", url1);
+    setRemoteSite(update, remoteName2, "url", url2);
+    ReplicationRemotesApi objectUnderTest = getReplicationRemotesApi();
+
+    objectUnderTest.update(update);
+
+    assertRemoteSite(baseConfig.getConfig(), remoteName1, "url").isEqualTo(url1);
+    assertRemoteSite(baseConfig.getConfig(), remoteName2, "url").isEqualTo(url2);
+  }
+
+  @Test
+  public void shouldReturnRemotesFromBaseConfigWhenNoOverrides() {
+    ReplicationRemotesApi objectUnderTest = getReplicationRemotesApi();
+
+    baseConfig.getConfig().setString("remote", remoteName1, "url", url1);
+    baseConfig.getConfig().setString("remote", remoteName2, "url", url2);
+
+    Config remotesConfig = objectUnderTest.get(remoteName1, remoteName2);
+    assertRemoteSite(remotesConfig, remoteName1, "url").isEqualTo(url1);
+    assertRemoteSite(remotesConfig, remoteName2, "url").isEqualTo(url2);
+  }
+
+  @Test
+  public void addRemotesSectionToBaseOverridesConfig() throws Exception {
+    Config update = new Config();
+    setRemoteSite(update, remoteName1, "url", url1);
+    setRemoteSite(update, remoteName2, "url", url2);
+    ReplicationRemotesApi objectUnderTest = getReplicationRemotesApi();
+
+    objectUnderTest.update(update);
+
+    assertRemoteSite(testOverrides.get().getConfig(), remoteName1, "url").isEqualTo(url1);
+    assertRemoteSite(testOverrides.get().getConfig(), remoteName2, "url").isEqualTo(url2);
+    assertRemoteSite(baseConfig.getConfig(), remoteName1, "url").isNull();
+    assertRemoteSite(baseConfig.getConfig(), remoteName2, "url").isNull();
+
+    Config remotesConfig = objectUnderTest.get(remoteName1, remoteName2);
+    assertRemoteSite(remotesConfig, remoteName1, "url").isEqualTo(url1);
+    assertRemoteSite(remotesConfig, remoteName2, "url").isEqualTo(url2);
+  }
+
+  @Test
+  public void shouldSetPasswordViaSecureStoreButNotStoreInConfig() throws Exception {
+    Config update = new Config();
+    String password = "encrypted-password";
+    String remoteName = "site";
+    setRemoteSite(update, remoteName, "password", password);
+    ReplicationRemotesApi objectUnderTest = getReplicationRemotesApi();
+
+    objectUnderTest.update(update);
+
+    verify(secureStoreMock).setList("remote", remoteName, "password", List.of(password));
+    assertRemoteSite(baseConfig.getConfig(), remoteName, "password").isNull();
+    assertRemoteSite(testOverrides.get().getConfig(), remoteName, "password").isNull();
+    assertRemoteSite(objectUnderTest.get(remoteName), remoteName, "password").isNull();
+  }
+
+  @Test
+  public void shouldSetUsernameViaSecureStoreButNotStoreInConfig() throws Exception {
+    Config update = new Config();
+    String username = "encrypted-username";
+    String remoteName = "site";
+    setRemoteSite(update, remoteName, "username", username);
+    ReplicationRemotesApi objectUnderTest = getReplicationRemotesApi();
+
+    objectUnderTest.update(update);
+
+    verify(secureStoreMock).setList("remote", remoteName, "username", List.of(username));
+    assertRemoteSite(baseConfig.getConfig(), remoteName, "username").isNull();
+    assertRemoteSite(testOverrides.get().getConfig(), remoteName, "username").isNull();
+    assertRemoteSite(objectUnderTest.get(remoteName), remoteName, "username").isNull();
+  }
+
+  private void setRemoteSite(Config config, String remoteName, String name, String value) {
+    config.setString("remote", remoteName, name, value);
+  }
+
+  private StringSubject assertRemoteSite(Config config, String remoteName, String name) {
+    return assertThat(config.getString("remote", remoteName, name));
+  }
+
+  private ReplicationRemotesApi getReplicationRemotesApi() {
+    return testInjector.getInstance(ReplicationRemotesApi.class);
+  }
+
+  static class TestReplicationConfigOverrides implements ReplicationConfigOverrides {
+    private Config config = new Config();
+
+    @Override
+    public Config getConfig() {
+      return config;
+    }
+
+    @Override
+    public void update(Config update) throws IOException {
+      config = update;
+    }
+
+    @Override
+    public String getVersion() {
+      return "none";
+    }
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStorageIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStorageIT.java
index e800cff..94646c3 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStorageIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStorageIT.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.acceptance.WaitUtil;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.googlesource.gerrit.plugins.replication.Destination.QueueInfo;
+import com.googlesource.gerrit.plugins.replication.Destination.Queue;
 import com.googlesource.gerrit.plugins.replication.ReplicationTasksStorage.ReplicateRefUpdate;
 import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig.FilterType;
 import java.io.IOException;
@@ -303,11 +303,11 @@
     createTestProject(projectName);
 
     WaitUtil.waitUntil(
-        () -> isTaskRescheduled(destination.getQueueInfo(), urish), TEST_NEW_PROJECT_TIMEOUT);
+        () -> isTaskRescheduled(destination.getQueue(), urish), TEST_NEW_PROJECT_TIMEOUT);
     // replicationRetry is set to 1 minute which is the minimum value. That's why
     // should be safe to get the pushOne object from pending because it should be
     // here for one minute
-    PushOne pushOp = destination.getQueueInfo().pending.get(urish);
+    PushOne pushOp = destination.getQueue().pending.get(urish);
 
     WaitUtil.waitUntil(() -> pushOp.wasCanceled(), MAX_RETRY_WITH_TOLERANCE_TIMEOUT);
     WaitUtil.waitUntil(() -> isTaskCleanedUp(), TEST_TASK_FINISH_TIMEOUT);
@@ -333,7 +333,7 @@
     assertThat(listWaitingReplicationTasks(branchToDelete)).hasSize(1);
   }
 
-  private boolean isTaskRescheduled(QueueInfo queue, URIish uri) {
+  private boolean isTaskRescheduled(Queue queue, URIish uri) {
     PushOne pushOne = queue.pending.get(uri);
     return pushOne == null ? false : pushOne.isRetrying();
   }