Merge "Prevent ConcurrentModificationException for PushOne.refBatchesToPush"
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ConfigResource.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ConfigResource.java
index 7b41fe6..c0a5a7d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ConfigResource.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ConfigResource.java
@@ -14,6 +14,7 @@
 
 package com.googlesource.gerrit.plugins.replication;
 
+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 loaded in memory, depending on the
    * actual implementation of the configuration on the persistent storage.
    *
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 008e50b..98170ae 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
@@ -127,8 +127,8 @@
   }
 
   public static class QueueInfo {
-    public final Map<URIish, PushOne> pending;
-    public final Map<URIish, PushOne> inFlight;
+    public final ImmutableMap<URIish, PushOne> pending;
+    public final ImmutableMap<URIish, PushOne> inFlight;
 
     public QueueInfo(Map<URIish, PushOne> pending, Map<URIish, PushOne> inFlight) {
       this.pending = ImmutableMap.copyOf(pending);
@@ -640,7 +640,7 @@
     }
 
     // by default push all projects
-    List<String> projects = config.getProjects();
+    ImmutableList<String> projects = config.getProjects();
     if (projects.isEmpty()) {
       return true;
     }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java
index 08f3477..3854801 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java
@@ -52,7 +52,7 @@
 
   private final Destination.Factory destinationFactory;
   private final Provider<ReplicationQueue> replicationQueue;
-  private volatile List<Destination> destinations;
+  private volatile ImmutableList<Destination> destinations;
   private boolean shuttingDown;
 
   public static class EventQueueNotEmptyException extends Exception {
@@ -258,7 +258,7 @@
     }
   }
 
-  private List<Destination> allDestinations(
+  private ImmutableList<Destination> allDestinations(
       Destination.Factory destinationFactory, List<RemoteConfiguration> remoteConfigurations) {
 
     ImmutableList.Builder<Destination> dest = ImmutableList.builder();
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 4220ddb..0a2afa6 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResource.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResource.java
@@ -23,9 +23,11 @@
 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.List;
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Stream;
@@ -61,6 +63,30 @@
     }
   }
 
+  @Override
+  public void update(Config updates) throws IOException {
+    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", remote, option, values);
+      }
+      remoteConfig.save();
+    }
+
+    removeRemotes(updates);
+    super.update(updates);
+  }
+
   private static void removeRemotes(Config config) {
     Set<String> remoteNames = config.getSubsections("remote");
     if (remoteNames.size() > 0) {
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 e5bd8d0..7e19d2c 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/FileConfigResource.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/FileConfigResource.java
@@ -17,12 +17,14 @@
 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;
 import com.google.inject.Inject;
 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;
@@ -53,6 +55,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/PushOne.java b/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
index c25f375..a9f8154 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
@@ -355,7 +355,7 @@
   }
 
   ReplicationState[] getStatesByRef(String ref) {
-    Collection<ReplicationState> states = stateMap.get(ref);
+    List<ReplicationState> states = stateMap.get(ref);
     return states.toArray(new ReplicationState[states.size()]);
   }
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteConfiguration.java b/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteConfiguration.java
index 05b4066..726bcf5 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteConfiguration.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteConfiguration.java
@@ -14,7 +14,6 @@
 package com.googlesource.gerrit.plugins.replication;
 
 import com.google.common.collect.ImmutableList;
-import java.util.List;
 import org.eclipse.jgit.transport.RemoteConfig;
 
 /** Remote configuration for a replication endpoint */
@@ -117,7 +116,7 @@
    * @return true, when configuration is for a single project, false otherwise
    */
   default boolean isSingleProjectMatch() {
-    List<String> projects = getProjects();
+    ImmutableList<String> projects = getProjects();
     boolean ret = (projects.size() == 1);
     if (ret) {
       String projectMatch = projects.get(0);
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 76ee14b..4f2def8 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java
@@ -13,9 +13,13 @@
 // limitations under the License.
 package com.googlesource.gerrit.plugins.replication;
 
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.PluginData;
+import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import java.nio.file.Path;
@@ -30,7 +34,7 @@
   private int maxRefsToLog;
   private final int maxRefsToShow;
   private int sshCommandTimeout;
-  private int sshConnectionTimeout = DEFAULT_SSH_CONNECTION_TIMEOUT_MS;
+  private int sshConnectionTimeout;
   private final MergedConfigResource configResource;
   private final Path pluginDataDir;
 
@@ -44,6 +48,17 @@
     this.defaultForceUpdate = config.getBoolean("gerrit", "defaultForceUpdate", false);
     this.maxRefsToLog = config.getInt("gerrit", "maxRefsToLog", 0);
     this.maxRefsToShow = config.getInt("gerrit", "maxRefsToShow", 2);
+    this.sshCommandTimeout =
+        (int) ConfigUtil.getTimeUnit(config, "gerrit", null, "sshCommandTimeout", 0, SECONDS);
+    this.sshConnectionTimeout =
+        (int)
+            ConfigUtil.getTimeUnit(
+                config,
+                "gerrit",
+                null,
+                "sshConnectionTimeout",
+                DEFAULT_SSH_CONNECTION_TIMEOUT_MS,
+                MILLISECONDS);
     this.pluginDataDir = pluginDataDir;
   }
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigModule.java
index 93a2b85..114ad15 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigModule.java
@@ -23,11 +23,14 @@
 import com.google.inject.Inject;
 import com.google.inject.ProvisionException;
 import com.google.inject.Scopes;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
 import com.google.inject.internal.UniqueAnnotations;
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
+
+import com.googlesource.gerrit.plugins.replication.events.ProjectDeletionState;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
@@ -55,6 +58,14 @@
     } else {
       bind(ReplicationConfig.class).to(ReplicationConfigImpl.class).in(Scopes.SINGLETON);
     }
+
+    bind(ReplicationQueue.class).in(Scopes.SINGLETON);
+    bind(ObservableQueue.class).to(ReplicationQueue.class);
+    bind(ReplicationDestinations.class).to(DestinationsCollection.class);
+    bind(ConfigParser.class).to(DestinationConfigParser.class).in(Scopes.SINGLETON);
+
+    install(new FactoryModuleBuilder().build(Destination.Factory.class));
+    install(new FactoryModuleBuilder().build(ProjectDeletionState.Factory.class));
   }
 
   public FileBasedConfig getReplicationConfig() {
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..a357a20 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
@@ -52,10 +52,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())
         .to(ReplicationQueue.class);
@@ -77,10 +74,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);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesUpdater.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesUpdater.java
new file mode 100644
index 0000000..8c27ff6
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesUpdater.java
@@ -0,0 +1,108 @@
+// 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.gerrit.common.UsedAt.Project.PLUGIN_GITHUB;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.server.securestore.SecureStore;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+
+/** Public API to update replication plugin remotes configurations programmatically. */
+@Singleton
+@UsedAt(PLUGIN_GITHUB)
+public class ReplicationRemotesUpdater {
+
+  private final SecureStore secureStore;
+  private final Provider<ConfigResource> baseConfigProvider;
+  private final DynamicItem<ReplicationConfigOverrides> configOverridesItem;
+
+  @Inject
+  ReplicationRemotesUpdater(
+      SecureStore secureStore,
+      Provider<ConfigResource> baseConfigProvider,
+      @Nullable DynamicItem<ReplicationConfigOverrides> configOverridesItem) {
+    this.secureStore = secureStore;
+    this.baseConfigProvider = baseConfigProvider;
+    this.configOverridesItem = configOverridesItem;
+  }
+
+  /**
+   * 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
+   */
+  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 = onlyRemoteSectionsWithSeparatedPasswords(remoteConfig);
+    persistRemotesPasswords(configs);
+
+    if (hasConfigOverrides()) {
+      configOverridesItem.get().update(configs.remotes);
+    } else {
+      baseConfigProvider.get().update(configs.remotes);
+    }
+  }
+
+  private SeparatedRemoteConfigs onlyRemoteSectionsWithSeparatedPasswords(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)) {
+          configs.passwords.setStringList("remote", subSection, "password", values);
+        } else {
+          configs.remotes.setStringList("remote", subSection, name, values);
+        }
+      }
+    }
+
+    return configs;
+  }
+
+  private void persistRemotesPasswords(SeparatedRemoteConfigs configs) {
+    for (String subSection : configs.passwords.getSubsections("remote")) {
+      List<String> values =
+          List.of(configs.passwords.getStringList("remote", subSection, "password"));
+      secureStore.setList("remote", subSection, "password", values);
+    }
+  }
+
+  private boolean hasConfigOverrides() {
+    return configOverridesItem != null && configOverridesItem.get() != null;
+  }
+
+  private static class SeparatedRemoteConfigs {
+    private final Config remotes = new Config();
+    private final Config passwords = new Config();
+  }
+}
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 6782700..61f81df 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResourceTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResourceTest.java
@@ -16,12 +16,15 @@
 
 import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE;
 import static com.google.common.truth.Truth.assertThat;
+import static com.googlesource.gerrit.plugins.replication.FanoutConfigResource.CONFIG_DIR;
 
 import com.google.common.io.MoreFiles;
 import com.googlesource.gerrit.plugins.replication.ReplicationConfig.FilterType;
+import java.io.File;
 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;
@@ -39,13 +42,18 @@
   String remoteUrl2 = "ssh://git@git.elsewhere.com/${name}";
 
   @Before
-  public void setupTests() {
+  public void setupTests() throws Exception {
     FileBasedConfig config = newReplicationConfig();
     try {
       config.save();
     } catch (IOException e) {
       throw new RuntimeException(e);
     }
+    File replicationConfig = sitePaths.etc_dir.resolve(CONFIG_DIR).toFile();
+    if (!replicationConfig.mkdir()) {
+      throw new IOException(
+          "Cannot create test replication config directory in: " + replicationConfig.toPath().toAbsolutePath());
+    }
   }
 
   @Test
@@ -277,9 +285,69 @@
     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);
+
+    Config actual = loadRemoteConfig(remoteName1);
+    assertThat(actual.getString("remote", remoteName1, "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);
+
+    Config actual = loadRemoteConfig(remoteName1);
+    assertThat(actual.getString("remote", remoteName1, "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..9e8d36c
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/FileConfigResourceTest.java
@@ -0,0 +1,120 @@
+// 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 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 86da1c0..c9ce2c5 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/MergedConfigResourceTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/MergedConfigResourceTest.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
+import org.apache.commons.lang3.NotImplementedException;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
@@ -75,6 +76,11 @@
     }
 
     @Override
+    public void update(Config config) {
+      throw new NotImplementedException("not implemented");
+    }
+
+    @Override
     public String getVersion() {
       return "base";
     }
@@ -90,6 +96,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/ReplicationRemotesUpdaterTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesUpdaterTest.java
new file mode 100644
index 0000000..6e57ba7
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesUpdaterTest.java
@@ -0,0 +1,149 @@
+// 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 static org.mockito.Mockito.when;
+
+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.util.Providers;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ReplicationRemotesUpdaterTest {
+
+  private Path testSite;
+  private SecureStore secureStoreMock;
+  private FileConfigResource baseConfig;
+
+  @Before
+  public void setUp() throws Exception {
+    testSite = Files.createTempDirectory("replicationRemotesUpdateTest");
+    secureStoreMock = mock(SecureStore.class);
+    baseConfig = new FileConfigResource(new SitePaths(testSite));
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    MoreFiles.deleteRecursively(testSite, ALLOW_INSECURE);
+  }
+
+  @Test
+  public void shouldThrowWhenNoRemotesInTheUpdate() {
+    Config update = new Config();
+    ReplicationRemotesUpdater objectUnderTest = newReplicationConfigUpdater();
+
+    assertThrows(IllegalArgumentException.class, () -> objectUnderTest.update(update));
+
+    update.setString("non-remote", null, "value", "one");
+
+    assertThrows(IllegalArgumentException.class, () -> objectUnderTest.update(update));
+  }
+
+  @Test
+  public void addRemoteSectionToBaseConfigWhenNoOverrides() throws Exception {
+    String url = "fake_url";
+    Config update = new Config();
+    setRemoteSite(update, "url", url);
+    ReplicationRemotesUpdater objectUnderTest = newReplicationConfigUpdater();
+
+    objectUnderTest.update(update);
+
+    assertRemoteSite(baseConfig.getConfig(), "url").isEqualTo(url);
+  }
+
+  @Test
+  public void addRemoteSectionToBaseOverridesConfig() throws Exception {
+    TestReplicationConfigOverrides testOverrides = new TestReplicationConfigOverrides();
+    String url = "fake_url";
+    Config update = new Config();
+    setRemoteSite(update, "url", url);
+    ReplicationRemotesUpdater objectUnderTest = newReplicationConfigUpdater(testOverrides);
+
+    objectUnderTest.update(update);
+
+    assertRemoteSite(testOverrides.getConfig(), "url").isEqualTo(url);
+    assertRemoteSite(baseConfig.getConfig(), "url").isNull();
+  }
+
+  @Test
+  public void encryptPassword() throws Exception {
+    TestReplicationConfigOverrides testOverrides = new TestReplicationConfigOverrides();
+    Config update = new Config();
+    String password = "my_secret_password";
+    setRemoteSite(update, "password", password);
+    ReplicationRemotesUpdater objectUnderTest = newReplicationConfigUpdater(testOverrides);
+
+    objectUnderTest.update(update);
+
+    verify(secureStoreMock).setList("remote", "site", "password", List.of(password));
+    assertRemoteSite(baseConfig.getConfig(), "password").isNull();
+    assertRemoteSite(testOverrides.getConfig(), "password").isNull();
+  }
+
+  private ReplicationRemotesUpdater newReplicationConfigUpdater() {
+    return newReplicationConfigUpdater(null);
+  }
+
+  private void setRemoteSite(Config config, String name, String value) {
+    config.setString("remote", "site", name, value);
+  }
+
+  private StringSubject assertRemoteSite(Config config, String name) {
+    return assertThat(config.getString("remote", "site", name));
+  }
+
+  private ReplicationRemotesUpdater newReplicationConfigUpdater(
+      ReplicationConfigOverrides overrides) {
+    DynamicItem<ReplicationConfigOverrides> dynamicItemMock = mock(DynamicItem.class);
+    when(dynamicItemMock.get()).thenReturn(overrides);
+
+    return new ReplicationRemotesUpdater(
+        secureStoreMock, Providers.of(baseConfig), dynamicItemMock);
+  }
+
+  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";
+    }
+  }
+}