Merge branch 'stable-3.4'

* stable-3.4:
  Introduce Bearer Token Authentication
  Remove authorisation from ProjectInitializationAction
  Allow internal user to delete project in Pull Replication API in a primary node
  Fix flaky test in Pull Replication plugin
  FetchJob creation fails in replica when FetchAction is invoked with async=true
  Remove authorisation from PullReplicationFilter
  Use preemptive basic authentication in Pull Replication Plugin
  Do not rely on magic numbers for parsing the URL
  Do not use timer.getStartTime() when propating timers metrics
  Add missing @Assisted when creating the metrics
  Do not rely on System.nanoTime for E2E metrics
  Reduce default exclude refs filter
  Introduce the apply-objects REST-API for the whole '/meta' chain
  Fix the processing of an empty HTML response body from REST-API
  Fix issue with ref deletion and global-refdb
  Fix issue with fetching all refs after project creation
  Always fallback to fetch when ApplyObject REST-API fails
  Log the reason why a ref object wasn't loaded by RevisionReader
  Consider any HTTP 2xx response code from REST-API as success
  Return NO_CONTENT when removing a ref through ApplyObject
  Introduce E2E fetch REST-API metrics
  Fix ApplyObjectIT.shouldApplyRefMetaObject test for apply object
  Introduce E2E apply object REST-API metrics
  Add missing @Override to parseRemotes
  Add more logging for the apply object REST-API
  Support ApplyObject of non-commit refs
  Refactor FetchJob to an assisted injection factory

Release-Notes: skip
Change-Id: I5766e7cd973744c4244c07e9918094d73340f076
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ApplyObjectMetrics.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ApplyObjectMetrics.java
index 78b6ddb..78745bb 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ApplyObjectMetrics.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ApplyObjectMetrics.java
@@ -26,25 +26,33 @@
 @Singleton
 public class ApplyObjectMetrics {
   private final Timer1<String> executionTime;
+  private final Timer1<String> end2EndTime;
 
   @Inject
   ApplyObjectMetrics(@PluginName String pluginName, MetricMaker metricMaker) {
-    Field<String> SOURCE_FIELD =
+    Field<String> field =
         Field.ofString(
-                "source",
+                "pull_replication",
                 (metadataBuilder, fieldValue) ->
                     metadataBuilder
                         .pluginName(pluginName)
-                        .addPluginMetadata(PluginMetadata.create("source", fieldValue)))
+                        .addPluginMetadata(PluginMetadata.create("pull_replication", fieldValue)))
             .build();
-
     executionTime =
         metricMaker.newTimer(
             "apply_object_latency",
             new Description("Time spent applying object from remote source.")
                 .setCumulative()
                 .setUnit(Description.Units.MILLISECONDS),
-            SOURCE_FIELD);
+            field);
+
+    end2EndTime =
+        metricMaker.newTimer(
+            "apply_object_end_2_end_latency",
+            new Description("Time spent for e2e replication with the apply object REST API")
+                .setCumulative()
+                .setUnit(Description.Units.MILLISECONDS),
+            field);
   }
 
   /**
@@ -56,4 +64,14 @@
   public Timer1.Context<String> start(String name) {
     return executionTime.start(name);
   }
+
+  /**
+   * Start the replication latency timer from a source.
+   *
+   * @param name the source name.
+   * @return the timer context.
+   */
+  public Timer1.Context<String> startEnd2End(String name) {
+    return end2EndTime.start(name);
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/BearerTokenProvider.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/BearerTokenProvider.java
new file mode 100644
index 0000000..be34319
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/BearerTokenProvider.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2022 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.pull;
+
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class BearerTokenProvider implements Provider<Optional<String>> {
+
+  private final Optional<String> bearerToken;
+
+  @Inject
+  public BearerTokenProvider(@GerritServerConfig Config gerritConfig) {
+    this.bearerToken = Optional.ofNullable(gerritConfig.getString("auth", null, "bearerToken"));
+  }
+
+  @Override
+  public Optional<String> get() {
+    return bearerToken;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/DeleteProjectTask.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/DeleteProjectTask.java
index 527b746..2882482 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/DeleteProjectTask.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/DeleteProjectTask.java
@@ -67,7 +67,7 @@
     } catch (URISyntaxException | IOException e) {
       String errorMessage =
           String.format("Cannot delete project %s on remote site %s.", project, uri);
-      logger.atWarning().withCause(e).log(errorMessage);
+      logger.atWarning().withCause(e).log("%s", errorMessage);
       repLog.warn(errorMessage);
     }
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchAll.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchAll.java
index bcd86d7..42310ff 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchAll.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchAll.java
@@ -21,6 +21,7 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import com.googlesource.gerrit.plugins.replication.ReplicationFilter;
+import java.util.Optional;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.transport.URIish;
@@ -91,7 +92,7 @@
     for (Source cfg : sources.getAll()) {
       if (cfg.wouldFetchProject(project)) {
         for (URIish uri : cfg.getURIs(project, urlMatch)) {
-          cfg.schedule(project, FetchOne.ALL_REFS, uri, state, replicationType);
+          cfg.schedule(project, FetchOne.ALL_REFS, uri, state, replicationType, Optional.empty());
         }
       }
     }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchOne.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchOne.java
index 56eb483..d8da65e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchOne.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchOne.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.util.IdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import com.googlesource.gerrit.plugins.replication.pull.api.PullReplicationApiRequestMetrics;
 import com.googlesource.gerrit.plugins.replication.pull.fetch.Fetch;
 import com.googlesource.gerrit.plugins.replication.pull.fetch.FetchFactory;
 import com.googlesource.gerrit.plugins.replication.pull.fetch.RefUpdateState;
@@ -68,7 +69,8 @@
   static final String ID_KEY = "fetchOneId";
 
   interface Factory {
-    FetchOne create(Project.NameKey d, URIish u);
+    FetchOne create(
+        Project.NameKey d, URIish u, Optional<PullReplicationApiRequestMetrics> apiRequestMetrics);
   }
 
   private final GitRepositoryManager gitManager;
@@ -93,6 +95,7 @@
   private final FetchReplicationMetrics metrics;
   private final AtomicBoolean canceledWhileRunning;
   private final FetchFactory fetchFactory;
+  private final Optional<PullReplicationApiRequestMetrics> apiRequestMetrics;
 
   @Inject
   FetchOne(
@@ -105,7 +108,8 @@
       FetchReplicationMetrics m,
       FetchFactory fetchFactory,
       @Assisted Project.NameKey d,
-      @Assisted URIish u) {
+      @Assisted URIish u,
+      @Assisted Optional<PullReplicationApiRequestMetrics> apiRequestMetrics) {
     gitManager = grm;
     pool = s;
     config = c.getRemoteConfig();
@@ -121,6 +125,7 @@
     canceledWhileRunning = new AtomicBoolean(false);
     this.fetchFactory = fetchFactory;
     maxRetries = s.getMaxRetries();
+    this.apiRequestMetrics = apiRequestMetrics;
   }
 
   @Override
@@ -298,12 +303,17 @@
       git = gitManager.openRepository(projectName);
       runImpl();
       long elapsed = NANOSECONDS.toMillis(context.stop());
+      Optional<Long> elapsedEnd2End =
+          apiRequestMetrics
+              .flatMap(metrics -> metrics.stop(config.getName()))
+              .map(NANOSECONDS::toMillis);
       repLog.info(
-          "Replication from {} completed in {}ms, {}ms delay, {} retries",
+          "Replication from {} completed in {}ms, {}ms delay, {} retries{}",
           uri,
           elapsed,
           delay,
-          retryCount);
+          retryCount,
+          elapsedEnd2End.map(el -> String.format(", E2E %dms", el)).orElse(""));
     } catch (RepositoryNotFoundException e) {
       stateLog.error(
           "Cannot replicate " + projectName + "; Local repository error: " + e.getMessage(),
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchReplicationMetrics.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchReplicationMetrics.java
index e952252..22bb073 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchReplicationMetrics.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchReplicationMetrics.java
@@ -23,10 +23,12 @@
 import com.google.gerrit.server.logging.PluginMetadata;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.concurrent.TimeUnit;
 
 @Singleton
 public class FetchReplicationMetrics {
   private final Timer1<String> executionTime;
+  private final Timer1<String> end2EndExecutionTime;
   private final Histogram1<String> executionDelay;
   private final Histogram1<String> executionRetries;
 
@@ -34,11 +36,11 @@
   FetchReplicationMetrics(@PluginName String pluginName, MetricMaker metricMaker) {
     Field<String> SOURCE_FIELD =
         Field.ofString(
-                "source",
+                "pull_replication",
                 (metadataBuilder, fieldValue) ->
                     metadataBuilder
                         .pluginName(pluginName)
-                        .addPluginMetadata(PluginMetadata.create("source", fieldValue)))
+                        .addPluginMetadata(PluginMetadata.create("pull_replication", fieldValue)))
             .build();
 
     executionTime =
@@ -49,6 +51,14 @@
                 .setUnit(Description.Units.MILLISECONDS),
             SOURCE_FIELD);
 
+    end2EndExecutionTime =
+        metricMaker.newTimer(
+            "replication_end_2_end_latency",
+            new Description("Time spent end-2-end fetching from remote source.")
+                .setCumulative()
+                .setUnit(Description.Units.MILLISECONDS),
+            SOURCE_FIELD);
+
     executionDelay =
         metricMaker.newHistogram(
             "replication_delay",
@@ -77,6 +87,26 @@
   }
 
   /**
+   * Start the end-to-end replication latency timer from a source.
+   *
+   * @param name the source name.
+   * @return the timer context.
+   */
+  public Timer1.Context<String> startEnd2End(String name) {
+    return end2EndExecutionTime.start(name);
+  }
+
+  /**
+   * Record the end-to-end replication latency timer from a source.
+   *
+   * @param name the source name.
+   * @param metricNanos the timer value in nanos
+   */
+  public void recordEnd2End(String name, long metricNanos) {
+    end2EndExecutionTime.record(name, metricNanos, TimeUnit.NANOSECONDS);
+  }
+
+  /**
    * Record the replication delay and retry metrics for a source.
    *
    * @param name the source name.
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/GerritConfigOps.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/GerritConfigOps.java
index 2437d80..bca2219 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/GerritConfigOps.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/GerritConfigOps.java
@@ -46,7 +46,7 @@
       uri = new URIish("file://" + basePath + "/" + projectName);
       return Optional.of(uri);
     } catch (URISyntaxException e) {
-      logger.atSevere().withCause(e).log("Unsupported URI for project " + projectName);
+      logger.atSevere().withCause(e).log("Unsupported URI for project %s", projectName);
     }
 
     return Optional.empty();
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationModule.java
index ebc36b3..93bbde0 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationModule.java
@@ -42,6 +42,7 @@
 import com.googlesource.gerrit.plugins.replication.ReplicationConfig;
 import com.googlesource.gerrit.plugins.replication.ReplicationFileBasedConfig;
 import com.googlesource.gerrit.plugins.replication.StartReplicationCapability;
+import com.googlesource.gerrit.plugins.replication.pull.api.FetchJob;
 import com.googlesource.gerrit.plugins.replication.pull.api.PullReplicationApiModule;
 import com.googlesource.gerrit.plugins.replication.pull.client.FetchApiClient;
 import com.googlesource.gerrit.plugins.replication.pull.client.FetchRestApiClient;
@@ -72,9 +73,10 @@
   @Override
   protected void configure() {
 
+    bind(BearerTokenProvider.class).in(Scopes.SINGLETON);
     bind(RevisionReader.class).in(Scopes.SINGLETON);
     bind(ApplyObject.class);
-
+    install(new FactoryModuleBuilder().build(FetchJob.Factory.class));
     install(new PullReplicationApiModule());
 
     install(new FetchRefReplicatedEventModule());
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java
index cfe01e2..380ac8e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java
@@ -15,14 +15,17 @@
 package com.googlesource.gerrit.plugins.replication.pull;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Queues;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 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.metrics.Timer1.Context;
 import com.google.gerrit.server.events.EventDispatcher;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Inject;
@@ -36,7 +39,10 @@
 import com.googlesource.gerrit.plugins.replication.pull.filter.ExcludedRefsFilter;
 import java.io.IOException;
 import java.net.URISyntaxException;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Optional;
 import java.util.Queue;
 import java.util.Set;
@@ -46,7 +52,11 @@
 import java.util.concurrent.TimeoutException;
 import java.util.function.Consumer;
 import org.apache.http.client.ClientProtocolException;
+import org.eclipse.jgit.errors.CorruptObjectException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.transport.URIish;
 import org.slf4j.Logger;
@@ -74,7 +84,9 @@
   private FetchApiClient.Factory fetchClientFactory;
   private Integer fetchCallsTimeout;
   private ExcludedRefsFilter refsFilter;
-  private RevisionReader revisionReader;
+  private Provider<RevisionReader> revReaderProvider;
+  private final ApplyObjectMetrics applyObjectMetrics;
+  private final FetchReplicationMetrics fetchMetrics;
 
   @Inject
   ReplicationQueue(
@@ -84,7 +96,9 @@
       ReplicationStateListeners sl,
       FetchApiClient.Factory fetchClientFactory,
       ExcludedRefsFilter refsFilter,
-      RevisionReader revReader) {
+      Provider<RevisionReader> revReaderProvider,
+      ApplyObjectMetrics applyObjectMetrics,
+      FetchReplicationMetrics fetchMetrics) {
     workQueue = wq;
     dispatcher = dis;
     sources = rd;
@@ -92,7 +106,9 @@
     beforeStartupEventsQueue = Queues.newConcurrentLinkedQueue();
     this.fetchClientFactory = fetchClientFactory;
     this.refsFilter = refsFilter;
-    this.revisionReader = revReader;
+    this.revReaderProvider = revReaderProvider;
+    this.applyObjectMetrics = applyObjectMetrics;
+    this.fetchMetrics = fetchMetrics;
   }
 
   @Override
@@ -225,9 +241,19 @@
     CallFunction call = getCallFunction(project, objectId, refName, isDelete, state);
 
     return (source) -> {
+      boolean callSuccessful;
       try {
-        call.call(source);
-      } catch (MissingParentObjectException e) {
+        callSuccessful = call.call(source);
+      } catch (Exception e) {
+        repLog.warn(
+            String.format(
+                "Failed to apply object %s on project %s:%s, falling back to git fetch",
+                objectId.name(), project, refName),
+            e);
+        callSuccessful = false;
+      }
+
+      if (!callSuccessful) {
         callFetch(source, project, refName, state);
       }
     };
@@ -244,10 +270,18 @@
     }
 
     try {
-      Optional<RevisionData> revisionData = revisionReader.read(project, objectId, refName);
+      Optional<RevisionData> revisionData =
+          revReaderProvider.get().read(project, objectId, refName, 0);
+      repLog.info(
+          "RevisionData is {} for {}:{}",
+          revisionData.map(RevisionData::toString).orElse("ABSENT"),
+          project,
+          refName);
+
       if (revisionData.isPresent()) {
         return ((source) ->
-            callSendObject(source, project, refName, isDelete, revisionData.get(), state));
+            callSendObject(
+                source, project, refName, isDelete, Arrays.asList(revisionData.get()), state));
       }
     } catch (InvalidObjectIdException | IOException e) {
       stateLog.error(
@@ -261,94 +295,189 @@
     return (source) -> callFetch(source, project, refName, state);
   }
 
-  private void callSendObject(
+  private boolean callSendObject(
       Source source,
       Project.NameKey project,
       String refName,
       boolean isDelete,
-      RevisionData revision,
+      List<RevisionData> revision,
       ReplicationState state)
       throws MissingParentObjectException {
+    boolean resultIsSuccessful = true;
     if (source.wouldFetchProject(project) && source.wouldFetchRef(refName)) {
       for (String apiUrl : source.getApis()) {
         try {
           URIish uri = new URIish(apiUrl);
           FetchApiClient fetchClient = fetchClientFactory.create(source);
+          repLog.info(
+              "Pull replication REST API apply object to {} for {}:{} - {}",
+              apiUrl,
+              project,
+              refName,
+              revision);
+          Context<String> apiTimer = applyObjectMetrics.startEnd2End(source.getRemoteConfigName());
+          HttpResult result =
+              isDelete
+                  ? fetchClient.callSendObject(project, refName, isDelete, null, uri)
+                  : fetchClient.callSendObjects(project, refName, revision, uri);
+          boolean resultSuccessful = result.isSuccessful();
+          repLog.info(
+              "Pull replication REST API apply object to {} COMPLETED for {}:{} - {}, HTTP Result:"
+                  + " {} - time:{} ms",
+              apiUrl,
+              project,
+              refName,
+              revision,
+              result,
+              apiTimer.stop() / 1000000.0);
 
-          HttpResult result = fetchClient.callSendObject(project, refName, isDelete, revision, uri);
-          if (isProjectMissing(result, project) && source.isCreateMissingRepositories()) {
+          if (!resultSuccessful
+              && result.isProjectMissing(project)
+              && source.isCreateMissingRepositories()) {
             result = initProject(project, uri, fetchClient, result);
+            repLog.info("Missing project {} created, HTTP Result:{}", project, result);
           }
-          if (!result.isSuccessful()) {
-            repLog.warn(
-                String.format(
-                    "Pull replication rest api apply object call failed. Endpoint url: %s, reason:%s",
-                    apiUrl, result.getMessage().orElse("unknown")));
+
+          if (!resultSuccessful) {
             if (result.isParentObjectMissing()) {
+
+              if (RefNames.isNoteDbMetaRef(refName) && revision.size() == 1) {
+                List<RevisionData> allRevisions =
+                    fetchWholeMetaHistory(project, refName, revision.get(0));
+                repLog.info(
+                    "Pull replication REST API apply object to {} for {}:{} - {}",
+                    apiUrl,
+                    project,
+                    refName,
+                    allRevisions);
+                return callSendObject(source, project, refName, isDelete, allRevisions, state);
+              }
+
               throw new MissingParentObjectException(
                   project, refName, source.getRemoteConfigName());
             }
           }
+
+          resultIsSuccessful &= resultSuccessful;
         } catch (URISyntaxException e) {
+          repLog.warn(
+              "Pull replication REST API apply object to {} *FAILED* for {}:{} - {}",
+              apiUrl,
+              project,
+              refName,
+              revision,
+              e);
           stateLog.error(String.format("Cannot parse pull replication api url:%s", apiUrl), state);
+          resultIsSuccessful = false;
         } catch (IOException e) {
+          repLog.warn(
+              "Pull replication REST API apply object to {} *FAILED* for {}:{} - {}",
+              apiUrl,
+              project,
+              refName,
+              revision,
+              e);
           stateLog.error(
               String.format(
-                  "Exception during the pull replication fetch rest api call. Endpoint url:%s, message:%s",
+                  "Exception during the pull replication fetch rest api call. Endpoint url:%s,"
+                      + " message:%s",
                   apiUrl, e.getMessage()),
               e,
               state);
+          resultIsSuccessful = false;
         }
       }
     }
+
+    return resultIsSuccessful;
   }
 
-  private void callFetch(
+  private List<RevisionData> fetchWholeMetaHistory(
+      NameKey project, String refName, RevisionData revision)
+      throws RepositoryNotFoundException, MissingObjectException, IncorrectObjectTypeException,
+          CorruptObjectException, IOException {
+    RevisionReader revisionReader = revReaderProvider.get();
+    Optional<RevisionData> revisionDataWithParents =
+        revisionReader.read(project, refName, Integer.MAX_VALUE);
+
+    ImmutableList.Builder<RevisionData> revisionDataBuilder = ImmutableList.builder();
+    List<ObjectId> parentObjectIds =
+        revisionDataWithParents
+            .map(RevisionData::getParentObjetIds)
+            .orElse(Collections.emptyList());
+    for (ObjectId parentObjectId : parentObjectIds) {
+      revisionReader.read(project, parentObjectId, refName, 0).ifPresent(revisionDataBuilder::add);
+    }
+
+    revisionDataBuilder.add(revision);
+
+    return revisionDataBuilder.build();
+  }
+
+  private boolean callFetch(
       Source source, Project.NameKey project, String refName, ReplicationState state) {
+    boolean resultIsSuccessful = true;
     if (source.wouldFetchProject(project) && source.wouldFetchRef(refName)) {
       for (String apiUrl : source.getApis()) {
         try {
           URIish uri = new URIish(apiUrl);
           FetchApiClient fetchClient = fetchClientFactory.create(source);
+          repLog.info("Pull replication REST API fetch to {} for {}:{}", apiUrl, project, refName);
+          Context<String> timer = fetchMetrics.startEnd2End(source.getRemoteConfigName());
           HttpResult result = fetchClient.callFetch(project, refName, uri);
-          if (isProjectMissing(result, project) && source.isCreateMissingRepositories()) {
+          long elapsedMs = TimeUnit.NANOSECONDS.toMillis(timer.stop());
+          boolean resultSuccessful = result.isSuccessful();
+          repLog.info(
+              "Pull replication REST API fetch to {} COMPLETED for {}:{}, HTTP Result:"
+                  + " {} - time:{} ms",
+              apiUrl,
+              project,
+              refName,
+              result,
+              elapsedMs);
+          if (!resultSuccessful
+              && result.isProjectMissing(project)
+              && source.isCreateMissingRepositories()) {
             result = initProject(project, uri, fetchClient, result);
           }
-          if (!result.isSuccessful()) {
+          if (!resultSuccessful) {
             stateLog.warn(
                 String.format(
                     "Pull replication rest api fetch call failed. Endpoint url: %s, reason:%s",
                     apiUrl, result.getMessage().orElse("unknown")),
                 state);
           }
+
+          resultIsSuccessful &= result.isSuccessful();
         } catch (URISyntaxException e) {
           stateLog.error(String.format("Cannot parse pull replication api url:%s", apiUrl), state);
+          resultIsSuccessful = false;
         } catch (Exception e) {
           stateLog.error(
               String.format(
-                  "Exception during the pull replication fetch rest api call. Endpoint url:%s, message:%s",
+                  "Exception during the pull replication fetch rest api call. Endpoint url:%s,"
+                      + " message:%s",
                   apiUrl, e.getMessage()),
               e,
               state);
+          resultIsSuccessful = false;
         }
       }
     }
+
+    return resultIsSuccessful;
   }
 
   public boolean retry(int attempt, int maxRetries) {
     return maxRetries == 0 || attempt < maxRetries;
   }
 
-  private Boolean isProjectMissing(HttpResult result, Project.NameKey project) {
-    return !result.isSuccessful() && result.isProjectMissing(project);
-  }
-
   private HttpResult initProject(
       Project.NameKey project, URIish uri, FetchApiClient fetchClient, HttpResult result)
       throws IOException, ClientProtocolException {
     HttpResult initProjectResult = fetchClient.initProject(project, uri);
     if (initProjectResult.isSuccessful()) {
-      result = fetchClient.callFetch(project, "refs/*", uri);
+      result = fetchClient.callFetch(project, FetchOne.ALL_REFS, uri);
     } else {
       String errorMessage = initProjectResult.getMessage().map(e -> " - Error: " + e).orElse("");
       repLog.error("Cannot create project " + project + errorMessage);
@@ -399,6 +528,6 @@
 
   @FunctionalInterface
   private interface CallFunction {
-    void call(Source source) throws MissingParentObjectException;
+    boolean call(Source source) throws MissingParentObjectException;
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReader.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReader.java
index 468c5fc..db46b23 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReader.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReader.java
@@ -17,6 +17,7 @@
 import static com.googlesource.gerrit.plugins.replication.pull.PullReplicationLogger.repLog;
 
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
@@ -24,6 +25,9 @@
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData;
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionObjectData;
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.diff.DiffEntry;
@@ -36,6 +40,7 @@
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevTree;
@@ -45,8 +50,11 @@
 public class RevisionReader {
   private static final String CONFIG_MAX_API_PAYLOAD_SIZE = "maxApiPayloadSize";
   private static final Long DEFAULT_MAX_PAYLOAD_SIZE_IN_BYTES = 10000L;
+  static final String CONFIG_MAX_API_HISTORY_DEPTH = "maxApiHistoryDepth";
+  private static final int DEFAULT_MAX_API_HISTORY_DEPTH = 128;
   private GitRepositoryManager gitRepositoryManager;
   private Long maxRefSize;
+  private final int maxDepth;
 
   @Inject
   public RevisionReader(GitRepositoryManager gitRepositoryManager, ReplicationConfig cfg) {
@@ -54,17 +62,49 @@
     this.maxRefSize =
         cfg.getConfig()
             .getLong("replication", CONFIG_MAX_API_PAYLOAD_SIZE, DEFAULT_MAX_PAYLOAD_SIZE_IN_BYTES);
+    this.maxDepth =
+        cfg.getConfig()
+            .getInt("replication", CONFIG_MAX_API_HISTORY_DEPTH, DEFAULT_MAX_API_HISTORY_DEPTH);
   }
 
-  public Optional<RevisionData> read(Project.NameKey project, ObjectId objectId, String refName)
+  public Optional<RevisionData> read(
+      Project.NameKey project, String refName, int maxParentObjectIds)
+      throws RepositoryNotFoundException, MissingObjectException, IncorrectObjectTypeException,
+          CorruptObjectException, IOException {
+    return read(project, null, refName, maxParentObjectIds);
+  }
+
+  public Optional<RevisionData> read(
+      Project.NameKey project,
+      @Nullable ObjectId refObjectId,
+      String refName,
+      int maxParentObjectIds)
       throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
           RepositoryNotFoundException, IOException {
     try (Repository git = gitRepositoryManager.openRepository(project)) {
       Long totalRefSize = 0l;
 
+      Ref ref = git.exactRef(refName);
+      if (ref == null) {
+        return Optional.empty();
+      }
+
+      ObjectId objectId = refObjectId == null ? ref.getObjectId() : refObjectId;
+
       ObjectLoader commitLoader = git.open(objectId);
       totalRefSize += commitLoader.getSize();
-      verifySize(totalRefSize, commitLoader);
+      verifySize(project, refName, objectId, totalRefSize, commitLoader);
+
+      if (commitLoader.getType() == Constants.OBJ_BLOB) {
+        return Optional.of(
+            new RevisionData(
+                Collections.emptyList(),
+                null,
+                null,
+                Arrays.asList(
+                    new RevisionObjectData(
+                        objectId.name(), Constants.OBJ_BLOB, commitLoader.getCachedBytes()))));
+      }
 
       if (commitLoader.getType() != Constants.OBJ_COMMIT) {
         repLog.trace(
@@ -77,28 +117,34 @@
 
       RevCommit commit = RevCommit.parse(commitLoader.getCachedBytes());
       RevisionObjectData commitRev =
-          new RevisionObjectData(commit.getType(), commitLoader.getCachedBytes());
+          new RevisionObjectData(objectId.name(), commit.getType(), commitLoader.getCachedBytes());
 
       RevTree tree = commit.getTree();
-      ObjectLoader treeLoader = git.open(commit.getTree().toObjectId());
+      ObjectId treeObjectId = commit.getTree().toObjectId();
+      ObjectLoader treeLoader = git.open(treeObjectId);
       totalRefSize += treeLoader.getSize();
-      verifySize(totalRefSize, treeLoader);
+      verifySize(project, refName, treeObjectId, totalRefSize, treeLoader);
 
       RevisionObjectData treeRev =
-          new RevisionObjectData(tree.getType(), treeLoader.getCachedBytes());
+          new RevisionObjectData(treeObjectId.name(), tree.getType(), treeLoader.getCachedBytes());
 
       List<RevisionObjectData> blobs = Lists.newLinkedList();
       try (TreeWalk walk = new TreeWalk(git)) {
         if (commit.getParentCount() > 0) {
           List<DiffEntry> diffEntries = readDiffs(git, commit, tree, walk);
-          blobs = readBlobs(git, totalRefSize, diffEntries);
+          blobs = readBlobs(project, refName, git, totalRefSize, diffEntries);
         } else {
           walk.setRecursive(true);
           walk.addTree(tree);
-          blobs = readBlobs(git, totalRefSize, walk);
+          blobs = readBlobs(project, refName, git, totalRefSize, walk);
         }
       }
-      return Optional.of(new RevisionData(commitRev, treeRev, blobs));
+
+      List<ObjectId> parentObjectIds =
+          getParentObjectIds(git, commit.getParents(), 0, Math.min(maxDepth, maxParentObjectIds));
+      Collections.reverse(parentObjectIds);
+
+      return Optional.of(new RevisionData(parentObjectIds, commitRev, treeRev, blobs));
     } catch (LargeObjectException e) {
       repLog.trace(
           "Ref {} size for project {} is greater than configured '{}'",
@@ -109,6 +155,32 @@
     }
   }
 
+  private List<ObjectId> getParentObjectIds(
+      Repository git, RevCommit[] commit, int parentsDepth, int maxParentObjectIds)
+      throws MissingObjectException, IncorrectObjectTypeException, IOException {
+    if (commit == null || commit.length == 0) {
+      return Collections.emptyList();
+    }
+
+    ArrayList<ObjectId> parentObjectIds = new ArrayList<>();
+    for (RevCommit revCommit : commit) {
+      if (parentsDepth < maxParentObjectIds) {
+        parentObjectIds.add(revCommit.getId());
+        parentsDepth++;
+
+        ObjectLoader ol = git.open(revCommit.getId(), Constants.OBJ_COMMIT);
+        RevCommit[] commitParents = RevCommit.parse(ol.getCachedBytes()).getParents();
+
+        List<ObjectId> nestedParentObjectIds =
+            getParentObjectIds(git, commitParents, parentsDepth, maxParentObjectIds);
+        parentObjectIds.addAll(nestedParentObjectIds);
+        parentsDepth += nestedParentObjectIds.size();
+      }
+    }
+
+    return parentObjectIds;
+  }
+
   private List<DiffEntry> readDiffs(Repository git, RevCommit commit, RevTree tree, TreeWalk walk)
       throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
           IOException {
@@ -117,7 +189,8 @@
     return DiffEntry.scan(walk, true);
   }
 
-  private List<RevisionObjectData> readBlobs(Repository git, Long totalRefSize, TreeWalk walk)
+  private List<RevisionObjectData> readBlobs(
+      Project.NameKey projectName, String refName, Repository git, Long totalRefSize, TreeWalk walk)
       throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
           IOException {
     List<RevisionObjectData> blobs = Lists.newLinkedList();
@@ -125,26 +198,33 @@
       ObjectId objectId = walk.getObjectId(0);
       ObjectLoader objectLoader = git.open(objectId);
       totalRefSize += objectLoader.getSize();
-      verifySize(totalRefSize, objectLoader);
+      verifySize(projectName, refName, objectId, totalRefSize, objectLoader);
 
       RevisionObjectData rev =
-          new RevisionObjectData(objectLoader.getType(), objectLoader.getCachedBytes());
+          new RevisionObjectData(
+              objectId.name(), objectLoader.getType(), objectLoader.getCachedBytes());
       blobs.add(rev);
     }
     return blobs;
   }
 
   private List<RevisionObjectData> readBlobs(
-      Repository git, Long totalRefSize, List<DiffEntry> diffEntries)
+      Project.NameKey projectName,
+      String refName,
+      Repository git,
+      Long totalRefSize,
+      List<DiffEntry> diffEntries)
       throws MissingObjectException, IOException {
     List<RevisionObjectData> blobs = Lists.newLinkedList();
     for (DiffEntry diffEntry : diffEntries) {
       if (!ChangeType.DELETE.equals(diffEntry.getChangeType())) {
-        ObjectLoader objectLoader = git.open(diffEntry.getNewId().toObjectId());
+        ObjectId diffObjectId = diffEntry.getNewId().toObjectId();
+        ObjectLoader objectLoader = git.open(diffObjectId);
         totalRefSize += objectLoader.getSize();
-        verifySize(totalRefSize, objectLoader);
+        verifySize(projectName, refName, diffObjectId, totalRefSize, objectLoader);
         RevisionObjectData rev =
-            new RevisionObjectData(objectLoader.getType(), objectLoader.getCachedBytes());
+            new RevisionObjectData(
+                diffObjectId.name(), objectLoader.getType(), objectLoader.getCachedBytes());
         blobs.add(rev);
       }
     }
@@ -159,9 +239,44 @@
     return parentCommit.getTree();
   }
 
-  private void verifySize(Long totalRefSize, ObjectLoader loader) throws LargeObjectException {
-    if (loader.isLarge() || totalRefSize > maxRefSize) {
-      throw new LargeObjectException();
+  private void verifySize(
+      Project.NameKey projectName,
+      String refName,
+      ObjectId objectId,
+      Long totalRefSize,
+      ObjectLoader loader)
+      throws LargeObjectException {
+    if (loader.isLarge()) {
+      repLog.warn(
+          "Objects associated with {}:{} ({}) are too big to fit into the object loader's memory",
+          projectName,
+          refName,
+          objectTypeToString(loader.getType()));
+      throw new LargeObjectException(objectId);
+    }
+
+    if (totalRefSize > maxRefSize) {
+      repLog.warn(
+          "Objects associated with {}:{} ({}) use {} bytes, over the maximum limit of {} bytes",
+          projectName,
+          refName,
+          objectTypeToString(loader.getType()),
+          totalRefSize,
+          maxRefSize);
+      throw new LargeObjectException(objectId);
+    }
+  }
+
+  private static String objectTypeToString(int type) {
+    switch (type) {
+      case Constants.OBJ_BLOB:
+        return "BLOB";
+      case Constants.OBJ_COMMIT:
+        return "COMMIT";
+      case Constants.OBJ_TREE:
+        return "TREE";
+      default:
+        return "type:" + type;
     }
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/Source.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/Source.java
index 10a8997..3170eb5 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/Source.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/Source.java
@@ -60,6 +60,7 @@
 import com.google.inject.servlet.RequestScoped;
 import com.googlesource.gerrit.plugins.replication.RemoteSiteUser;
 import com.googlesource.gerrit.plugins.replication.ReplicationFilter;
+import com.googlesource.gerrit.plugins.replication.pull.api.PullReplicationApiRequestMetrics;
 import com.googlesource.gerrit.plugins.replication.pull.fetch.BatchFetchClient;
 import com.googlesource.gerrit.plugins.replication.pull.fetch.CGitFetch;
 import com.googlesource.gerrit.plugins.replication.pull.fetch.CGitFetchValidator;
@@ -391,9 +392,10 @@
       Project.NameKey project,
       String ref,
       ReplicationState state,
-      ReplicationType replicationType) {
+      ReplicationType replicationType,
+      Optional<PullReplicationApiRequestMetrics> apiRequestMetrics) {
     URIish uri = getURI(project);
-    return schedule(project, ref, uri, state, replicationType);
+    return schedule(project, ref, uri, state, replicationType, apiRequestMetrics);
   }
 
   public Future<?> schedule(
@@ -401,7 +403,8 @@
       String ref,
       URIish uri,
       ReplicationState state,
-      ReplicationType replicationType) {
+      ReplicationType replicationType,
+      Optional<PullReplicationApiRequestMetrics> apiRequestMetrics) {
 
     repLog.info("scheduling replication {}:{} => {}", uri, ref, project);
     if (!shouldReplicate(project, ref, state)) {
@@ -437,7 +440,7 @@
       FetchOne e = pending.get(uri);
       Future<?> f = CompletableFuture.completedFuture(null);
       if (e == null) {
-        e = opFactory.create(project, uri);
+        e = opFactory.create(project, uri, apiRequestMetrics);
         addRef(e, ref);
         e.addState(ref, state);
         pending.put(uri, e);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/SourceConfigParser.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/SourceConfigParser.java
index 6dfed44..a8799c2 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/SourceConfigParser.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/SourceConfigParser.java
@@ -35,6 +35,7 @@
   /* (non-Javadoc)
    * @see com.googlesource.gerrit.plugins.replication.ConfigParser#parseRemotes(org.eclipse.jgit.lib.Config)
    */
+  @Override
   public List<RemoteConfiguration> parseRemotes(Config config) throws ConfigInvalidException {
 
     if (config.getSections().isEmpty()) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/UpdateHeadTask.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/UpdateHeadTask.java
index e169eb3..fabe3cd 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/UpdateHeadTask.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/UpdateHeadTask.java
@@ -72,7 +72,7 @@
           String.format(
               "Cannot update HEAD of project %s remote site %s",
               project.get(), apiURI.toASCIIString());
-      logger.atWarning().withCause(e).log(errorMessage);
+      logger.atWarning().withCause(e).log("%s", errorMessage);
       repLog.warn(errorMessage);
     }
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectAction.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectAction.java
index 5132f41..04cc9e8 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectAction.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectAction.java
@@ -14,6 +14,8 @@
 
 package com.googlesource.gerrit.plugins.replication.pull.api;
 
+import static com.googlesource.gerrit.plugins.replication.pull.PullReplicationLogger.repLog;
+
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -29,19 +31,20 @@
 import com.googlesource.gerrit.plugins.replication.pull.api.exception.RefUpdateException;
 import java.io.IOException;
 import java.util.Objects;
+import javax.servlet.http.HttpServletResponse;
 
 public class ApplyObjectAction implements RestModifyView<ProjectResource, RevisionInput> {
 
-  private final ApplyObjectCommand command;
+  private final ApplyObjectCommand applyObjectCommand;
   private final DeleteRefCommand deleteRefCommand;
   private final FetchPreconditions preConditions;
 
   @Inject
   public ApplyObjectAction(
-      ApplyObjectCommand command,
+      ApplyObjectCommand applyObjectCommand,
       DeleteRefCommand deleteRefCommand,
       FetchPreconditions preConditions) {
-    this.command = command;
+    this.applyObjectCommand = applyObjectCommand;
     this.deleteRefCommand = deleteRefCommand;
     this.preConditions = preConditions;
   }
@@ -50,42 +53,78 @@
   public Response<?> apply(ProjectResource resource, RevisionInput input) throws RestApiException {
 
     if (!preConditions.canCallFetchApi()) {
-      throw new AuthException("not allowed to call fetch command");
+      throw new AuthException("Not allowed to call fetch command");
     }
+    if (Strings.isNullOrEmpty(input.getLabel())) {
+      throw new BadRequestException("Source label cannot be null or empty");
+    }
+    if (Strings.isNullOrEmpty(input.getRefName())) {
+      throw new BadRequestException("Ref-update refname cannot be null or empty");
+    }
+
     try {
-      if (Strings.isNullOrEmpty(input.getLabel())) {
-        throw new BadRequestException("Source label cannot be null or empty");
-      }
-      if (Strings.isNullOrEmpty(input.getRefName())) {
-        throw new BadRequestException("Ref-update refname cannot be null or empty");
-      }
+      repLog.info(
+          "Apply object API from {} for {}:{} - {}",
+          resource.getNameKey(),
+          input.getLabel(),
+          input.getRefName(),
+          input.getRevisionData());
 
       if (Objects.isNull(input.getRevisionData())) {
         deleteRefCommand.deleteRef(resource.getNameKey(), input.getRefName(), input.getLabel());
-        return Response.created(input);
+        repLog.info(
+            "Apply object API - REF DELETED - from {} for {}:{} - {}",
+            resource.getNameKey(),
+            input.getLabel(),
+            input.getRefName(),
+            input.getRevisionData());
+        return Response.withStatusCode(HttpServletResponse.SC_NO_CONTENT, "");
       }
 
-      if (Objects.isNull(input.getRevisionData().getCommitObject())
-          || Objects.isNull(input.getRevisionData().getCommitObject().getContent())
-          || input.getRevisionData().getCommitObject().getContent().length == 0
-          || Objects.isNull(input.getRevisionData().getCommitObject().getType())) {
-        throw new BadRequestException("Ref-update commit object cannot be null or empty");
+      try {
+        input.validate();
+      } catch (IllegalArgumentException e) {
+        BadRequestException bre =
+            new BadRequestException("Ref-update with invalid input: " + e.getMessage(), e);
+        repLog.error(
+            "Apply object API *FAILED* from {} for {}:{} - {}",
+            input.getLabel(),
+            resource.getNameKey(),
+            input.getRefName(),
+            input.getRevisionData(),
+            bre);
+        throw bre;
       }
 
-      if (Objects.isNull(input.getRevisionData().getTreeObject())
-          || Objects.isNull(input.getRevisionData().getTreeObject().getContent())
-          || Objects.isNull(input.getRevisionData().getTreeObject().getType())) {
-        throw new BadRequestException("Ref-update tree object cannot be null");
-      }
-
-      command.applyObject(
+      applyObjectCommand.applyObject(
           resource.getNameKey(), input.getRefName(), input.getRevisionData(), input.getLabel());
       return Response.created(input);
     } catch (MissingParentObjectException e) {
+      repLog.error(
+          "Apply object API *FAILED* from {} for {}:{} - {}",
+          input.getLabel(),
+          resource.getNameKey(),
+          input.getRefName(),
+          input.getRevisionData(),
+          e);
       throw new ResourceConflictException(e.getMessage(), e);
     } catch (NumberFormatException | IOException e) {
+      repLog.error(
+          "Apply object API *FAILED* from {} for {}:{} - {}",
+          input.getLabel(),
+          resource.getNameKey(),
+          input.getRefName(),
+          input.getRevisionData(),
+          e);
       throw RestApiException.wrap(e.getMessage(), e);
     } catch (RefUpdateException e) {
+      repLog.error(
+          "Apply object API *FAILED* from {} for {}:{} - {}",
+          input.getLabel(),
+          resource.getNameKey(),
+          input.getRefName(),
+          input.getRevisionData(),
+          e);
       throw new UnprocessableEntityException(e.getMessage());
     }
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommand.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommand.java
index 3e6c07b..c268ba1 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommand.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommand.java
@@ -39,6 +39,7 @@
 import com.googlesource.gerrit.plugins.replication.pull.fetch.ApplyObject;
 import com.googlesource.gerrit.plugins.replication.pull.fetch.RefUpdateState;
 import java.io.IOException;
+import java.util.Arrays;
 import java.util.Set;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.transport.RefSpec;
@@ -75,12 +76,25 @@
   }
 
   public void applyObject(
-      Project.NameKey name, String refName, RevisionData revisionData, String sourceLabel)
+      Project.NameKey name, String refName, RevisionData revisionsData, String sourceLabel)
+      throws IOException, RefUpdateException, MissingParentObjectException {
+    applyObjects(name, refName, new RevisionData[] {revisionsData}, sourceLabel);
+  }
+
+  public void applyObjects(
+      Project.NameKey name, String refName, RevisionData[] revisionsData, String sourceLabel)
       throws IOException, RefUpdateException, MissingParentObjectException {
 
-    repLog.info("Apply object from {} for project {}, ref name {}", sourceLabel, name, refName);
+    repLog.info(
+        "Apply object from {} for {}:{} - {}",
+        sourceLabel,
+        name,
+        refName,
+        Arrays.toString(revisionsData));
     Timer1.Context<String> context = metrics.start(sourceLabel);
-    RefUpdateState refUpdateState = applyObject.apply(name, new RefSpec(refName), revisionData);
+
+    RefUpdateState refUpdateState = applyObject.apply(name, new RefSpec(refName), revisionsData);
+
     long elapsed = NANOSECONDS.toMillis(context.stop());
 
     try {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectsAction.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectsAction.java
new file mode 100644
index 0000000..a1e1f5b
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectsAction.java
@@ -0,0 +1,131 @@
+// Copyright (C) 2022 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.pull.api;
+
+import static com.googlesource.gerrit.plugins.replication.pull.PullReplicationLogger.repLog;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionsInput;
+import com.googlesource.gerrit.plugins.replication.pull.api.exception.MissingParentObjectException;
+import com.googlesource.gerrit.plugins.replication.pull.api.exception.RefUpdateException;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Objects;
+import javax.servlet.http.HttpServletResponse;
+
+public class ApplyObjectsAction implements RestModifyView<ProjectResource, RevisionsInput> {
+
+  private final ApplyObjectCommand command;
+  private final DeleteRefCommand deleteRefCommand;
+  private final FetchPreconditions preConditions;
+
+  @Inject
+  public ApplyObjectsAction(
+      ApplyObjectCommand command,
+      DeleteRefCommand deleteRefCommand,
+      FetchPreconditions preConditions) {
+    this.command = command;
+    this.deleteRefCommand = deleteRefCommand;
+    this.preConditions = preConditions;
+  }
+
+  @Override
+  public Response<?> apply(ProjectResource resource, RevisionsInput input) throws RestApiException {
+    if (!preConditions.canCallFetchApi()) {
+      throw new AuthException("not allowed to call fetch command");
+    }
+
+    try {
+      if (Strings.isNullOrEmpty(input.getLabel())) {
+        throw new BadRequestException("Source label cannot be null or empty");
+      }
+      if (Strings.isNullOrEmpty(input.getRefName())) {
+        throw new BadRequestException("Ref-update refname cannot be null or empty");
+      }
+
+      repLog.info(
+          "Apply object API from {} for {}:{} - {}",
+          resource.getNameKey(),
+          input.getLabel(),
+          input.getRefName(),
+          Arrays.toString(input.getRevisionsData()));
+
+      if (Objects.isNull(input.getRevisionsData())) {
+        deleteRefCommand.deleteRef(resource.getNameKey(), input.getRefName(), input.getLabel());
+        repLog.info(
+            "Apply object API - REF DELETED - from {} for {}:{}",
+            resource.getNameKey(),
+            input.getLabel(),
+            input.getRefName());
+        return Response.withStatusCode(HttpServletResponse.SC_NO_CONTENT, "");
+      }
+
+      try {
+        input.validate();
+      } catch (IllegalArgumentException e) {
+        BadRequestException bre =
+            new BadRequestException("Ref-update with invalid input: " + e.getMessage(), e);
+        repLog.error(
+            "Apply object API *FAILED* from {} for {}:{} - {}",
+            input.getLabel(),
+            resource.getNameKey(),
+            input.getRefName(),
+            Arrays.toString(input.getRevisionsData()),
+            bre);
+        throw bre;
+      }
+
+      command.applyObjects(
+          resource.getNameKey(), input.getRefName(), input.getRevisionsData(), input.getLabel());
+      return Response.created(input);
+    } catch (MissingParentObjectException e) {
+      repLog.error(
+          "Apply object API *FAILED* from {} for {}:{} - {}",
+          input.getLabel(),
+          resource.getNameKey(),
+          input.getRefName(),
+          Arrays.toString(input.getRevisionsData()),
+          e);
+      throw new ResourceConflictException(e.getMessage(), e);
+    } catch (NumberFormatException | IOException e) {
+      repLog.error(
+          "Apply object API *FAILED* from {} for {}:{} - {}",
+          input.getLabel(),
+          resource.getNameKey(),
+          input.getRefName(),
+          Arrays.toString(input.getRevisionsData()),
+          e);
+      throw RestApiException.wrap(e.getMessage(), e);
+    } catch (RefUpdateException e) {
+      repLog.error(
+          "Apply object API *FAILED* from {} for {}:{} - {}",
+          input.getLabel(),
+          resource.getNameKey(),
+          input.getRefName(),
+          Arrays.toString(input.getRevisionsData()),
+          e);
+      throw new UnprocessableEntityException(e.getMessage());
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/BearerAuthenticationFilter.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/BearerAuthenticationFilter.java
new file mode 100644
index 0000000..cbe1ab8
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/BearerAuthenticationFilter.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2022 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.pull.api;
+
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.httpd.AllRequestFilter;
+import com.google.gerrit.httpd.WebSession;
+import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.PluginUser;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * Authenticates the current user by HTTP bearer token authentication.
+ *
+ * <p>* @see <a href="https://www.rfc-editor.org/rfc/rfc6750">RFC 6750</a>
+ */
+public class BearerAuthenticationFilter extends AllRequestFilter {
+
+  private static final String BEARER_TOKEN = "BearerToken";
+  private final DynamicItem<WebSession> session;
+  private final String pluginName;
+  private final Provider<PluginUser> pluginUserProvider;
+  private final Provider<ThreadLocalRequestContext> threadLocalRequestContext;
+  private final String bearerToken;
+  private final Pattern bearerTokenRegex = Pattern.compile("^Bearer\\s(.+)$");
+
+  @Inject
+  BearerAuthenticationFilter(
+      DynamicItem<WebSession> session,
+      @PluginName String pluginName,
+      Provider<PluginUser> pluginUserProvider,
+      Provider<ThreadLocalRequestContext> threadLocalRequestContext,
+      @Named(BEARER_TOKEN) String bearerToken) {
+    this.session = session;
+    this.pluginName = pluginName;
+    this.pluginUserProvider = pluginUserProvider;
+    this.threadLocalRequestContext = threadLocalRequestContext;
+    this.bearerToken = bearerToken;
+  }
+
+  @Override
+  public void doFilter(
+      ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
+      throws IOException, ServletException {
+
+    if (!(servletRequest instanceof HttpServletRequest)
+        || !(servletResponse instanceof HttpServletResponse)) {
+      filterChain.doFilter(servletRequest, servletResponse);
+      return;
+    }
+
+    HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
+    HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
+    String requestURI = httpRequest.getRequestURI();
+
+    if (isBasicAuthenticationRequest(requestURI)) {
+      filterChain.doFilter(servletRequest, servletResponse);
+    } else if (isPullReplicationApiRequest(requestURI)) {
+      Optional<String> authorizationHeader =
+          Optional.ofNullable(httpRequest.getHeader("Authorization"));
+
+      if (isBearerTokenAuthenticated(authorizationHeader, bearerToken))
+        try (ManualRequestContext ctx =
+            new ManualRequestContext(pluginUserProvider.get(), threadLocalRequestContext.get())) {
+          WebSession ws = session.get();
+          ws.setAccessPathOk(AccessPath.REST_API, true);
+          filterChain.doFilter(servletRequest, servletResponse);
+        }
+      else httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+
+    } else {
+      filterChain.doFilter(servletRequest, servletResponse);
+    }
+  }
+
+  private boolean isBearerTokenAuthenticated(
+      Optional<String> authorizationHeader, String bearerToken) {
+    return authorizationHeader
+        .flatMap(this::extractBearerToken)
+        .map(bt -> bt.equals(bearerToken))
+        .orElse(false);
+  }
+
+  private boolean isBasicAuthenticationRequest(String requestURI) {
+    return requestURI.startsWith("/a/");
+  }
+
+  private boolean isPullReplicationApiRequest(String requestURI) {
+    return (requestURI.contains(pluginName)
+            && (requestURI.endsWith(String.format("/%s~apply-object", pluginName))
+                || requestURI.endsWith(String.format("/%s~apply-objects", pluginName))
+                || requestURI.endsWith(String.format("/%s~fetch", pluginName))
+                || requestURI.endsWith(String.format("/%s~delete-project", pluginName))
+                || requestURI.contains(String.format("/%s/init-project/", pluginName))))
+        || requestURI.matches(".*/projects/[^/]+/HEAD");
+  }
+
+  private Optional<String> extractBearerToken(String authorizationHeader) {
+    Matcher projectGroupMatcher = bearerTokenRegex.matcher(authorizationHeader);
+
+    if (projectGroupMatcher.find()) {
+      return Optional.of(projectGroupMatcher.group(1));
+    }
+    return Optional.empty();
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/DeleteRefCommand.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/DeleteRefCommand.java
index 8e08977..14f2545 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/DeleteRefCommand.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/DeleteRefCommand.java
@@ -19,14 +19,15 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.events.EventDispatcher;
+import com.google.gerrit.server.git.LocalDiskRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.restapi.project.DeleteRef;
 import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.replication.pull.Context;
 import com.googlesource.gerrit.plugins.replication.pull.FetchRefReplicatedEvent;
@@ -34,32 +35,42 @@
 import com.googlesource.gerrit.plugins.replication.pull.ReplicationState;
 import com.googlesource.gerrit.plugins.replication.pull.Source;
 import com.googlesource.gerrit.plugins.replication.pull.SourcesCollection;
+import com.googlesource.gerrit.plugins.replication.pull.fetch.ApplyObject;
+import com.googlesource.gerrit.plugins.replication.pull.fetch.RefUpdateState;
 import java.io.IOException;
 import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.URIish;
 
 public class DeleteRefCommand {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final PullReplicationStateLogger fetchStateLog;
-  private final DeleteRef deleteRef;
+  private final ApplyObject applyObject;
   private final DynamicItem<EventDispatcher> eventDispatcher;
   private final ProjectCache projectCache;
   private final SourcesCollection sourcesCollection;
+  private final PermissionBackend permissionBackend;
+  private final LocalDiskRepositoryManager gitManager;
 
   @Inject
   public DeleteRefCommand(
       PullReplicationStateLogger fetchStateLog,
       ProjectCache projectCache,
-      DeleteRef deleteRef,
       DynamicItem<EventDispatcher> eventDispatcher,
-      SourcesCollection sourcesCollection) {
+      SourcesCollection sourcesCollection,
+      ApplyObject applyObject,
+      PermissionBackend permissionBackend,
+      LocalDiskRepositoryManager gitManager) {
     this.fetchStateLog = fetchStateLog;
     this.projectCache = projectCache;
-    this.deleteRef = deleteRef;
+    this.applyObject = applyObject;
     this.eventDispatcher = eventDispatcher;
     this.sourcesCollection = sourcesCollection;
+    this.permissionBackend = permissionBackend;
+    this.gitManager = gitManager;
   }
 
   public void deleteRef(Project.NameKey name, String refName, String sourceLabel)
@@ -81,8 +92,15 @@
       URIish sourceUri = source.getURI(name);
 
       try {
+        projectState.get().checkStatePermitsWrite();
+        permissionBackend
+            .currentUser()
+            .project(projectState.get().getNameKey())
+            .ref(refName)
+            .check(RefPermission.DELETE);
+
         Context.setLocalEvent(true);
-        deleteRef.deleteSingleRef(projectState.get(), refName);
+        deleteRef(name, refName);
 
         eventDispatcher
             .get()
@@ -98,7 +116,7 @@
             "Unexpected error while trying to delete ref '%s' on project %s and notifying it",
             refName, name);
         throw RestApiException.wrap(e.getMessage(), e);
-      } catch (ResourceConflictException e) {
+      } catch (IOException e) {
         eventDispatcher
             .get()
             .postEvent(
@@ -112,7 +130,7 @@
             String.format(
                 "RefUpdate lock failure for: sourceLabel=%s, project=%s, refName=%s",
                 sourceLabel, name, refName);
-        logger.atSevere().withCause(e).log(message);
+        logger.atSevere().withCause(e).log("%s", message);
         fetchStateLog.error(message);
         throw e;
       } finally {
@@ -125,4 +143,18 @@
       throw RestApiException.wrap(e.getMessage(), e);
     }
   }
+
+  private RefUpdateState deleteRef(Project.NameKey name, String refName) throws IOException {
+
+    try (Repository repository = gitManager.openRepository(name)) {
+      RefUpdate.Result result;
+      RefUpdate u = repository.updateRef(refName);
+      u.setExpectedOldObjectId(repository.exactRef(refName).getObjectId());
+      u.setNewObjectId(ObjectId.zeroId());
+      u.setForceUpdate(true);
+
+      result = u.delete();
+      return new RefUpdateState(refName, result);
+    }
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchAction.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchAction.java
index 23b7018..fdb4f8f 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchAction.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchAction.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -32,6 +31,7 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.replication.pull.api.FetchAction.Input;
+import com.googlesource.gerrit.plugins.replication.pull.api.FetchJob.Factory;
 import com.googlesource.gerrit.plugins.replication.pull.api.exception.RemoteConfigurationMissingException;
 import java.util.Optional;
 import java.util.concurrent.ExecutionException;
@@ -42,17 +42,20 @@
   private final WorkQueue workQueue;
   private final DynamicItem<UrlFormatter> urlFormatter;
   private final FetchPreconditions preConditions;
+  private final Factory fetchJobFactory;
 
   @Inject
   public FetchAction(
       FetchCommand command,
       WorkQueue workQueue,
       DynamicItem<UrlFormatter> urlFormatter,
-      FetchPreconditions preConditions) {
+      FetchPreconditions preConditions,
+      FetchJob.Factory fetchJobFactory) {
     this.command = command;
     this.workQueue = workQueue;
     this.urlFormatter = urlFormatter;
     this.preConditions = preConditions;
+    this.fetchJobFactory = fetchJobFactory;
   }
 
   public static class Input {
@@ -101,7 +104,10 @@
     @SuppressWarnings("unchecked")
     WorkQueue.Task<Void> task =
         (WorkQueue.Task<Void>)
-            workQueue.getDefaultQueue().submit(new FetchJob(command, project, input));
+            workQueue
+                .getDefaultQueue()
+                .submit(
+                    fetchJobFactory.create(project, input, PullReplicationApiRequestMetrics.get()));
     Optional<String> url =
         urlFormatter
             .get()
@@ -110,32 +116,4 @@
     checkState(url.isPresent());
     return Response.accepted(url.get());
   }
-
-  public static class FetchJob implements Runnable {
-    private static final FluentLogger log = FluentLogger.forEnclosingClass();
-
-    private FetchCommand command;
-    private Project.NameKey project;
-    private FetchAction.Input input;
-
-    public FetchJob(FetchCommand command, Project.NameKey project, FetchAction.Input input) {
-      this.command = command;
-      this.project = project;
-      this.input = input;
-    }
-
-    @Override
-    public void run() {
-      try {
-        command.fetchAsync(project, input.label, input.refName);
-      } catch (InterruptedException
-          | ExecutionException
-          | RemoteConfigurationMissingException
-          | TimeoutException e) {
-        log.atSevere().withCause(e).log(
-            "Exception during the async fetch call for project %s, label %s and ref name %s",
-            project.get(), input.label, input.refName);
-      }
-    }
-  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchCommand.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchCommand.java
index 8b3c437..dd06875 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchCommand.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchCommand.java
@@ -54,19 +54,28 @@
     this.eventDispatcher = eventDispatcher;
   }
 
-  public void fetchAsync(Project.NameKey name, String label, String refName)
+  public void fetchAsync(
+      Project.NameKey name,
+      String label,
+      String refName,
+      PullReplicationApiRequestMetrics apiRequestMetrics)
       throws InterruptedException, ExecutionException, RemoteConfigurationMissingException,
           TimeoutException {
-    fetch(name, label, refName, ASYNC);
+    fetch(name, label, refName, ASYNC, Optional.of(apiRequestMetrics));
   }
 
   public void fetchSync(Project.NameKey name, String label, String refName)
       throws InterruptedException, ExecutionException, RemoteConfigurationMissingException,
           TimeoutException {
-    fetch(name, label, refName, SYNC);
+    fetch(name, label, refName, SYNC, Optional.empty());
   }
 
-  private void fetch(Project.NameKey name, String label, String refName, ReplicationType fetchType)
+  private void fetch(
+      Project.NameKey name,
+      String label,
+      String refName,
+      ReplicationType fetchType,
+      Optional<PullReplicationApiRequestMetrics> apiRequestMetrics)
       throws InterruptedException, ExecutionException, RemoteConfigurationMissingException,
           TimeoutException {
     ReplicationState state =
@@ -81,7 +90,7 @@
 
     try {
       state.markAllFetchTasksScheduled();
-      Future<?> future = source.get().schedule(name, refName, state, fetchType);
+      Future<?> future = source.get().schedule(name, refName, state, fetchType, apiRequestMetrics);
       future.get(source.get().getTimeout(), TimeUnit.SECONDS);
     } catch (ExecutionException
         | IllegalStateException
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchJob.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchJob.java
new file mode 100644
index 0000000..e15dd68
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchJob.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2022 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.pull.api;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import com.googlesource.gerrit.plugins.replication.pull.api.exception.RemoteConfigurationMissingException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+public class FetchJob implements Runnable {
+  private static final FluentLogger log = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    FetchJob create(
+        Project.NameKey project, FetchAction.Input input, PullReplicationApiRequestMetrics metrics);
+  }
+
+  private FetchCommand command;
+  private Project.NameKey project;
+  private FetchAction.Input input;
+  private final PullReplicationApiRequestMetrics metrics;
+
+  @Inject
+  public FetchJob(
+      FetchCommand command,
+      @Assisted Project.NameKey project,
+      @Assisted FetchAction.Input input,
+      @Assisted PullReplicationApiRequestMetrics metrics) {
+    this.command = command;
+    this.project = project;
+    this.input = input;
+    this.metrics = metrics;
+  }
+
+  @Override
+  public void run() {
+    try {
+      command.fetchAsync(project, input.label, input.refName, metrics);
+    } catch (InterruptedException
+        | ExecutionException
+        | RemoteConfigurationMissingException
+        | TimeoutException e) {
+      log.atSevere().withCause(e).log(
+          "Exception during the async fetch call for project %s, label %s and ref name %s",
+          project.get(), input.label, input.refName);
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchPreconditions.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchPreconditions.java
index ca1557a..161bcf4 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchPreconditions.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchPreconditions.java
@@ -40,8 +40,10 @@
   }
 
   public Boolean canCallFetchApi() {
-    PermissionBackend.WithUser userPermission = permissionBackend.user(userProvider.get());
-    return userPermission.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER)
+    CurrentUser currentUser = userProvider.get();
+    PermissionBackend.WithUser userPermission = permissionBackend.user(currentUser);
+    return currentUser.isInternalUser()
+        || userPermission.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER)
         || userPermission.testOrFalse(new PluginPermission(pluginName, CALL_FETCH_ACTION));
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/HttpModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/HttpModule.java
index b2ef28d..0f3e1e8 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/HttpModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/HttpModule.java
@@ -19,18 +19,36 @@
 import com.google.gerrit.server.config.GerritIsReplica;
 import com.google.inject.Inject;
 import com.google.inject.Scopes;
+import com.google.inject.name.Names;
 import com.google.inject.servlet.ServletModule;
+import com.googlesource.gerrit.plugins.replication.pull.BearerTokenProvider;
 
 public class HttpModule extends ServletModule {
   private boolean isReplica;
+  private final BearerTokenProvider bearerTokenProvider;
 
   @Inject
-  public HttpModule(@GerritIsReplica Boolean isReplica) {
+  public HttpModule(@GerritIsReplica Boolean isReplica, BearerTokenProvider bearerTokenProvider) {
     this.isReplica = isReplica;
+    this.bearerTokenProvider = bearerTokenProvider;
   }
 
   @Override
   protected void configureServlets() {
+    DynamicSet.bind(binder(), AllRequestFilter.class)
+        .to(PullReplicationApiMetricsFilter.class)
+        .in(Scopes.SINGLETON);
+
+    bearerTokenProvider
+        .get()
+        .ifPresent(
+            bt -> {
+              bind(String.class).annotatedWith(Names.named("BearerToken")).toInstance(bt);
+              DynamicSet.bind(binder(), AllRequestFilter.class)
+                  .to(BearerAuthenticationFilter.class)
+                  .in(Scopes.SINGLETON);
+            });
+
     if (isReplica) {
       DynamicSet.bind(binder(), AllRequestFilter.class)
           .to(PullReplicationFilter.class)
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionAction.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionAction.java
index 8915e78..2e1c5d4 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionAction.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionAction.java
@@ -22,9 +22,11 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.googlesource.gerrit.plugins.replication.LocalFS;
 import com.googlesource.gerrit.plugins.replication.pull.GerritConfigOps;
 import java.util.Optional;
@@ -37,20 +39,29 @@
 
   static class DeleteInput {}
 
+  private final Provider<CurrentUser> userProvider;
   private final GerritConfigOps gerritConfigOps;
   private final PermissionBackend permissionBackend;
 
   @Inject
-  ProjectDeletionAction(GerritConfigOps gerritConfigOps, PermissionBackend permissionBackend) {
+  ProjectDeletionAction(
+      GerritConfigOps gerritConfigOps,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> userProvider) {
     this.gerritConfigOps = gerritConfigOps;
     this.permissionBackend = permissionBackend;
+    this.userProvider = userProvider;
   }
 
   @Override
   public Response<?> apply(ProjectResource projectResource, DeleteInput input)
       throws AuthException, BadRequestException, ResourceConflictException, Exception {
 
-    permissionBackend.user(projectResource.getUser()).check(DELETE_PROJECT);
+    // When triggered internally(for example by consuming stream events) user is not provided
+    // and internal user is returned. Project deletion should be always allowed for internal user.
+    if (!userProvider.get().isInternalUser()) {
+      permissionBackend.user(projectResource.getUser()).check(DELETE_PROJECT);
+    }
 
     Optional<URIish> maybeRepoURI =
         gerritConfigOps.getGitRepositoryURI(String.format("%s.git", projectResource.getName()));
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java
index 63d32c4..2214fb3 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java
@@ -14,7 +14,6 @@
 
 package com.googlesource.gerrit.plugins.replication.pull.api;
 
-import static com.googlesource.gerrit.plugins.replication.pull.api.FetchApiCapability.CALL_FETCH_ACTION;
 import static com.googlesource.gerrit.plugins.replication.pull.api.HttpServletOps.checkAcceptHeader;
 import static com.googlesource.gerrit.plugins.replication.pull.api.HttpServletOps.setResponse;
 
@@ -70,17 +69,8 @@
       return;
     }
 
-    if (!userProvider.get().isIdentifiedUser()) {
-      setResponse(
-          httpServletResponse,
-          HttpServletResponse.SC_UNAUTHORIZED,
-          "Unauthorized user. '" + CALL_FETCH_ACTION + "' capability needed.");
-      return;
-    }
-
     String path = httpServletRequest.getRequestURI();
     String projectName = Url.decode(path.substring(path.lastIndexOf('/') + 1));
-
     try {
       if (initProject(projectName)) {
         setResponse(
@@ -118,9 +108,4 @@
     Project.NameKey projectNameKey = Project.NameKey.parse(projectName);
     return localFS.createProject(projectNameKey, RefNames.HEAD);
   }
-
-  public static String getProjectInitializationUrl(String pluginName, String projectName) {
-    return String.format(
-        "a/plugins/%s/init-project/%s", pluginName, Url.encode(projectName) + ".git");
-  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationApiMetricsFilter.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationApiMetricsFilter.java
new file mode 100644
index 0000000..3858db2
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationApiMetricsFilter.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2022 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.pull.api;
+
+import com.google.gerrit.httpd.AllRequestFilter;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+@Singleton
+public class PullReplicationApiMetricsFilter extends AllRequestFilter {
+  private final Provider<PullReplicationApiRequestMetrics> apiRequestMetrics;
+
+  @Inject
+  public PullReplicationApiMetricsFilter(
+      Provider<PullReplicationApiRequestMetrics> apiRequestMetrics) {
+    this.apiRequestMetrics = apiRequestMetrics;
+  }
+
+  @Override
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
+    if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) {
+      chain.doFilter(request, response);
+      return;
+    }
+
+    PullReplicationApiRequestMetrics requestMetrics = apiRequestMetrics.get();
+    requestMetrics.start((HttpServletRequest) request);
+    PullReplicationApiRequestMetrics.set(requestMetrics);
+
+    chain.doFilter(request, response);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationApiModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationApiModule.java
index e7b3a7f..d1d28a6 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationApiModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationApiModule.java
@@ -31,6 +31,7 @@
     bind(UpdateHeadAction.class).in(Scopes.SINGLETON);
     post(PROJECT_KIND, "fetch").to(FetchAction.class);
     post(PROJECT_KIND, "apply-object").to(ApplyObjectAction.class);
+    post(PROJECT_KIND, "apply-objects").to(ApplyObjectsAction.class);
     delete(PROJECT_KIND, "delete-project").to(ProjectDeletionAction.class);
     put(PROJECT_KIND, "HEAD").to(UpdateHeadAction.class);
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationApiRequestMetrics.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationApiRequestMetrics.java
new file mode 100644
index 0000000..8e4e43f
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationApiRequestMetrics.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2022 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.pull.api;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import com.google.gerrit.server.events.Event;
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.replication.pull.FetchReplicationMetrics;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+import javax.servlet.http.HttpServletRequest;
+
+public class PullReplicationApiRequestMetrics {
+  private static final ThreadLocal<PullReplicationApiRequestMetrics> localApiRequestMetrics =
+      new ThreadLocal<>();
+
+  public static final String HTTP_HEADER_X_START_TIME_NANOS = "X-StartTimeNanos";
+
+  private Optional<Long> startTimeNanos = Optional.empty();
+  private final AtomicBoolean initialised = new AtomicBoolean();
+  private final FetchReplicationMetrics metrics;
+
+  public static PullReplicationApiRequestMetrics get() {
+    return localApiRequestMetrics.get();
+  }
+
+  public static void set(PullReplicationApiRequestMetrics metrics) {
+    localApiRequestMetrics.set(metrics);
+  }
+
+  @Inject
+  public PullReplicationApiRequestMetrics(FetchReplicationMetrics metrics) {
+    this.metrics = metrics;
+  }
+
+  public void start(HttpServletRequest req) {
+    if (!initialised.compareAndSet(false, true)) {
+      throw new IllegalStateException("PullReplicationApiRequestMetrics already initialised");
+    }
+
+    startTimeNanos =
+        Optional.ofNullable(req.getHeader(HTTP_HEADER_X_START_TIME_NANOS))
+            .map(Long::parseLong)
+            /* Adjust with the system's nanotime for preventing negative execution times
+             * due to a clock skew between the client and the server timestamp.
+             */
+            .map(nanoTime -> Math.min(currentTimeNanos(), nanoTime));
+  }
+
+  public void start(Event event) {
+    if (!initialised.compareAndSet(false, true)) {
+      throw new IllegalStateException("PullReplicationApiRequestMetrics already initialised");
+    }
+    startTimeNanos = Optional.of(event.eventCreatedOn * 1000 * 1000 * 1000);
+  }
+
+  public Optional<Long> stop(String replicationSourceName) {
+    return startTimeNanos.map(
+        start -> {
+          long elapsed = currentTimeNanos() - start;
+          metrics.recordEnd2End(replicationSourceName, elapsed);
+          return elapsed;
+        });
+  }
+
+  private long currentTimeNanos() {
+    return MILLISECONDS.toNanos(System.currentTimeMillis());
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java
index 4af2bf7..8c47801 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java
@@ -23,9 +23,7 @@
 import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
 import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
-import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
 
-import com.google.common.base.Splitter;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.api.projects.HeadInput;
@@ -38,11 +36,9 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.httpd.AllRequestFilter;
 import com.google.gerrit.httpd.restapi.RestApiServlet;
 import com.google.gerrit.json.OutputFormat;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
@@ -51,18 +47,20 @@
 import com.google.gson.stream.JsonReader;
 import com.google.gson.stream.MalformedJsonException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
 import com.googlesource.gerrit.plugins.replication.pull.api.FetchAction.Input;
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionInput;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionsInput;
 import com.googlesource.gerrit.plugins.replication.pull.api.exception.InitProjectException;
 import java.io.BufferedReader;
 import java.io.EOFException;
 import java.io.IOException;
 import java.io.PrintWriter;
-import java.util.ArrayList;
-import java.util.List;
 import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import javax.servlet.FilterChain;
 import javax.servlet.ServletException;
 import javax.servlet.ServletRequest;
@@ -73,33 +71,37 @@
 public class PullReplicationFilter extends AllRequestFilter {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static final Pattern projectNameInGerritUrl = Pattern.compile(".*/projects/([^/]+)/.*");
+  private static final Pattern projectNameInitProjectUrl =
+      Pattern.compile(".*/init-project/([^/]+.git)");
+
   private FetchAction fetchAction;
   private ApplyObjectAction applyObjectAction;
+  private ApplyObjectsAction applyObjectsAction;
   private ProjectInitializationAction projectInitializationAction;
   private UpdateHeadAction updateHEADAction;
   private ProjectDeletionAction projectDeletionAction;
   private ProjectsCollection projectsCollection;
   private Gson gson;
-  private Provider<CurrentUser> userProvider;
   private String pluginName;
 
   @Inject
   public PullReplicationFilter(
       FetchAction fetchAction,
       ApplyObjectAction applyObjectAction,
+      ApplyObjectsAction applyObjectsAction,
       ProjectInitializationAction projectInitializationAction,
       UpdateHeadAction updateHEADAction,
       ProjectDeletionAction projectDeletionAction,
       ProjectsCollection projectsCollection,
-      Provider<CurrentUser> userProvider,
       @PluginName String pluginName) {
     this.fetchAction = fetchAction;
     this.applyObjectAction = applyObjectAction;
+    this.applyObjectsAction = applyObjectsAction;
     this.projectInitializationAction = projectInitializationAction;
     this.updateHEADAction = updateHEADAction;
     this.projectDeletionAction = projectDeletionAction;
     this.projectsCollection = projectsCollection;
-    this.userProvider = userProvider;
     this.pluginName = pluginName;
     this.gson = OutputFormat.JSON.newGsonBuilder().create();
   }
@@ -116,38 +118,20 @@
     HttpServletRequest httpRequest = (HttpServletRequest) request;
     try {
       if (isFetchAction(httpRequest)) {
-        if (userProvider.get().isIdentifiedUser()) {
-          writeResponse(httpResponse, doFetch(httpRequest));
-        } else {
-          httpResponse.sendError(SC_UNAUTHORIZED);
-        }
+        writeResponse(httpResponse, doFetch(httpRequest));
       } else if (isApplyObjectAction(httpRequest)) {
-        if (userProvider.get().isIdentifiedUser()) {
-          writeResponse(httpResponse, doApplyObject(httpRequest));
-        } else {
-          httpResponse.sendError(SC_UNAUTHORIZED);
-        }
+        writeResponse(httpResponse, doApplyObject(httpRequest));
+      } else if (isApplyObjectsAction(httpRequest)) {
+        writeResponse(httpResponse, doApplyObjects(httpRequest));
       } else if (isInitProjectAction(httpRequest)) {
-        if (userProvider.get().isIdentifiedUser()) {
-          if (!checkAcceptHeader(httpRequest, httpResponse)) {
-            return;
-          }
-          doInitProject(httpRequest, httpResponse);
-        } else {
-          httpResponse.sendError(SC_UNAUTHORIZED);
+        if (!checkAcceptHeader(httpRequest, httpResponse)) {
+          return;
         }
+        doInitProject(httpRequest, httpResponse);
       } else if (isUpdateHEADAction(httpRequest)) {
-        if (userProvider.get().isIdentifiedUser()) {
-          writeResponse(httpResponse, doUpdateHEAD(httpRequest));
-        } else {
-          httpResponse.sendError(SC_UNAUTHORIZED);
-        }
+        writeResponse(httpResponse, doUpdateHEAD(httpRequest));
       } else if (isDeleteProjectAction(httpRequest)) {
-        if (userProvider.get().isIdentifiedUser()) {
-          writeResponse(httpResponse, doDeleteProject(httpRequest));
-        } else {
-          httpResponse.sendError(SC_UNAUTHORIZED);
-        }
+        writeResponse(httpResponse, doDeleteProject(httpRequest));
       } else {
         chain.doFilter(request, response);
       }
@@ -170,6 +154,9 @@
     } catch (InitProjectException | ResourceNotFoundException e) {
       RestApiServlet.replyError(
           httpRequest, httpResponse, SC_INTERNAL_SERVER_ERROR, e.getMessage(), e.caching(), e);
+    } catch (NoSuchElementException e) {
+      RestApiServlet.replyError(
+          httpRequest, httpResponse, SC_BAD_REQUEST, "Project name not present in the url", e);
     } catch (Exception e) {
       throw new ServletException(e);
     }
@@ -178,8 +165,8 @@
   private void doInitProject(HttpServletRequest httpRequest, HttpServletResponse httpResponse)
       throws RestApiException, IOException, PermissionBackendException {
 
-    String path = httpRequest.getRequestURI();
-    String projectName = Url.decode(path.substring(path.lastIndexOf('/') + 1));
+    IdString id = getInitProjectName(httpRequest).get();
+    String projectName = id.get();
     if (projectInitializationAction.initProject(projectName)) {
       setResponse(
           httpResponse, HttpServletResponse.SC_CREATED, "Project " + projectName + " initialized");
@@ -192,25 +179,35 @@
   private Response<Map<String, Object>> doApplyObject(HttpServletRequest httpRequest)
       throws RestApiException, IOException, PermissionBackendException {
     RevisionInput input = readJson(httpRequest, TypeLiteral.get(RevisionInput.class));
-    IdString id = getProjectName(httpRequest);
+    IdString id = getProjectName(httpRequest).get();
     ProjectResource projectResource = projectsCollection.parse(TopLevelResource.INSTANCE, id);
 
     return (Response<Map<String, Object>>) applyObjectAction.apply(projectResource, input);
   }
 
   @SuppressWarnings("unchecked")
+  private Response<Map<String, Object>> doApplyObjects(HttpServletRequest httpRequest)
+      throws RestApiException, IOException, PermissionBackendException {
+    RevisionsInput input = readJson(httpRequest, TypeLiteral.get(RevisionsInput.class));
+    IdString id = getProjectName(httpRequest).get();
+    ProjectResource projectResource = projectsCollection.parse(TopLevelResource.INSTANCE, id);
+
+    return (Response<Map<String, Object>>) applyObjectsAction.apply(projectResource, input);
+  }
+
+  @SuppressWarnings("unchecked")
   private Response<String> doUpdateHEAD(HttpServletRequest httpRequest) throws Exception {
     HeadInput input = readJson(httpRequest, TypeLiteral.get(HeadInput.class));
-    ProjectResource projectResource =
-        projectsCollection.parse(TopLevelResource.INSTANCE, getProjectName(httpRequest));
+    IdString id = getProjectName(httpRequest).get();
+    ProjectResource projectResource = projectsCollection.parse(TopLevelResource.INSTANCE, id);
 
     return (Response<String>) updateHEADAction.apply(projectResource, input);
   }
 
   @SuppressWarnings("unchecked")
   private Response<String> doDeleteProject(HttpServletRequest httpRequest) throws Exception {
-    ProjectResource projectResource =
-        projectsCollection.parse(TopLevelResource.INSTANCE, getProjectName(httpRequest));
+    IdString id = getProjectName(httpRequest).get();
+    ProjectResource projectResource = projectsCollection.parse(TopLevelResource.INSTANCE, id);
     return (Response<String>)
         projectDeletionAction.apply(projectResource, new ProjectDeletionAction.DeleteInput());
   }
@@ -219,7 +216,7 @@
   private Response<Map<String, Object>> doFetch(HttpServletRequest httpRequest)
       throws IOException, RestApiException, PermissionBackendException {
     Input input = readJson(httpRequest, TypeLiteral.get(Input.class));
-    IdString id = getProjectName(httpRequest);
+    IdString id = getProjectName(httpRequest).get();
     ProjectResource projectResource = projectsCollection.parse(TopLevelResource.INSTANCE, id);
 
     return (Response<Map<String, Object>>) fetchAction.apply(projectResource, input);
@@ -276,38 +273,48 @@
    * @param req
    * @return project name
    */
-  private IdString getProjectName(HttpServletRequest req) {
-    String path = req.getRequestURI();
+  private Optional<IdString> getInitProjectName(HttpServletRequest req) {
+    return extractProjectName(req, projectNameInitProjectUrl);
+  }
 
-    List<IdString> out = new ArrayList<>();
-    for (String p : Splitter.on('/').split(path)) {
-      out.add(IdString.fromUrl(p));
+  private Optional<IdString> getProjectName(HttpServletRequest req) {
+    return extractProjectName(req, projectNameInGerritUrl);
+  }
+
+  private Optional<IdString> extractProjectName(HttpServletRequest req, Pattern urlPattern) {
+    String path = req.getRequestURI();
+    Matcher projectGroupMatcher = urlPattern.matcher(path);
+
+    if (projectGroupMatcher.find()) {
+      return Optional.of(IdString.fromUrl(projectGroupMatcher.group(1)));
     }
-    if (!out.isEmpty() && out.get(out.size() - 1).isEmpty()) {
-      out.remove(out.size() - 1);
-    }
-    return out.get(3);
+
+    return Optional.empty();
   }
 
   private boolean isApplyObjectAction(HttpServletRequest httpRequest) {
-    return httpRequest.getRequestURI().endsWith("pull-replication~apply-object");
+    return httpRequest.getRequestURI().endsWith(String.format("/%s~apply-object", pluginName));
+  }
+
+  private boolean isApplyObjectsAction(HttpServletRequest httpRequest) {
+    return httpRequest.getRequestURI().endsWith(String.format("/%s~apply-objects", pluginName));
   }
 
   private boolean isFetchAction(HttpServletRequest httpRequest) {
-    return httpRequest.getRequestURI().endsWith("pull-replication~fetch");
+    return httpRequest.getRequestURI().endsWith(String.format("/%s~fetch", pluginName));
   }
 
   private boolean isInitProjectAction(HttpServletRequest httpRequest) {
-    return httpRequest.getRequestURI().contains("pull-replication/init-project/");
+    return httpRequest.getRequestURI().contains(String.format("/%s/init-project/", pluginName));
   }
 
   private boolean isUpdateHEADAction(HttpServletRequest httpRequest) {
-    return httpRequest.getRequestURI().matches("(/a)?/projects/[^/]+/HEAD")
+    return httpRequest.getRequestURI().matches(".*/projects/[^/]+/HEAD")
         && "PUT".equals(httpRequest.getMethod());
   }
 
   private boolean isDeleteProjectAction(HttpServletRequest httpRequest) {
-    return httpRequest.getRequestURI().endsWith(String.format("%s~delete-project", pluginName))
+    return httpRequest.getRequestURI().endsWith(String.format("/%s~delete-project", pluginName))
         && "DELETE".equals(httpRequest.getMethod());
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionData.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionData.java
index bcd4e05..ffe98da 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionData.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionData.java
@@ -15,8 +15,11 @@
 package com.googlesource.gerrit.plugins.replication.pull.api.data;
 
 import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
 
 public class RevisionData {
+  private transient List<ObjectId> parentObjectIds;
+
   private RevisionObjectData commitObject;
 
   private RevisionObjectData treeObject;
@@ -24,14 +27,20 @@
   private List<RevisionObjectData> blobs;
 
   public RevisionData(
+      List<ObjectId> parentObjectIds,
       RevisionObjectData commitObject,
       RevisionObjectData treeObject,
       List<RevisionObjectData> blobs) {
+    this.parentObjectIds = parentObjectIds;
     this.commitObject = commitObject;
     this.treeObject = treeObject;
     this.blobs = blobs;
   }
 
+  public List<ObjectId> getParentObjetIds() {
+    return parentObjectIds;
+  }
+
   public RevisionObjectData getCommitObject() {
     return commitObject;
   }
@@ -43,4 +52,15 @@
   public List<RevisionObjectData> getBlobs() {
     return blobs;
   }
+
+  @Override
+  public String toString() {
+    return "{"
+        + (commitObject != null ? "commitObject=" + commitObject : "")
+        + " "
+        + (treeObject != null ? "treeObject=" + treeObject : "")
+        + " "
+        + (blobs != null && !blobs.isEmpty() ? "blobs=" + blobs : "")
+        + "}";
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionInput.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionInput.java
index bc3e218..c18e11d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionInput.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionInput.java
@@ -14,6 +14,10 @@
 
 package com.googlesource.gerrit.plugins.replication.pull.api.data;
 
+import java.util.List;
+import java.util.Objects;
+import org.eclipse.jgit.lib.Constants;
+
 public class RevisionInput {
   private String label;
 
@@ -38,4 +42,44 @@
   public RevisionData getRevisionData() {
     return revisionData;
   }
+
+  public void validate() {
+    validate(refName, revisionData);
+  }
+
+  static void validate(String refName, RevisionData revisionData) {
+    // Non-heads refs can point to non-commit objects
+    if (!refName.startsWith(Constants.R_HEADS)
+        && Objects.isNull(revisionData.getCommitObject())
+        && Objects.isNull(revisionData.getTreeObject())) {
+
+      List<RevisionObjectData> blobs = revisionData.getBlobs();
+
+      if (Objects.isNull(blobs) || blobs.isEmpty()) {
+        throw new IllegalArgumentException(
+            "Ref " + refName + " cannot have a null or empty list of BLOBs associated");
+      }
+
+      if (blobs.size() > 1) {
+        throw new IllegalArgumentException("Ref " + refName + " has more than one BLOB associated");
+      }
+
+      return;
+    }
+
+    if (Objects.isNull(revisionData.getCommitObject())
+        || Objects.isNull(revisionData.getCommitObject().getContent())
+        || revisionData.getCommitObject().getContent().length == 0
+        || Objects.isNull(revisionData.getCommitObject().getType())) {
+      throw new IllegalArgumentException(
+          "Commit object for ref " + refName + " cannot be null or empty");
+    }
+
+    if (Objects.isNull(revisionData.getTreeObject())
+        || Objects.isNull(revisionData.getTreeObject().getContent())
+        || Objects.isNull(revisionData.getTreeObject().getType())) {
+      throw new IllegalArgumentException(
+          "Ref-update tree object for ref " + refName + " cannot be null");
+    }
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionObjectData.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionObjectData.java
index 02ba06c..b21f495 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionObjectData.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionObjectData.java
@@ -15,12 +15,15 @@
 package com.googlesource.gerrit.plugins.replication.pull.api.data;
 
 import java.util.Base64;
+import org.eclipse.jgit.lib.Constants;
 
 public class RevisionObjectData {
+  private final String sha1;
   private final Integer type;
   private final String content;
 
-  public RevisionObjectData(int type, byte[] content) {
+  public RevisionObjectData(String sha1, int type, byte[] content) {
+    this.sha1 = sha1;
     this.type = type;
     this.content = content == null ? "" : Base64.getEncoder().encodeToString(content);
   }
@@ -32,4 +35,29 @@
   public byte[] getContent() {
     return Base64.getDecoder().decode(content);
   }
+
+  public String getSha1() {
+    return sha1;
+  }
+
+  @Override
+  public String toString() {
+    String typeStr;
+    switch (type) {
+      case Constants.OBJ_BLOB:
+        typeStr = "BLOB";
+        break;
+      case Constants.OBJ_COMMIT:
+        typeStr = "COMMIT";
+        break;
+      case Constants.OBJ_TREE:
+        typeStr = "TREE";
+        break;
+      default:
+        typeStr = "type:" + type;
+        break;
+    }
+
+    return sha1 + " (" + typeStr + ")";
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionsInput.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionsInput.java
new file mode 100644
index 0000000..2361f6b
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionsInput.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2022 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.pull.api.data;
+
+import java.util.Arrays;
+
+public class RevisionsInput {
+  private String label;
+
+  private String refName;
+
+  private RevisionData[] revisionsData;
+
+  public RevisionsInput(String label, String refName, RevisionData[] revisionsData) {
+    this.label = label;
+    this.refName = refName;
+    this.revisionsData = revisionsData;
+  }
+
+  public String getLabel() {
+    return label;
+  }
+
+  public String getRefName() {
+    return refName;
+  }
+
+  public RevisionData[] getRevisionsData() {
+    return revisionsData;
+  }
+
+  public void validate() {
+    for (RevisionData revisionData : revisionsData) {
+      RevisionInput.validate(refName, revisionData);
+    }
+  }
+
+  @Override
+  public String toString() {
+    return "RevisionsInput { "
+        + label
+        + ":"
+        + refName
+        + " - "
+        + Arrays.toString(revisionsData)
+        + "}";
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchApiClient.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchApiClient.java
index 476a35b..6000eb9 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchApiClient.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchApiClient.java
@@ -14,10 +14,13 @@
 
 package com.googlesource.gerrit.plugins.replication.pull.client;
 
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
 import com.google.gerrit.entities.Project;
 import com.googlesource.gerrit.plugins.replication.pull.Source;
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData;
 import java.io.IOException;
+import java.util.List;
 import org.apache.http.client.ClientProtocolException;
 import org.eclipse.jgit.transport.URIish;
 
@@ -27,9 +30,15 @@
     FetchApiClient create(Source source);
   }
 
-  HttpResult callFetch(Project.NameKey project, String refName, URIish targetUri)
+  HttpResult callFetch(
+      Project.NameKey project, String refName, URIish targetUri, long startTimeNanos)
       throws ClientProtocolException, IOException;
 
+  default HttpResult callFetch(Project.NameKey project, String refName, URIish targetUri)
+      throws ClientProtocolException, IOException {
+    return callFetch(project, refName, targetUri, MILLISECONDS.toNanos(System.currentTimeMillis()));
+  }
+
   HttpResult initProject(Project.NameKey project, URIish uri) throws IOException;
 
   HttpResult deleteProject(Project.NameKey project, URIish apiUri) throws IOException;
@@ -43,4 +52,8 @@
       RevisionData revisionData,
       URIish targetUri)
       throws ClientProtocolException, IOException;
+
+  HttpResult callSendObjects(
+      Project.NameKey project, String refName, List<RevisionData> revisionData, URIish targetUri)
+      throws ClientProtocolException, IOException;
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClient.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClient.java
index f1d486d..cbc2cf7 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClient.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClient.java
@@ -15,7 +15,6 @@
 package com.googlesource.gerrit.plugins.replication.pull.client;
 
 import static com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES;
-import static com.googlesource.gerrit.plugins.replication.pull.api.ProjectInitializationAction.getProjectInitializationUrl;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Strings;
@@ -23,6 +22,7 @@
 import com.google.common.net.MediaType;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.server.config.GerritInstanceId;
@@ -32,26 +32,30 @@
 import com.google.inject.assistedinject.Assisted;
 import com.googlesource.gerrit.plugins.replication.CredentialsFactory;
 import com.googlesource.gerrit.plugins.replication.ReplicationConfig;
+import com.googlesource.gerrit.plugins.replication.pull.BearerTokenProvider;
 import com.googlesource.gerrit.plugins.replication.pull.Source;
+import com.googlesource.gerrit.plugins.replication.pull.api.PullReplicationApiRequestMetrics;
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData;
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionInput;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionsInput;
 import com.googlesource.gerrit.plugins.replication.pull.filter.SyncRefsFilter;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
+import java.util.List;
 import java.util.Optional;
+import org.apache.http.HttpHeaders;
 import org.apache.http.HttpResponse;
 import org.apache.http.ParseException;
-import org.apache.http.auth.AuthScope;
+import org.apache.http.auth.AuthenticationException;
 import org.apache.http.auth.UsernamePasswordCredentials;
 import org.apache.http.client.ClientProtocolException;
-import org.apache.http.client.CredentialsProvider;
 import org.apache.http.client.ResponseHandler;
 import org.apache.http.client.methods.HttpDelete;
 import org.apache.http.client.methods.HttpPost;
 import org.apache.http.client.methods.HttpPut;
-import org.apache.http.client.protocol.HttpClientContext;
+import org.apache.http.client.methods.HttpRequestBase;
 import org.apache.http.entity.StringEntity;
-import org.apache.http.impl.client.BasicCredentialsProvider;
+import org.apache.http.impl.auth.BasicScheme;
 import org.apache.http.message.BasicHeader;
 import org.apache.http.util.EntityUtils;
 import org.eclipse.jgit.transport.CredentialItem;
@@ -70,6 +74,8 @@
   private final String instanceId;
   private final String pluginName;
   private final SyncRefsFilter syncRefsFilter;
+  private final BearerTokenProvider bearerTokenProvider;
+  private final String urlAuthenticationPrefix;
 
   @Inject
   FetchRestApiClient(
@@ -79,6 +85,7 @@
       SyncRefsFilter syncRefsFilter,
       @PluginName String pluginName,
       @Nullable @GerritInstanceId String instanceId,
+      BearerTokenProvider bearerTokenProvider,
       @Assisted Source source) {
     this.credentials = credentials;
     this.httpClientFactory = httpClientFactory;
@@ -94,18 +101,19 @@
     requireNonNull(
         Strings.emptyToNull(this.instanceId),
         "gerrit.instanceId or replication.instanceLabel must be set");
+
+    this.bearerTokenProvider = bearerTokenProvider;
+    this.urlAuthenticationPrefix = bearerTokenProvider.get().map(br -> "").orElse("a/");
   }
 
   /* (non-Javadoc)
    * @see com.googlesource.gerrit.plugins.replication.pull.client.FetchApiClient#callFetch(com.google.gerrit.entities.Project.NameKey, java.lang.String, org.eclipse.jgit.transport.URIish)
    */
   @Override
-  public HttpResult callFetch(Project.NameKey project, String refName, URIish targetUri)
-      throws ClientProtocolException, IOException {
-    String url =
-        String.format(
-            "%s/a/projects/%s/pull-replication~fetch",
-            targetUri.toString(), Url.encode(project.get()));
+  public HttpResult callFetch(
+      Project.NameKey project, String refName, URIish targetUri, long startTimeNanos)
+      throws IOException {
+    String url = formatUrl(targetUri.toString(), project, "fetch");
     Boolean callAsync = !syncRefsFilter.match(refName);
     HttpPost post = new HttpPost(url);
     post.setEntity(
@@ -115,7 +123,10 @@
                 instanceId, refName, callAsync),
             StandardCharsets.UTF_8));
     post.addHeader(new BasicHeader("Content-Type", "application/json"));
-    return httpClientFactory.create(source).execute(post, this, getContext(targetUri));
+    post.addHeader(
+        PullReplicationApiRequestMetrics.HTTP_HEADER_X_START_TIME_NANOS,
+        Long.toString(startTimeNanos));
+    return executeRequest(post, bearerTokenProvider.get(), targetUri);
   }
 
   /* (non-Javadoc)
@@ -123,13 +134,11 @@
    */
   @Override
   public HttpResult initProject(Project.NameKey project, URIish uri) throws IOException {
-    String url =
-        String.format(
-            "%s/%s", uri.toString(), getProjectInitializationUrl(pluginName, project.get()));
+    String url = formatInitProjectUrl(uri.toString(), project);
     HttpPut put = new HttpPut(url);
     put.addHeader(new BasicHeader("Accept", MediaType.ANY_TEXT_TYPE.toString()));
     put.addHeader(new BasicHeader("Content-Type", MediaType.PLAIN_TEXT_UTF_8.toString()));
-    return httpClientFactory.create(source).execute(put, this, getContext(uri));
+    return executeRequest(put, bearerTokenProvider.get(), uri);
   }
 
   /* (non-Javadoc)
@@ -137,10 +146,9 @@
    */
   @Override
   public HttpResult deleteProject(Project.NameKey project, URIish apiUri) throws IOException {
-    String url =
-        String.format("%s/%s", apiUri.toASCIIString(), getProjectDeletionUrl(project.get()));
+    String url = formatUrl(apiUri.toASCIIString(), project, "delete-project");
     HttpDelete delete = new HttpDelete(url);
-    return httpClientFactory.create(source).execute(delete, this, getContext(apiUri));
+    return executeRequest(delete, bearerTokenProvider.get(), apiUri);
   }
 
   /* (non-Javadoc)
@@ -150,13 +158,12 @@
   public HttpResult updateHead(Project.NameKey project, String newHead, URIish apiUri)
       throws IOException {
     logger.atFine().log("Updating head of %s on %s", project.get(), newHead);
-    String url =
-        String.format("%s/%s", apiUri.toASCIIString(), getProjectUpdateHeadUrl(project.get()));
+    String url = formatUrl(apiUri.toASCIIString(), project, "HEAD");
     HttpPut req = new HttpPut(url);
     req.setEntity(
         new StringEntity(String.format("{\"ref\": \"%s\"}", newHead), StandardCharsets.UTF_8));
     req.addHeader(new BasicHeader("Content-Type", MediaType.JSON_UTF_8.toString()));
-    return httpClientFactory.create(source).execute(req, this, getContext(apiUri));
+    return executeRequest(req, bearerTokenProvider.get(), apiUri);
   }
 
   /* (non-Javadoc)
@@ -179,15 +186,42 @@
     }
     RevisionInput input = new RevisionInput(instanceId, refName, revisionData);
 
-    String url =
-        String.format(
-            "%s/a/projects/%s/%s~apply-object",
-            targetUri.toString(), Url.encode(project.get()), pluginName);
+    String url = formatUrl(targetUri.toString(), project, "apply-object");
 
     HttpPost post = new HttpPost(url);
     post.setEntity(new StringEntity(GSON.toJson(input)));
     post.addHeader(new BasicHeader("Content-Type", MediaType.JSON_UTF_8.toString()));
-    return httpClientFactory.create(source).execute(post, this, getContext(targetUri));
+    return executeRequest(post, bearerTokenProvider.get(), targetUri);
+  }
+
+  @Override
+  public HttpResult callSendObjects(
+      NameKey project, String refName, List<RevisionData> revisionData, URIish targetUri)
+      throws ClientProtocolException, IOException {
+    if (revisionData.size() == 1) {
+      return callSendObject(project, refName, false, revisionData.get(0), targetUri);
+    }
+
+    RevisionData[] inputData = new RevisionData[revisionData.size()];
+    RevisionsInput input = new RevisionsInput(instanceId, refName, revisionData.toArray(inputData));
+
+    String url = formatUrl(targetUri.toString(), project, "apply-objects");
+    HttpPost post = new HttpPost(url);
+    post.setEntity(new StringEntity(GSON.toJson(input)));
+    post.addHeader(new BasicHeader("Content-Type", MediaType.JSON_UTF_8.toString()));
+    return executeRequest(post, bearerTokenProvider.get(), targetUri);
+  }
+
+  private String formatUrl(String targetUri, Project.NameKey project, String api) {
+    return String.format(
+        "%s/%sprojects/%s/%s~%s",
+        targetUri, urlAuthenticationPrefix, Url.encode(project.get()), pluginName, api);
+  }
+
+  private String formatInitProjectUrl(String targetUri, Project.NameKey project) {
+    return String.format(
+        "%s/%splugins/%s/init-project/%s.git",
+        targetUri, urlAuthenticationPrefix, pluginName, Url.encode(project.get()));
   }
 
   private void requireNull(Object object, String string) {
@@ -198,40 +232,54 @@
 
   @Override
   public HttpResult handleResponse(HttpResponse response) {
-    Optional<String> responseBody = Optional.empty();
 
-    try {
-      responseBody = Optional.ofNullable(EntityUtils.toString(response.getEntity()));
-    } catch (ParseException | IOException e) {
-      logger.atSevere().withCause(e).log("Unable get response body from %s", response.toString());
-    }
+    Optional<String> responseBody =
+        Optional.ofNullable(response.getEntity())
+            .flatMap(
+                body -> {
+                  try {
+                    return Optional.of(EntityUtils.toString(body));
+                  } catch (ParseException | IOException e) {
+                    logger.atSevere().withCause(e).log(
+                        "Unable get response body from %s", response.toString());
+                    return Optional.empty();
+                  }
+                });
+
     return new HttpResult(response.getStatusLine().getStatusCode(), responseBody);
   }
 
-  private HttpClientContext getContext(URIish targetUri) {
-    HttpClientContext ctx = HttpClientContext.create();
-    ctx.setCredentialsProvider(adapt(credentials.create(source.getRemoteConfigName()), targetUri));
-    return ctx;
+  private HttpResult executeRequest(
+      HttpRequestBase httpRequest, Optional<String> bearerToken, URIish targetUri)
+      throws IOException {
+
+    HttpRequestBase reqWithAuthentication =
+        bearerToken.isPresent()
+            ? withBearerTokenAuthentication(httpRequest, bearerToken.get())
+            : withBasicAuthentication(targetUri, httpRequest);
+
+    return httpClientFactory.create(source).execute(reqWithAuthentication, this);
   }
 
-  private CredentialsProvider adapt(org.eclipse.jgit.transport.CredentialsProvider cp, URIish uri) {
+  private HttpRequestBase withBasicAuthentication(URIish targetUri, HttpRequestBase req) {
+    org.eclipse.jgit.transport.CredentialsProvider cp =
+        credentials.create(source.getRemoteConfigName());
     CredentialItem.Username user = new CredentialItem.Username();
     CredentialItem.Password pass = new CredentialItem.Password();
-    if (cp.supports(user, pass) && cp.get(uri, user, pass)) {
-      CredentialsProvider adapted = new BasicCredentialsProvider();
-      adapted.setCredentials(
-          AuthScope.ANY,
-          new UsernamePasswordCredentials(user.getValue(), new String(pass.getValue())));
-      return adapted;
+    if (cp.supports(user, pass) && cp.get(targetUri, user, pass)) {
+      UsernamePasswordCredentials creds =
+          new UsernamePasswordCredentials(user.getValue(), new String(pass.getValue()));
+      try {
+        req.addHeader(new BasicScheme().authenticate(creds, req, null));
+      } catch (AuthenticationException e) {
+        logger.atFine().log("Anonymous Basic Authentication for uri: %s", targetUri);
+      }
     }
-    return null;
+    return req;
   }
 
-  String getProjectDeletionUrl(String projectName) {
-    return String.format("a/projects/%s/%s~delete-project", Url.encode(projectName), pluginName);
-  }
-
-  String getProjectUpdateHeadUrl(String projectName) {
-    return String.format("a/projects/%s/%s~HEAD", Url.encode(projectName), pluginName);
+  private HttpRequestBase withBearerTokenAuthentication(HttpRequestBase req, String bearerToken) {
+    req.addHeader(new BasicHeader(HttpHeaders.AUTHORIZATION, "Bearer " + bearerToken));
+    return req;
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpClient.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpClient.java
index 7bfc7d1..6254a42 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpClient.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpClient.java
@@ -18,14 +18,11 @@
 import org.apache.http.client.ClientProtocolException;
 import org.apache.http.client.ResponseHandler;
 import org.apache.http.client.methods.HttpUriRequest;
-import org.apache.http.protocol.HttpContext;
 
 /** HTTP client for executing URI requests to a remote site */
 public interface HttpClient {
 
   public <T> T execute(
-      final HttpUriRequest request,
-      final ResponseHandler<? extends T> responseHandler,
-      final HttpContext context)
+      final HttpUriRequest request, final ResponseHandler<? extends T> responseHandler)
       throws ClientProtocolException, IOException;
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpResult.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpResult.java
index bd164df..ec9d65f 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpResult.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpResult.java
@@ -15,9 +15,6 @@
 package com.googlesource.gerrit.plugins.replication.pull.client;
 
 import static javax.servlet.http.HttpServletResponse.SC_CONFLICT;
-import static javax.servlet.http.HttpServletResponse.SC_CREATED;
-import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
-import static javax.servlet.http.HttpServletResponse.SC_OK;
 
 import com.google.gerrit.entities.Project;
 import java.util.Optional;
@@ -36,7 +33,7 @@
   }
 
   public boolean isSuccessful() {
-    return responseCode == SC_CREATED || responseCode == SC_NO_CONTENT || responseCode == SC_OK;
+    return responseCode / 100 == 2; // Any 2xx response code is a success
   }
 
   public boolean isProjectMissing(Project.NameKey projectName) {
@@ -47,4 +44,11 @@
   public boolean isParentObjectMissing() {
     return responseCode == SC_CONFLICT;
   }
+
+  @Override
+  public String toString() {
+    return isSuccessful()
+        ? "OK"
+        : "FAILED" + ", status=" + responseCode + message.map(s -> " '" + s + "'").orElse("");
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/SourceHttpClient.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/SourceHttpClient.java
index ee0fe79..fa700e3 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/SourceHttpClient.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/SourceHttpClient.java
@@ -25,7 +25,6 @@
 import org.apache.http.conn.HttpClientConnectionManager;
 import org.apache.http.impl.client.HttpClients;
 import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
-import org.apache.http.protocol.HttpContext;
 
 /** Apache HTTP client implementation based on Source-specific parameters */
 public class SourceHttpClient implements HttpClient {
@@ -41,8 +40,7 @@
   }
 
   @Override
-  public <T> T execute(
-      HttpUriRequest request, ResponseHandler<? extends T> responseHandler, HttpContext context)
+  public <T> T execute(HttpUriRequest request, ResponseHandler<? extends T> responseHandler)
       throws ClientProtocolException, IOException {
     return source
         .memoize(
@@ -51,7 +49,7 @@
                     .setConnectionManager(customConnectionManager(source))
                     .setDefaultRequestConfig(customRequestConfig(source))
                     .build())
-        .execute(request, responseHandler, context);
+        .execute(request, responseHandler);
   }
 
   private static RequestConfig customRequestConfig(Source source) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/StreamEventListener.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/StreamEventListener.java
index 4360fcf..0f092e5 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/StreamEventListener.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/StreamEventListener.java
@@ -30,31 +30,37 @@
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.googlesource.gerrit.plugins.replication.pull.FetchOne;
 import com.googlesource.gerrit.plugins.replication.pull.api.FetchAction;
-import com.googlesource.gerrit.plugins.replication.pull.api.FetchAction.FetchJob;
-import com.googlesource.gerrit.plugins.replication.pull.api.FetchCommand;
+import com.googlesource.gerrit.plugins.replication.pull.api.FetchJob;
+import com.googlesource.gerrit.plugins.replication.pull.api.FetchJob.Factory;
 import com.googlesource.gerrit.plugins.replication.pull.api.ProjectInitializationAction;
+import com.googlesource.gerrit.plugins.replication.pull.api.PullReplicationApiRequestMetrics;
 import org.eclipse.jgit.lib.ObjectId;
 
 public class StreamEventListener implements EventListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private String instanceId;
-  private FetchCommand fetchCommand;
   private WorkQueue workQueue;
   private ProjectInitializationAction projectInitializationAction;
 
+  private Factory fetchJobFactory;
+  private final Provider<PullReplicationApiRequestMetrics> metricsProvider;
+
   @Inject
   public StreamEventListener(
       @Nullable @GerritInstanceId String instanceId,
-      FetchCommand command,
       ProjectInitializationAction projectInitializationAction,
-      WorkQueue workQueue) {
+      WorkQueue workQueue,
+      FetchJob.Factory fetchJobFactory,
+      Provider<PullReplicationApiRequestMetrics> metricsProvider) {
     this.instanceId = instanceId;
-    this.fetchCommand = command;
     this.projectInitializationAction = projectInitializationAction;
     this.workQueue = workQueue;
+    this.fetchJobFactory = fetchJobFactory;
+    this.metricsProvider = metricsProvider;
 
     requireNonNull(
         Strings.emptyToNull(this.instanceId), "gerrit.instanceId cannot be null or empty");
@@ -63,13 +69,16 @@
   @Override
   public void onEvent(Event event) {
     if (!instanceId.equals(event.instanceId)) {
+      PullReplicationApiRequestMetrics metrics = metricsProvider.get();
+      metrics.start(event);
       if (event instanceof RefUpdatedEvent) {
         RefUpdatedEvent refUpdatedEvent = (RefUpdatedEvent) event;
         if (!isProjectDelete(refUpdatedEvent)) {
           fetchRefsAsync(
               refUpdatedEvent.getRefName(),
               refUpdatedEvent.instanceId,
-              refUpdatedEvent.getProjectNameKey());
+              refUpdatedEvent.getProjectNameKey(),
+              metrics);
         }
       }
       if (event instanceof ProjectCreatedEvent) {
@@ -79,7 +88,8 @@
           fetchRefsAsync(
               FetchOne.ALL_REFS,
               projectCreatedEvent.instanceId,
-              projectCreatedEvent.getProjectNameKey());
+              projectCreatedEvent.getProjectNameKey(),
+              metrics);
         } catch (AuthException | PermissionBackendException e) {
           logger.atSevere().withCause(e).log(
               "Cannot initialise project:%s", projectCreatedEvent.projectName);
@@ -93,11 +103,15 @@
         && ObjectId.zeroId().equals(ObjectId.fromString(event.refUpdate.get().newRev));
   }
 
-  protected void fetchRefsAsync(String refName, String sourceInstanceId, NameKey projectNameKey) {
+  protected void fetchRefsAsync(
+      String refName,
+      String sourceInstanceId,
+      NameKey projectNameKey,
+      PullReplicationApiRequestMetrics metrics) {
     FetchAction.Input input = new FetchAction.Input();
     input.refName = refName;
     input.label = sourceInstanceId;
-    workQueue.getDefaultQueue().submit(new FetchJob(fetchCommand, projectNameKey, input));
+    workQueue.getDefaultQueue().submit(fetchJobFactory.create(projectNameKey, input, metrics));
   }
 
   private String getProjectRepositoryName(ProjectCreatedEvent projectCreatedEvent) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObject.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObject.java
index 03362bf..2bb1caf 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObject.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObject.java
@@ -41,35 +41,51 @@
     this.gitManager = gitManager;
   }
 
-  public RefUpdateState apply(Project.NameKey name, RefSpec refSpec, RevisionData revisionData)
+  public RefUpdateState apply(Project.NameKey name, RefSpec refSpec, RevisionData[] revisionsData)
       throws MissingParentObjectException, IOException {
     try (Repository git = gitManager.openRepository(name)) {
 
-      ObjectId newObjectID = null;
+      ObjectId refHead = null;
+      RefUpdate ru = git.updateRef(refSpec.getSource());
       try (ObjectInserter oi = git.newObjectInserter()) {
-        RevisionObjectData commitObject = revisionData.getCommitObject();
-        RevCommit commit = RevCommit.parse(commitObject.getContent());
-        for (RevCommit parent : commit.getParents()) {
-          if (!git.getObjectDatabase().has(parent.getId())) {
-            throw new MissingParentObjectException(name, refSpec.getSource(), parent.getId());
+        for (RevisionData revisionData : revisionsData) {
+
+          ObjectId newObjectID = null;
+          RevisionObjectData commitObject = revisionData.getCommitObject();
+
+          if (commitObject != null) {
+            RevCommit commit = RevCommit.parse(commitObject.getContent());
+            for (RevCommit parent : commit.getParents()) {
+              if (!git.getObjectDatabase().has(parent.getId())) {
+                throw new MissingParentObjectException(name, refSpec.getSource(), parent.getId());
+              }
+            }
+            refHead = newObjectID = oi.insert(commitObject.getType(), commitObject.getContent());
+
+            RevisionObjectData treeObject = revisionData.getTreeObject();
+            oi.insert(treeObject.getType(), treeObject.getContent());
+          }
+
+          for (RevisionObjectData rev : revisionData.getBlobs()) {
+            ObjectId blobObjectId = oi.insert(rev.getType(), rev.getContent());
+            if (newObjectID == null) {
+              newObjectID = blobObjectId;
+            }
+            refHead = newObjectID;
+          }
+
+          oi.flush();
+
+          if (commitObject == null) {
+            // Non-commits must be forced as they do not have a graph associated
+            ru.setForceUpdate(true);
           }
         }
-        newObjectID = oi.insert(commitObject.getType(), commitObject.getContent());
 
-        RevisionObjectData treeObject = revisionData.getTreeObject();
-        oi.insert(treeObject.getType(), treeObject.getContent());
-
-        for (RevisionObjectData rev : revisionData.getBlobs()) {
-          oi.insert(rev.getType(), rev.getContent());
-        }
-
-        oi.flush();
+        ru.setNewObjectId(refHead);
+        RefUpdate.Result result = ru.update();
+        return new RefUpdateState(refSpec.getSource(), result);
       }
-      RefUpdate ru = git.updateRef(refSpec.getSource());
-      ru.setNewObjectId(newObjectID);
-      RefUpdate.Result result = ru.update();
-
-      return new RefUpdateState(refSpec.getSource(), result);
     }
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/filter/ExcludedRefsFilter.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/filter/ExcludedRefsFilter.java
index 63755b4..4847446 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/filter/ExcludedRefsFilter.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/filter/ExcludedRefsFilter.java
@@ -25,16 +25,7 @@
 @Singleton
 public class ExcludedRefsFilter extends RefsFilter {
   public static String[] DEFAULT_REPLICATION_EXCLUDE_REFS =
-      new String[] {
-        RefNames.REFS_USERS + "*",
-        RefNames.REFS_CONFIG,
-        RefNames.REFS_SEQUENCES + "*",
-        RefNames.REFS_EXTERNAL_IDS,
-        RefNames.REFS_GROUPS + "*",
-        RefNames.REFS_GROUPNAMES,
-        RefNames.REFS_CACHE_AUTOMERGE + "*",
-        RefNames.REFS_STARRED_CHANGES + "*"
-      };
+      new String[] {RefNames.REFS_CACHE_AUTOMERGE + "*", RefNames.REFS_STARRED_CHANGES + "*"};
 
   @Inject
   public ExcludedRefsFilter(ReplicationConfig replicationConfig) {
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationAsyncIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationAsyncIT.java
new file mode 100644
index 0000000..58be58e
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationAsyncIT.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2022 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.pull;
+
+import com.google.gerrit.acceptance.SkipProjectClone;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+
+@SkipProjectClone
+@UseLocalDisk
+@TestPlugin(
+    name = "pull-replication",
+    sysModule = "com.googlesource.gerrit.plugins.replication.pull.PullReplicationModule",
+    httpModule = "com.googlesource.gerrit.plugins.replication.pull.api.HttpModule")
+public class PullReplicationAsyncIT extends PullReplicationIT {
+  @Inject private SitePaths sitePaths;
+
+  @Override
+  public void setUpTestPlugin() throws Exception {
+    FileBasedConfig config =
+        new FileBasedConfig(sitePaths.etc_dir.resolve("replication.config").toFile(), FS.DETECTED);
+    config.setString("replication", null, "syncRefs", "^$");
+    config.save();
+
+    super.setUpTestPlugin(true);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationIT.java
index 13b3460..187af3d 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationIT.java
@@ -51,6 +51,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.googlesource.gerrit.plugins.replication.AutoReloadConfigDecorator;
+import java.io.File;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.time.Duration;
@@ -79,11 +80,12 @@
 @UseLocalDisk
 @TestPlugin(
     name = "pull-replication",
-    sysModule = "com.googlesource.gerrit.plugins.replication.pull.PullReplicationModule")
+    sysModule = "com.googlesource.gerrit.plugins.replication.pull.PullReplicationModule",
+    httpModule = "com.googlesource.gerrit.plugins.replication.pull.api.HttpModule")
 public class PullReplicationIT extends LightweightPluginDaemonTest {
   private static final Optional<String> ALL_PROJECTS = Optional.empty();
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-  private static final int TEST_REPLICATION_DELAY = 60;
+  private static final int TEST_REPLICATION_DELAY = 1;
   private static final Duration TEST_TIMEOUT = Duration.ofSeconds(TEST_REPLICATION_DELAY * 2000);
   private static final String TEST_REPLICATION_SUFFIX = "suffix1";
   private static final String TEST_REPLICATION_REMOTE = "remote1";
@@ -97,10 +99,17 @@
 
   @Override
   public void setUpTestPlugin() throws Exception {
+    setUpTestPlugin(false);
+  }
+
+  protected void setUpTestPlugin(boolean loadExisting) throws Exception {
     gitPath = sitePaths.site_path.resolve("git");
 
-    config =
-        new FileBasedConfig(sitePaths.etc_dir.resolve("replication.config").toFile(), FS.DETECTED);
+    File configFile = sitePaths.etc_dir.resolve("replication.config").toFile();
+    config = new FileBasedConfig(configFile, FS.DETECTED);
+    if (loadExisting && configFile.exists()) {
+      config.load();
+    }
     setReplicationSource(
         TEST_REPLICATION_REMOTE,
         TEST_REPLICATION_SUFFIX,
@@ -388,6 +397,35 @@
         });
   }
 
+  @Test
+  @GerritConfig(name = "gerrit.instanceId", value = TEST_REPLICATION_REMOTE)
+  @GerritConfig(name = "container.replica", value = "true")
+  public void shouldReplicateNewChangeRefToReplica() throws Exception {
+    testRepo = cloneProject(createTestProject(project + TEST_REPLICATION_SUFFIX));
+
+    Result pushResult = createChange();
+    RevCommit sourceCommit = pushResult.getCommit();
+    String sourceRef = pushResult.getPatchSet().refName();
+
+    ReplicationQueue pullReplicationQueue = getInstance(ReplicationQueue.class);
+    GitReferenceUpdatedListener.Event event =
+        new FakeGitReferenceUpdatedEvent(
+            project,
+            sourceRef,
+            ObjectId.zeroId().getName(),
+            sourceCommit.getId().getName(),
+            ReceiveCommand.Type.CREATE);
+    pullReplicationQueue.onGitReferenceUpdated(event);
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      waitUntil(() -> checkedGetRef(repo, sourceRef) != null);
+
+      Ref targetBranchRef = getRef(repo, sourceRef);
+      assertThat(targetBranchRef).isNotNull();
+      assertThat(targetBranchRef.getObjectId()).isEqualTo(sourceCommit.getId());
+    }
+  }
+
   private Ref getRef(Repository repo, String branchName) throws IOException {
     return repo.getRefDatabase().exactRef(branchName);
   }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueueTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueueTest.java
index 4e831bc..0743cb9 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueueTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueueTest.java
@@ -17,9 +17,11 @@
 import static com.google.common.truth.Truth.assertThat;
 import static java.nio.file.Files.createTempDirectory;
 import static org.mockito.ArgumentMatchers.anyBoolean;
-import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyInt;
 import static org.mockito.Mockito.anyString;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.lenient;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
@@ -35,6 +37,7 @@
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener.Event;
 import com.google.gerrit.extensions.events.ProjectDeletedListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.events.EventDispatcher;
 import com.google.gerrit.server.git.WorkQueue;
@@ -49,6 +52,8 @@
 import com.googlesource.gerrit.plugins.replication.pull.filter.ExcludedRefsFilter;
 import java.io.IOException;
 import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
 import java.util.Optional;
 import org.apache.http.client.ClientProtocolException;
 import org.eclipse.jgit.errors.LargeObjectException;
@@ -78,10 +83,17 @@
   @Mock AccountInfo accountInfo;
   @Mock RevisionReader revReader;
   @Mock RevisionData revisionData;
+  @Mock HttpResult successfulHttpResult;
+  @Mock HttpResult fetchHttpResult;
+  @Mock RevisionData revisionDataWithParents;
+  List<ObjectId> revisionDataParentObjectIds;
   @Mock HttpResult httpResult;
+  ApplyObjectMetrics applyObjectMetrics;
+  FetchReplicationMetrics fetchMetrics;
 
   @Captor ArgumentCaptor<String> stringCaptor;
   @Captor ArgumentCaptor<Project.NameKey> projectNameKeyCaptor;
+  @Captor ArgumentCaptor<List<RevisionData>> revisionsDataCaptor;
 
   private ExcludedRefsFilter refsFilter;
   private ReplicationQueue objectUnderTest;
@@ -102,16 +114,50 @@
     when(source.getApis()).thenReturn(apis);
     when(sourceCollection.getAll()).thenReturn(Lists.newArrayList(source));
     when(rd.get()).thenReturn(sourceCollection);
-    when(revReader.read(any(), any(), anyString())).thenReturn(Optional.of(revisionData));
+    lenient()
+        .when(revReader.read(any(), any(), anyString(), eq(0)))
+        .thenReturn(Optional.of(revisionData));
+    lenient().when(revReader.read(any(), anyString(), eq(0))).thenReturn(Optional.of(revisionData));
+    lenient()
+        .when(revReader.read(any(), any(), anyString(), eq(Integer.MAX_VALUE)))
+        .thenReturn(Optional.of(revisionDataWithParents));
+    lenient()
+        .when(revReader.read(any(), anyString(), eq(Integer.MAX_VALUE)))
+        .thenReturn(Optional.of(revisionDataWithParents));
+    revisionDataParentObjectIds =
+        Arrays.asList(
+            ObjectId.fromString("9f8d52853089a3cf00c02ff7bd0817bd4353a95a"),
+            ObjectId.fromString("b5d7bcf1d1c5b0f0726d10a16c8315f06f900bfb"));
+    when(revisionDataWithParents.getParentObjetIds()).thenReturn(revisionDataParentObjectIds);
+
     when(fetchClientFactory.create(any())).thenReturn(fetchRestApiClient);
-    when(fetchRestApiClient.callSendObject(any(), anyString(), anyBoolean(), any(), any()))
+    lenient()
+        .when(fetchRestApiClient.callSendObject(any(), anyString(), anyBoolean(), any(), any()))
         .thenReturn(httpResult);
-    when(fetchRestApiClient.callFetch(any(), anyString(), any())).thenReturn(httpResult);
+    lenient()
+        .when(fetchRestApiClient.callSendObjects(any(), anyString(), any(), any()))
+        .thenReturn(httpResult);
+    when(fetchRestApiClient.callFetch(any(), anyString(), any())).thenReturn(fetchHttpResult);
+    when(fetchRestApiClient.initProject(any(), any())).thenReturn(successfulHttpResult);
+    when(successfulHttpResult.isSuccessful()).thenReturn(true);
     when(httpResult.isSuccessful()).thenReturn(true);
+    when(fetchHttpResult.isSuccessful()).thenReturn(true);
     when(httpResult.isProjectMissing(any())).thenReturn(false);
 
+    applyObjectMetrics = new ApplyObjectMetrics("pull-replication", new DisabledMetricMaker());
+    fetchMetrics = new FetchReplicationMetrics("pull-replication", new DisabledMetricMaker());
+
     objectUnderTest =
-        new ReplicationQueue(wq, rd, dis, sl, fetchClientFactory, refsFilter, revReader);
+        new ReplicationQueue(
+            wq,
+            rd,
+            dis,
+            sl,
+            fetchClientFactory,
+            refsFilter,
+            () -> revReader,
+            applyObjectMetrics,
+            fetchMetrics);
   }
 
   @Test
@@ -120,7 +166,7 @@
     objectUnderTest.start();
     objectUnderTest.onGitReferenceUpdated(event);
 
-    verify(fetchRestApiClient).callSendObject(any(), anyString(), eq(false), any(), any());
+    verify(fetchRestApiClient).callSendObjects(any(), anyString(), any(), any());
   }
 
   @Test
@@ -155,7 +201,7 @@
     objectUnderTest.start();
     objectUnderTest.onGitReferenceUpdated(event);
 
-    verify(fetchRestApiClient).callSendObject(any(), anyString(), eq(false), any(), any());
+    verify(fetchRestApiClient).callSendObjects(any(), anyString(), any(), any());
   }
 
   @Test
@@ -164,7 +210,7 @@
     Event event = new TestEvent("refs/changes/01/1/meta");
     objectUnderTest.start();
 
-    when(revReader.read(any(), any(), anyString())).thenThrow(IOException.class);
+    when(revReader.read(any(), any(), anyString(), anyInt())).thenThrow(IOException.class);
 
     objectUnderTest.onGitReferenceUpdated(event);
 
@@ -177,7 +223,7 @@
     Event event = new TestEvent("refs/changes/01/1/1");
     objectUnderTest.start();
 
-    when(revReader.read(any(), any(), anyString())).thenReturn(Optional.empty());
+    when(revReader.read(any(), any(), anyString(), anyInt())).thenReturn(Optional.empty());
 
     objectUnderTest.onGitReferenceUpdated(event);
 
@@ -187,12 +233,12 @@
   @Test
   public void shouldFallbackToCallFetchWhenParentObjectIsMissing()
       throws ClientProtocolException, IOException {
-    Event event = new TestEvent("refs/changes/01/1/meta");
+    Event event = new TestEvent("refs/changes/01/1/1");
     objectUnderTest.start();
 
     when(httpResult.isSuccessful()).thenReturn(false);
     when(httpResult.isParentObjectMissing()).thenReturn(true);
-    when(fetchRestApiClient.callSendObject(any(), anyString(), eq(false), any(), any()))
+    when(fetchRestApiClient.callSendObjects(any(), anyString(), any(), any()))
         .thenReturn(httpResult);
 
     objectUnderTest.onGitReferenceUpdated(event);
@@ -201,35 +247,29 @@
   }
 
   @Test
-  public void shouldSkipEventWhenUsersRef() {
-    Event event = new TestEvent("refs/users/00/1000000");
+  public void shouldFallbackToApplyAllParentObjectsWhenParentObjectIsMissingOnMetaRef()
+      throws ClientProtocolException, IOException {
+    Event event = new TestEvent("refs/changes/01/1/meta");
+    objectUnderTest.start();
+
+    when(httpResult.isSuccessful()).thenReturn(false, true);
+    when(httpResult.isParentObjectMissing()).thenReturn(true, false);
+    when(fetchRestApiClient.callSendObjects(any(), anyString(), any(), any()))
+        .thenReturn(httpResult);
+
     objectUnderTest.onGitReferenceUpdated(event);
 
-    verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
-  }
+    verify(fetchRestApiClient, times(2))
+        .callSendObjects(any(), anyString(), revisionsDataCaptor.capture(), any());
+    List<List<RevisionData>> revisionsDataValues = revisionsDataCaptor.getAllValues();
+    assertThat(revisionsDataValues).hasSize(2);
 
-  @Test
-  public void shouldSkipEventWhenGroupsRef() {
-    Event event = new TestEvent("refs/groups/a1/a16d5b33cc789d60b682c654f03f9cc2feb12975");
-    objectUnderTest.onGitReferenceUpdated(event);
+    List<RevisionData> firstRevisionsValues = revisionsDataValues.get(0);
+    assertThat(firstRevisionsValues).hasSize(1);
+    assertThat(firstRevisionsValues).contains(revisionData);
 
-    verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
-  }
-
-  @Test
-  public void shouldSkipEventWhenGroupNamesRef() {
-    Event event = new TestEvent("refs/meta/group-names");
-    objectUnderTest.onGitReferenceUpdated(event);
-
-    verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
-  }
-
-  @Test
-  public void shouldSkipEventWhenMultiSequenceRef() {
-    Event event = new TestEvent("refs/sequences/changes");
-    objectUnderTest.onGitReferenceUpdated(event);
-
-    verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
+    List<RevisionData> secondRevisionsValues = revisionsDataValues.get(1);
+    assertThat(secondRevisionsValues).hasSize(1 + revisionDataParentObjectIds.size());
   }
 
   @Test
@@ -242,7 +282,16 @@
     refsFilter = new ExcludedRefsFilter(replicationConfig);
 
     objectUnderTest =
-        new ReplicationQueue(wq, rd, dis, sl, fetchClientFactory, refsFilter, revReader);
+        new ReplicationQueue(
+            wq,
+            rd,
+            dis,
+            sl,
+            fetchClientFactory,
+            refsFilter,
+            () -> revReader,
+            applyObjectMetrics,
+            fetchMetrics);
     Event event = new TestEvent("refs/multi-site/version");
     objectUnderTest.onGitReferenceUpdated(event);
 
@@ -258,22 +307,6 @@
   }
 
   @Test
-  public void shouldSkipEventWhenConfigRef() {
-    Event event = new TestEvent("refs/meta/config");
-    objectUnderTest.onGitReferenceUpdated(event);
-
-    verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
-  }
-
-  @Test
-  public void shouldSkipEventWhenExternalIdsRef() {
-    Event event = new TestEvent("refs/meta/external-ids");
-    objectUnderTest.onGitReferenceUpdated(event);
-
-    verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
-  }
-
-  @Test
   public void shouldCallDeleteWhenReplicateProjectDeletionsTrue() throws IOException {
     when(source.wouldDeleteProject(any())).thenReturn(true);
 
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReaderIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReaderIT.java
index e300613..1d8520f 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReaderIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReaderIT.java
@@ -24,12 +24,14 @@
 import com.google.gerrit.acceptance.TestPlugin;
 import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Change.Id;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Scopes;
 import com.googlesource.gerrit.plugins.replication.ReplicationConfig;
@@ -38,11 +40,13 @@
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionObjectData;
 import com.googlesource.gerrit.plugins.replication.pull.fetch.ApplyObject;
 import java.io.IOException;
+import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -53,9 +57,12 @@
 public class RevisionReaderIT extends LightweightPluginDaemonTest {
   RevisionReader objectUnderTest;
 
+  ReplicationFileBasedConfig replicationConfig;
+
   @Before
   public void setup() {
     objectUnderTest = plugin.getSysInjector().getInstance(RevisionReader.class);
+    replicationConfig = plugin.getSysInjector().getInstance(ReplicationFileBasedConfig.class);
   }
 
   @Test
@@ -64,7 +71,7 @@
     String refName = RefNames.changeMetaRef(pushResult.getChange().getId());
 
     Optional<RevisionData> revisionDataOption =
-        refObjectId(refName).flatMap(objId -> readRevisionFromObjectUnderTest(refName, objId));
+        refObjectId(refName).flatMap(objId -> readRevisionFromObjectUnderTest(refName, objId, 0));
 
     assertThat(revisionDataOption.isPresent()).isTrue();
     RevisionData revisionData = revisionDataOption.get();
@@ -80,9 +87,65 @@
     assertThat(revisionData.getBlobs()).isEmpty();
   }
 
-  private Optional<RevisionData> readRevisionFromObjectUnderTest(String refName, ObjectId objId) {
+  @Test
+  public void shouldReadRefMetaObjectWithMaxNumberOfParents() throws Exception {
+    int numberOfParents = 3;
+    setReplicationConfig(numberOfParents);
+    Result pushResult = createChange();
+    Id changeId = pushResult.getChange().getId();
+    String refName = RefNames.changeMetaRef(pushResult.getChange().getId());
+
+    addMultipleComments(numberOfParents, changeId);
+
+    Optional<RevisionData> revisionDataOption =
+        refObjectId(refName)
+            .flatMap(objId -> readRevisionFromObjectUnderTest(refName, objId, numberOfParents));
+
+    assertThat(revisionDataOption.isPresent()).isTrue();
+    List<ObjectId> parentObjectIds = revisionDataOption.get().getParentObjetIds();
+    assertThat(parentObjectIds).hasSize(numberOfParents);
+  }
+
+  @Test
+  public void shouldReadRefMetaObjectLimitedToMaxNumberOfParents() throws Exception {
+    int numberOfParents = 3;
+    setReplicationConfig(numberOfParents);
+    Result pushResult = createChange();
+    Id changeId = pushResult.getChange().getId();
+    String refName = RefNames.changeMetaRef(pushResult.getChange().getId());
+
+    addMultipleComments(numberOfParents + 1, changeId);
+
+    Optional<RevisionData> revisionDataOption =
+        refObjectId(refName)
+            .flatMap(objId -> readRevisionFromObjectUnderTest(refName, objId, numberOfParents));
+
+    assertThat(revisionDataOption.isPresent()).isTrue();
+    List<ObjectId> parentObjectIds = revisionDataOption.get().getParentObjetIds();
+    assertThat(parentObjectIds).hasSize(numberOfParents);
+  }
+
+  private void addMultipleComments(int numberOfParents, Id changeId) throws RestApiException {
+    for (int i = 0; i < numberOfParents; i++) {
+      addComment(changeId);
+    }
+  }
+
+  private void setReplicationConfig(int numberOfParents) throws IOException {
+    FileBasedConfig config = (FileBasedConfig) replicationConfig.getConfig();
+    config.setInt(
+        "replication", null, RevisionReader.CONFIG_MAX_API_HISTORY_DEPTH, numberOfParents);
+    config.save();
+  }
+
+  private void addComment(Id changeId) throws RestApiException {
+    gApi.changes().id(changeId.get()).current().review(new ReviewInput().message("foo"));
+  }
+
+  private Optional<RevisionData> readRevisionFromObjectUnderTest(
+      String refName, ObjectId objId, int maxParentsDepth) {
     try {
-      return objectUnderTest.read(project, objId, refName);
+      return objectUnderTest.read(project, objId, refName, maxParentsDepth);
     } catch (Exception e) {
       throw new IllegalStateException(e);
     }
@@ -107,7 +170,7 @@
     gApi.changes().id(changeId.get()).current().review(reviewInput);
 
     Optional<RevisionData> revisionDataOption =
-        refObjectId(refName).flatMap(objId -> readRevisionFromObjectUnderTest(refName, objId));
+        refObjectId(refName).flatMap(objId -> readRevisionFromObjectUnderTest(refName, objId, 0));
 
     assertThat(revisionDataOption.isPresent()).isTrue();
     RevisionData revisionData = revisionDataOption.get();
@@ -131,7 +194,7 @@
     createChange().assertOkStatus();
     String refName = RefNames.REFS_SEQUENCES + Sequences.NAME_CHANGES;
     Optional<RevisionData> revisionDataOption =
-        refObjectId(refName).flatMap(objId -> readRevisionFromObjectUnderTest(refName, objId));
+        refObjectId(refName).flatMap(objId -> readRevisionFromObjectUnderTest(refName, objId, 0));
 
     Truth8.assertThat(revisionDataOption).isEmpty();
   }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ActionITBase.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ActionITBase.java
index e6f788a..20c1fea 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ActionITBase.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ActionITBase.java
@@ -42,18 +42,18 @@
 import java.util.Base64;
 import java.util.List;
 import java.util.Optional;
+import org.apache.http.HttpHeaders;
 import org.apache.http.HttpResponse;
-import org.apache.http.auth.AuthScope;
+import org.apache.http.auth.AuthenticationException;
 import org.apache.http.auth.UsernamePasswordCredentials;
 import org.apache.http.client.ClientProtocolException;
-import org.apache.http.client.CredentialsProvider;
 import org.apache.http.client.ResponseHandler;
 import org.apache.http.client.methods.HttpDelete;
 import org.apache.http.client.methods.HttpPost;
 import org.apache.http.client.methods.HttpPut;
-import org.apache.http.client.protocol.HttpClientContext;
+import org.apache.http.client.methods.HttpRequestBase;
 import org.apache.http.entity.StringEntity;
-import org.apache.http.impl.client.BasicCredentialsProvider;
+import org.apache.http.impl.auth.BasicScheme;
 import org.apache.http.message.BasicHeader;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
@@ -83,7 +83,11 @@
   SourceHttpClient.Factory httpClientFactory;
   String url;
 
-  protected abstract String getURL(String projectName);
+  protected abstract String getURLWithAuthenticationPrefix(String projectName);
+
+  protected String getURLWithoutAuthenticationPrefix(String projectName) {
+    return getURLWithAuthenticationPrefix(projectName).replace("a/", "");
+  }
 
   @Override
   public void setUpTestPlugin() throws Exception {
@@ -109,7 +113,7 @@
     revisionReader = plugin.getSysInjector().getInstance(RevisionReader.class);
     source = plugin.getSysInjector().getInstance(SourcesCollection.class).getAll().get(0);
 
-    url = getURL(project.get());
+    url = getURLWithAuthenticationPrefix(project.get());
   }
 
   protected HttpPost createRequest(String sendObjectPayload) {
@@ -149,7 +153,8 @@
   protected Optional<RevisionData> createRevisionData(NameKey projectName, String refName)
       throws Exception {
     try (Repository repository = repoManager.openRepository(projectName)) {
-      return revisionReader.read(projectName, repository.exactRef(refName).getObjectId(), refName);
+      return revisionReader.read(
+          projectName, repository.exactRef(refName).getObjectId(), refName, 0);
     }
   }
 
@@ -169,17 +174,28 @@
     };
   }
 
-  protected HttpClientContext getContext() {
-    return getContextForAccount(admin);
+  protected HttpRequestBase withBearerTokenAuthentication(
+      HttpRequestBase httpRequest, String bearerToken) {
+    httpRequest.addHeader(new BasicHeader(HttpHeaders.AUTHORIZATION, "Bearer " + bearerToken));
+    return httpRequest;
   }
 
-  protected HttpClientContext getUserContext() {
-    return getContextForAccount(user);
+  protected HttpRequestBase withBasicAuthenticationAsAdmin(HttpRequestBase httpRequest)
+      throws AuthenticationException {
+    return withBasicAuthentication(httpRequest, admin);
   }
 
-  protected HttpClientContext getAnonymousContext() {
-    HttpClientContext ctx = HttpClientContext.create();
-    return ctx;
+  protected HttpRequestBase withBasicAuthenticationAsUser(HttpRequestBase httpRequest)
+      throws AuthenticationException {
+    return withBasicAuthentication(httpRequest, user);
+  }
+
+  private HttpRequestBase withBasicAuthentication(HttpRequestBase httpRequest, TestAccount account)
+      throws AuthenticationException {
+    UsernamePasswordCredentials creds =
+        new UsernamePasswordCredentials(account.username(), account.httpPassword());
+    httpRequest.addHeader(new BasicScheme().authenticate(creds, httpRequest, null));
+    return httpRequest;
   }
 
   private Project.NameKey createTestProject(String name) throws Exception {
@@ -215,13 +231,4 @@
     secureConfig.setString("remote", remoteName, "password", password);
     secureConfig.save();
   }
-
-  private HttpClientContext getContextForAccount(TestAccount account) {
-    HttpClientContext ctx = HttpClientContext.create();
-    CredentialsProvider adapted = new BasicCredentialsProvider();
-    adapted.setCredentials(
-        AuthScope.ANY, new UsernamePasswordCredentials(account.username(), account.httpPassword()));
-    ctx.setCredentialsProvider(adapted);
-    return ctx;
-  }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionIT.java
index aad3903..2ab5caf 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionIT.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.extensions.restapi.Url;
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData;
 import java.util.Optional;
-import org.apache.http.client.methods.HttpPost;
 import org.junit.Test;
 
 public class ApplyObjectActionIT extends ActionITBase {
@@ -41,8 +40,11 @@
     RevisionData revisionData = revisionDataOption.get();
     String sendObjectPayload = createPayload(payloadWithAsyncFieldTemplate, refName, revisionData);
 
-    HttpPost post = createRequest(sendObjectPayload);
-    httpClientFactory.create(source).execute(post, assertHttpResponseCode(201), getContext());
+    httpClientFactory
+        .create(source)
+        .execute(
+            withBasicAuthenticationAsAdmin(createRequest(sendObjectPayload)),
+            assertHttpResponseCode(201));
   }
 
   @Test
@@ -60,8 +62,11 @@
     String sendObjectPayload =
         createPayload(payloadWithoutAsyncFieldTemplate, refName, revisionData);
 
-    HttpPost post = createRequest(sendObjectPayload);
-    httpClientFactory.create(source).execute(post, assertHttpResponseCode(201), getContext());
+    httpClientFactory
+        .create(source)
+        .execute(
+            withBasicAuthenticationAsAdmin(createRequest(sendObjectPayload)),
+            assertHttpResponseCode(201));
   }
 
   @Test
@@ -80,8 +85,11 @@
     String sendObjectPayload =
         createPayload(payloadWithoutAsyncFieldTemplate, refName, revisionData);
 
-    HttpPost post = createRequest(sendObjectPayload);
-    httpClientFactory.create(source).execute(post, assertHttpResponseCode(201), getContext());
+    httpClientFactory
+        .create(source)
+        .execute(
+            withBasicAuthenticationAsAdmin(createRequest(sendObjectPayload)),
+            assertHttpResponseCode(201));
   }
 
   @Test
@@ -103,13 +111,16 @@
         String.format(
             "%s/a/projects/%s/pull-replication~apply-object",
             adminRestSession.url(), Url.encode(projectName.get()));
-    HttpPost post = createRequest(sendObjectPayload);
-    httpClientFactory.create(source).execute(post, assertHttpResponseCode(201), getContext());
+    httpClientFactory
+        .create(source)
+        .execute(
+            withBasicAuthenticationAsAdmin(createRequest(sendObjectPayload)),
+            assertHttpResponseCode(201));
   }
 
   @Test
   @GerritConfig(name = "container.replica", value = "true")
-  public void shouldReturnUnauthorizedWhenNodeIsAReplicaAndUSerIsAnonymous() throws Exception {
+  public void shouldReturnForbiddenWhenNodeIsAReplicaAndUSerIsAnonymous() throws Exception {
     String payloadWithoutAsyncFieldTemplate =
         "{\"label\":\""
             + TEST_REPLICATION_REMOTE
@@ -123,10 +134,9 @@
     String sendObjectPayload =
         createPayload(payloadWithoutAsyncFieldTemplate, refName, revisionData);
 
-    HttpPost post = createRequest(sendObjectPayload);
     httpClientFactory
         .create(source)
-        .execute(post, assertHttpResponseCode(401), getAnonymousContext());
+        .execute(createRequest(sendObjectPayload), assertHttpResponseCode(403));
   }
 
   @Test
@@ -142,8 +152,11 @@
     String sendObjectPayload =
         createPayload(payloadWithoutLabelFieldTemplate, refName, revisionData);
 
-    HttpPost post = createRequest(sendObjectPayload);
-    httpClientFactory.create(source).execute(post, assertHttpResponseCode(400), getContext());
+    httpClientFactory
+        .create(source)
+        .execute(
+            withBasicAuthenticationAsAdmin(createRequest(sendObjectPayload)),
+            assertHttpResponseCode(400));
   }
 
   @Test
@@ -160,8 +173,61 @@
     RevisionData revisionData = revisionDataOption.get();
     String sendObjectPayload = createPayload(wrongPayloadTemplate, refName, revisionData);
 
-    HttpPost post = createRequest(sendObjectPayload);
-    httpClientFactory.create(source).execute(post, assertHttpResponseCode(400), getContext());
+    httpClientFactory
+        .create(source)
+        .execute(
+            withBasicAuthenticationAsAdmin(createRequest(sendObjectPayload)),
+            assertHttpResponseCode(400));
+  }
+
+  @Test
+  @GerritConfig(name = "container.replica", value = "true")
+  @GerritConfig(name = "auth.bearerToken", value = "some-bearer-token")
+  public void shouldAcceptPayloadWhenNodeIsAReplicaWithBearerToken() throws Exception {
+    url = getURLWithoutAuthenticationPrefix(project.get());
+    String payloadWithoutAsyncFieldTemplate =
+        "{\"label\":\""
+            + TEST_REPLICATION_REMOTE
+            + "\",\"ref_name\":\"%s\",\"revision_data\":{\"commit_object\":{\"type\":1,\"content\":\"%s\"},\"tree_object\":{\"type\":2,\"content\":\"%s\"},\"blobs\":[]}}";
+
+    String refName = createRef();
+    Optional<RevisionData> revisionDataOption = createRevisionData(refName);
+    assertThat(revisionDataOption.isPresent()).isTrue();
+
+    RevisionData revisionData = revisionDataOption.get();
+    String sendObjectPayload =
+        createPayload(payloadWithoutAsyncFieldTemplate, refName, revisionData);
+
+    httpClientFactory
+        .create(source)
+        .execute(
+            withBearerTokenAuthentication(createRequest(sendObjectPayload), "some-bearer-token"),
+            assertHttpResponseCode(201));
+  }
+
+  @Test
+  @GerritConfig(name = "container.replica", value = "false")
+  @GerritConfig(name = "auth.bearerToken", value = "some-bearer-token")
+  public void shouldAcceptPayloadWhenNodeIsAPrimaryWithBearerToken() throws Exception {
+    url = getURLWithoutAuthenticationPrefix(project.get());
+    String payloadWithoutAsyncFieldTemplate =
+        "{\"label\":\""
+            + TEST_REPLICATION_REMOTE
+            + "\",\"ref_name\":\"%s\",\"revision_data\":{\"commit_object\":{\"type\":1,\"content\":\"%s\"},\"tree_object\":{\"type\":2,\"content\":\"%s\"},\"blobs\":[]}}";
+
+    String refName = createRef();
+    Optional<RevisionData> revisionDataOption = createRevisionData(refName);
+    assertThat(revisionDataOption.isPresent()).isTrue();
+
+    RevisionData revisionData = revisionDataOption.get();
+    String sendObjectPayload =
+        createPayload(payloadWithoutAsyncFieldTemplate, refName, revisionData);
+
+    httpClientFactory
+        .create(source)
+        .execute(
+            withBearerTokenAuthentication(createRequest(sendObjectPayload), "some-bearer-token"),
+            assertHttpResponseCode(201));
   }
 
   private String createPayload(
@@ -176,7 +242,7 @@
   }
 
   @Override
-  protected String getURL(String projectName) {
+  protected String getURLWithAuthenticationPrefix(String projectName) {
     return String.format(
         "%s/a/projects/%s/pull-replication~apply-object",
         adminRestSession.url(), Url.encode(projectName));
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionTest.java
index 56e1429..814ba76 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionTest.java
@@ -35,6 +35,9 @@
 import com.googlesource.gerrit.plugins.replication.pull.api.exception.MissingParentObjectException;
 import com.googlesource.gerrit.plugins.replication.pull.api.exception.RefUpdateException;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collections;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Before;
@@ -49,11 +52,18 @@
   String label = "instance-2-label";
   String url = "file:///gerrit-host/instance-1/git/${name}.git";
   String refName = "refs/heads/master";
+  String refMetaName = "refs/meta/version";
   String location = "http://gerrit-host/a/config/server/tasks/08d173e9";
   int taskId = 1234;
 
+  private String sampleCommitObjectId = "9f8d52853089a3cf00c02ff7bd0817bd4353a95a";
+  private String sampleTreeObjectId = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
+  private String sampleBlobObjectId = "b5d7bcf1d1c5b0f0726d10a16c8315f06f900bfb";
+
   private String sampleCommitContent =
-      "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n"
+      "tree "
+          + sampleTreeObjectId
+          + "\n"
           + "parent 20eb48d28be86dc88fb4bef747f08de0fbefe12d\n"
           + "author Gerrit User 1000000 <1000000@69ec38f0-350e-4d9c-96d4-bc956f2faaac> 1610471611 +0100\n"
           + "committer Gerrit Code Review <root@maczech-XPS-15> 1610471611 +0100\n"
@@ -92,6 +102,21 @@
     assertThat(response.statusCode()).isEqualTo(SC_CREATED);
   }
 
+  @Test
+  public void shouldReturnCreatedResponseCodeForBlob() throws RestApiException {
+    byte[] blobData = "foo".getBytes(StandardCharsets.UTF_8);
+    RevisionInput inputParams =
+        new RevisionInput(
+            label,
+            refMetaName,
+            createSampleRevisionDataBlob(
+                new RevisionObjectData(sampleBlobObjectId, Constants.OBJ_BLOB, blobData)));
+
+    Response<?> response = applyObjectAction.apply(projectResource, inputParams);
+
+    assertThat(response.statusCode()).isEqualTo(SC_CREATED);
+  }
+
   @SuppressWarnings("cast")
   @Test
   public void shouldReturnSourceUrlAndrefNameAsAResponseBody() throws Exception {
@@ -131,8 +156,10 @@
 
   @Test(expected = BadRequestException.class)
   public void shouldThrowBadRequestExceptionWhenMissingCommitObjectData() throws Exception {
-    RevisionObjectData commitData = new RevisionObjectData(Constants.OBJ_COMMIT, null);
-    RevisionObjectData treeData = new RevisionObjectData(Constants.OBJ_TREE, new byte[] {});
+    RevisionObjectData commitData =
+        new RevisionObjectData(sampleCommitObjectId, Constants.OBJ_COMMIT, null);
+    RevisionObjectData treeData =
+        new RevisionObjectData(sampleTreeObjectId, Constants.OBJ_TREE, new byte[] {});
     RevisionInput inputParams =
         new RevisionInput(label, refName, createSampleRevisionData(commitData, treeData));
 
@@ -142,7 +169,8 @@
   @Test(expected = BadRequestException.class)
   public void shouldThrowBadRequestExceptionWhenMissingTreeObject() throws Exception {
     RevisionObjectData commitData =
-        new RevisionObjectData(Constants.OBJ_COMMIT, sampleCommitContent.getBytes());
+        new RevisionObjectData(
+            sampleCommitObjectId, Constants.OBJ_COMMIT, sampleCommitContent.getBytes());
     RevisionInput inputParams =
         new RevisionInput(label, refName, createSampleRevisionData(commitData, null));
 
@@ -175,13 +203,19 @@
 
   private RevisionData createSampleRevisionData() {
     RevisionObjectData commitData =
-        new RevisionObjectData(Constants.OBJ_COMMIT, sampleCommitContent.getBytes());
-    RevisionObjectData treeData = new RevisionObjectData(Constants.OBJ_TREE, new byte[] {});
+        new RevisionObjectData(
+            sampleCommitObjectId, Constants.OBJ_COMMIT, sampleCommitContent.getBytes());
+    RevisionObjectData treeData =
+        new RevisionObjectData(sampleTreeObjectId, Constants.OBJ_TREE, new byte[] {});
     return createSampleRevisionData(commitData, treeData);
   }
 
   private RevisionData createSampleRevisionData(
       RevisionObjectData commitData, RevisionObjectData treeData) {
-    return new RevisionData(commitData, treeData, Lists.newArrayList());
+    return new RevisionData(Collections.emptyList(), commitData, treeData, Lists.newArrayList());
+  }
+
+  private RevisionData createSampleRevisionDataBlob(RevisionObjectData blob) {
+    return new RevisionData(Collections.emptyList(), null, null, Arrays.asList(blob));
   }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommandTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommandTest.java
index dfd06f8..9b5ef46 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommandTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommandTest.java
@@ -41,6 +41,7 @@
 import com.googlesource.gerrit.plugins.replication.pull.fetch.RefUpdateState;
 import java.io.IOException;
 import java.net.URISyntaxException;
+import java.util.Collections;
 import java.util.Optional;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.RefUpdate;
@@ -61,6 +62,10 @@
   private static final String TEST_REMOTE_NAME = "test-remote-name";
   private static URIish TEST_REMOTE_URI;
 
+  private String sampleCommitObjectId = "9f8d52853089a3cf00c02ff7bd0817bd4353a95a";
+  private String sampleTreeObjectId = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
+  private String sampleBlobObjectId = "b5d7bcf1d1c5b0f0726d10a16c8315f06f900bfb";
+
   @Mock private PullReplicationStateLogger fetchStateLog;
   @Mock private ApplyObject applyObject;
   @Mock private ApplyObjectMetrics metrics;
@@ -106,8 +111,10 @@
   }
 
   private RevisionData createSampleRevisionData() {
-    RevisionObjectData commitData = new RevisionObjectData(Constants.OBJ_COMMIT, new byte[] {});
-    RevisionObjectData treeData = new RevisionObjectData(Constants.OBJ_TREE, new byte[] {});
-    return new RevisionData(commitData, treeData, Lists.newArrayList());
+    RevisionObjectData commitData =
+        new RevisionObjectData(sampleCommitObjectId, Constants.OBJ_COMMIT, new byte[] {});
+    RevisionObjectData treeData =
+        new RevisionObjectData(sampleTreeObjectId, Constants.OBJ_TREE, new byte[] {});
+    return new RevisionData(Collections.emptyList(), commitData, treeData, Lists.newArrayList());
   }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/BearerAuthenticationFilterTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/BearerAuthenticationFilterTest.java
new file mode 100644
index 0000000..bbbe66f
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/BearerAuthenticationFilterTest.java
@@ -0,0 +1,203 @@
+// Copyright (C) 2022 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.pull.api;
+
+import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.httpd.WebSession;
+import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.PluginUser;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.inject.Provider;
+import java.io.IOException;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class BearerAuthenticationFilterTest {
+
+  @Mock private DynamicItem<WebSession> session;
+  @Mock private WebSession webSession;
+  @Mock private Provider<PluginUser> pluginUserProvider;
+  @Mock private Provider<ThreadLocalRequestContext> threadLocalRequestContextProvider;
+  @Mock private PluginUser pluginUser;
+  @Mock private ThreadLocalRequestContext threadLocalRequestContext;
+  @Mock private HttpServletRequest httpServletRequest;
+  @Mock private HttpServletResponse httpServletResponse;
+  @Mock private FilterChain filterChain;
+  private final String pluginName = "pull-replication";
+
+  private void authenticateWithURI(String uri) throws ServletException, IOException {
+    final String bearerToken = "some-bearer-token";
+    when(httpServletRequest.getRequestURI()).thenReturn(uri);
+    when(httpServletRequest.getHeader("Authorization"))
+        .thenReturn(String.format("Bearer %s", bearerToken));
+    when(pluginUserProvider.get()).thenReturn(pluginUser);
+    when(threadLocalRequestContextProvider.get()).thenReturn(threadLocalRequestContext);
+    when(session.get()).thenReturn(webSession);
+    final BearerAuthenticationFilter filter =
+        new BearerAuthenticationFilter(
+            session,
+            pluginName,
+            pluginUserProvider,
+            threadLocalRequestContextProvider,
+            bearerToken);
+    filter.doFilter(httpServletRequest, httpServletResponse, filterChain);
+
+    verify(httpServletRequest).getRequestURI();
+    verify(httpServletRequest).getHeader("Authorization");
+    verify(pluginUserProvider).get();
+    verify(threadLocalRequestContextProvider).get();
+    verify(session).get();
+    verify(webSession).setAccessPathOk(AccessPath.REST_API, true);
+    verify(filterChain).doFilter(httpServletRequest, httpServletResponse);
+  }
+
+  @Test
+  public void shouldAuthenticateWithBearerTokenWhenFetch() throws ServletException, IOException {
+    authenticateWithURI("any-prefix/pull-replication~fetch");
+  }
+
+  @Test
+  public void shouldAuthenticateWithBearerTokenWhenApplyObject()
+      throws ServletException, IOException {
+    authenticateWithURI("any-prefix/pull-replication~apply-object");
+  }
+
+  @Test
+  public void shouldAuthenticateWithBearerTokenWhenApplyObjects()
+      throws ServletException, IOException {
+    authenticateWithURI("any-prefix/pull-replication~apply-objects");
+  }
+
+  @Test
+  public void shouldAuthenticateWithBearerTokenWhenDeleteProject()
+      throws ServletException, IOException {
+    authenticateWithURI("any-prefix/pull-replication~delete-project");
+  }
+
+  @Test
+  public void shouldAuthenticateWithBearerTokenWhenUpdateHead()
+      throws ServletException, IOException {
+    authenticateWithURI("any-prefix/projects/my-project/HEAD");
+  }
+
+  @Test
+  public void shouldAuthenticateWithBearerTokenWhenInitProject()
+      throws ServletException, IOException {
+    authenticateWithURI("any-prefix/pull-replication/init-project/my-project.git");
+  }
+
+  @Test
+  public void shouldBe401WhenBearerTokenDoesNotMatch() throws ServletException, IOException {
+    when(httpServletRequest.getRequestURI()).thenReturn("any-prefix/pull-replication~fetch");
+    when(httpServletRequest.getHeader("Authorization"))
+        .thenReturn(String.format("Bearer %s", "some-different-bearer-token"));
+
+    final BearerAuthenticationFilter filter =
+        new BearerAuthenticationFilter(
+            session,
+            pluginName,
+            pluginUserProvider,
+            threadLocalRequestContextProvider,
+            "some-bearer-token");
+    filter.doFilter(httpServletRequest, httpServletResponse, filterChain);
+
+    verify(httpServletRequest).getRequestURI();
+    verify(httpServletRequest).getHeader("Authorization");
+    verify(httpServletResponse).sendError(SC_UNAUTHORIZED);
+  }
+
+  @Test
+  public void shouldBe401WhenBearerTokenCannotBeExtracted() throws ServletException, IOException {
+    when(httpServletRequest.getRequestURI()).thenReturn("any-prefix/pull-replication~fetch");
+    when(httpServletRequest.getHeader("Authorization")).thenReturn("bearer token");
+
+    final BearerAuthenticationFilter filter =
+        new BearerAuthenticationFilter(
+            session,
+            pluginName,
+            pluginUserProvider,
+            threadLocalRequestContextProvider,
+            "some-bearer-token");
+    filter.doFilter(httpServletRequest, httpServletResponse, filterChain);
+
+    verify(httpServletRequest).getRequestURI();
+    verify(httpServletRequest).getHeader("Authorization");
+    verify(httpServletResponse).sendError(SC_UNAUTHORIZED);
+  }
+
+  @Test
+  public void shouldBe401WhenNoAuthorizationHeaderInRequest() throws ServletException, IOException {
+    when(httpServletRequest.getRequestURI()).thenReturn("any-prefix/pull-replication~fetch");
+
+    final BearerAuthenticationFilter filter =
+        new BearerAuthenticationFilter(
+            session,
+            pluginName,
+            pluginUserProvider,
+            threadLocalRequestContextProvider,
+            "some-bearer-token");
+    filter.doFilter(httpServletRequest, httpServletResponse, filterChain);
+
+    verify(httpServletRequest).getRequestURI();
+    verify(httpServletResponse).sendError(SC_UNAUTHORIZED);
+  }
+
+  @Test
+  public void shouldGoNextInChainWhenUriDoesNotMatch() throws ServletException, IOException {
+    when(httpServletRequest.getRequestURI()).thenReturn("any-url");
+
+    final BearerAuthenticationFilter filter =
+        new BearerAuthenticationFilter(
+            session,
+            pluginName,
+            pluginUserProvider,
+            threadLocalRequestContextProvider,
+            "some-bearer-token");
+    filter.doFilter(httpServletRequest, httpServletResponse, filterChain);
+
+    verify(httpServletRequest).getRequestURI();
+    verify(filterChain).doFilter(httpServletRequest, httpServletResponse);
+  }
+
+  @Test
+  public void shouldGoNextInChainWhenBasicAuthorizationIsRequired()
+      throws ServletException, IOException {
+    when(httpServletRequest.getRequestURI())
+        .thenReturn("/a/projects/my-project/pull-replication~fetch");
+
+    final BearerAuthenticationFilter filter =
+        new BearerAuthenticationFilter(
+            session,
+            pluginName,
+            pluginUserProvider,
+            threadLocalRequestContextProvider,
+            "some-bearer-token");
+    filter.doFilter(httpServletRequest, httpServletResponse, filterChain);
+
+    verify(httpServletRequest).getRequestURI();
+    verify(filterChain).doFilter(httpServletRequest, httpServletResponse);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/DeleteRefCommandTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/DeleteRefCommandTest.java
index ea9d85f..1574e0c 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/DeleteRefCommandTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/DeleteRefCommandTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyString;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -24,15 +25,24 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.EventDispatcher;
+import com.google.gerrit.server.git.LocalDiskRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend.ForProject;
+import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
+import com.google.gerrit.server.permissions.PermissionBackend.WithUser;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.restapi.project.DeleteRef;
 import com.googlesource.gerrit.plugins.replication.pull.FetchRefReplicatedEvent;
 import com.googlesource.gerrit.plugins.replication.pull.PullReplicationStateLogger;
 import com.googlesource.gerrit.plugins.replication.pull.Source;
 import com.googlesource.gerrit.plugins.replication.pull.SourcesCollection;
-import java.net.URISyntaxException;
+import com.googlesource.gerrit.plugins.replication.pull.fetch.ApplyObject;
 import java.util.Optional;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.URIish;
 import org.junit.Before;
 import org.junit.Test;
@@ -53,25 +63,48 @@
   @Mock private DynamicItem<EventDispatcher> eventDispatcherDataItem;
   @Mock private EventDispatcher eventDispatcher;
   @Mock private ProjectCache projectCache;
-  @Mock private DeleteRef deleteRef;
+  @Mock private ApplyObject applyObject;
   @Mock private ProjectState projectState;
   @Mock private SourcesCollection sourceCollection;
   @Mock private Source source;
+  @Mock private PermissionBackend permissionBackend;
+  @Mock private WithUser currentUser;
+  @Mock private ForProject forProject;
+  @Mock private ForRef forRef;
+  @Mock private LocalDiskRepositoryManager gitManager;
+  @Mock private RefUpdate refUpdate;
+  @Mock private Repository repository;
+  @Mock private Ref currentRef;
+  @Mock private RefDatabase refDb;
   @Captor ArgumentCaptor<Event> eventCaptor;
 
   private DeleteRefCommand objectUnderTest;
 
   @Before
-  public void setup() throws URISyntaxException {
+  public void setup() throws Exception {
     when(eventDispatcherDataItem.get()).thenReturn(eventDispatcher);
     when(projectCache.get(any())).thenReturn(Optional.of(projectState));
     when(sourceCollection.getByRemoteName(TEST_SOURCE_LABEL)).thenReturn(Optional.of(source));
     TEST_REMOTE_URI = new URIish("git://some.remote.uri");
     when(source.getURI(TEST_PROJECT_NAME)).thenReturn(TEST_REMOTE_URI);
+    when(permissionBackend.currentUser()).thenReturn(currentUser);
+    when(currentUser.project(any())).thenReturn(forProject);
+    when(forProject.ref(any())).thenReturn(forRef);
+    when(gitManager.openRepository(any())).thenReturn(repository);
+    when(repository.updateRef(any())).thenReturn(refUpdate);
+    when(repository.getRefDatabase()).thenReturn(refDb);
+    when(refDb.exactRef(anyString())).thenReturn(currentRef);
+    when(refUpdate.delete()).thenReturn(Result.FORCED);
 
     objectUnderTest =
         new DeleteRefCommand(
-            fetchStateLog, projectCache, deleteRef, eventDispatcherDataItem, sourceCollection);
+            fetchStateLog,
+            projectCache,
+            eventDispatcherDataItem,
+            sourceCollection,
+            applyObject,
+            permissionBackend,
+            gitManager);
   }
 
   @Test
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchActionIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchActionIT.java
index 4ad8ce6..cc01c2a 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchActionIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchActionIT.java
@@ -35,7 +35,9 @@
 
     httpClientFactory
         .create(source)
-        .execute(createRequest(sendObjectPayload), assertHttpResponseCode(201), getContext());
+        .execute(
+            withBasicAuthenticationAsAdmin(createRequest(sendObjectPayload)),
+            assertHttpResponseCode(201));
   }
 
   @Test
@@ -55,12 +57,14 @@
             adminRestSession.url(), Url.encode(projectName.get()));
     httpClientFactory
         .create(source)
-        .execute(createRequest(sendObjectPayload), assertHttpResponseCode(201), getContext());
+        .execute(
+            withBasicAuthenticationAsAdmin(createRequest(sendObjectPayload)),
+            assertHttpResponseCode(201));
   }
 
   @Test
   @GerritConfig(name = "container.replica", value = "true")
-  public void shouldReturnUnauthorizedWhenNodeIsAReplicaAndUSerIsAnonymous() throws Exception {
+  public void shouldReturnForbiddenWhenNodeIsAReplicaAndUSerIsAnonymous() throws Exception {
     String refName = createRef();
     String sendObjectPayload =
         "{\"label\":\""
@@ -71,12 +75,51 @@
 
     httpClientFactory
         .create(source)
+        .execute(createRequest(sendObjectPayload), assertHttpResponseCode(403));
+  }
+
+  @Test
+  @GerritConfig(name = "container.replica", value = "true")
+  @GerritConfig(name = "auth.bearerToken", value = "some-bearer-token")
+  public void shouldFetchRefWhenNodeIsAReplicaWithBearerToken() throws Exception {
+    String refName = createRef();
+    url = getURLWithoutAuthenticationPrefix(project.get());
+    String sendObjectPayload =
+        "{\"label\":\""
+            + TEST_REPLICATION_REMOTE
+            + "\", \"ref_name\": \""
+            + refName
+            + "\", \"async\":false}";
+
+    httpClientFactory
+        .create(source)
         .execute(
-            createRequest(sendObjectPayload), assertHttpResponseCode(401), getAnonymousContext());
+            withBearerTokenAuthentication(createRequest(sendObjectPayload), "some-bearer-token"),
+            assertHttpResponseCode(201));
+  }
+
+  @Test
+  @GerritConfig(name = "container.replica", value = "false")
+  @GerritConfig(name = "auth.bearerToken", value = "some-bearer-token")
+  public void shouldFetchRefWhenNodeIsAPrimaryWithBearerToken() throws Exception {
+    String refName = createRef();
+    url = getURLWithoutAuthenticationPrefix(project.get());
+    String sendObjectPayload =
+        "{\"label\":\""
+            + TEST_REPLICATION_REMOTE
+            + "\", \"ref_name\": \""
+            + refName
+            + "\", \"async\":false}";
+
+    httpClientFactory
+        .create(source)
+        .execute(
+            withBearerTokenAuthentication(createRequest(sendObjectPayload), "some-bearer-token"),
+            assertHttpResponseCode(201));
   }
 
   @Override
-  protected String getURL(String projectName) {
+  protected String getURLWithAuthenticationPrefix(String projectName) {
     return String.format(
         "%s/a/projects/%s/pull-replication~fetch", adminRestSession.url(), Url.encode(projectName));
   }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchActionTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchActionTest.java
index fb7f3d1..ce0b9d3 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchActionTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchActionTest.java
@@ -55,6 +55,8 @@
   int taskId = 1234;
 
   @Mock FetchCommand fetchCommand;
+  @Mock FetchJob fetchJob;
+  @Mock FetchJob.Factory fetchJobFactory;
   @Mock ProjectResource projectResource;
   @Mock WorkQueue workQueue;
   @Mock ScheduledExecutorService exceutorService;
@@ -65,6 +67,7 @@
 
   @Before
   public void setup() {
+    when(fetchJobFactory.create(any(), any(), any())).thenReturn(fetchJob);
     when(workQueue.getDefaultQueue()).thenReturn(exceutorService);
     when(urlFormatter.getRestUrl(anyString())).thenReturn(Optional.of(location));
     when(exceutorService.submit(any(Runnable.class)))
@@ -79,7 +82,9 @@
     when(task.getTaskId()).thenReturn(taskId);
     when(preConditions.canCallFetchApi()).thenReturn(true);
 
-    fetchAction = new FetchAction(fetchCommand, workQueue, urlFormatterDynamicItem, preConditions);
+    fetchAction =
+        new FetchAction(
+            fetchCommand, workQueue, urlFormatterDynamicItem, preConditions, fetchJobFactory);
   }
 
   @Test
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchCommandTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchCommandTest.java
index e8d1a0f..c093719 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchCommandTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchCommandTest.java
@@ -56,6 +56,7 @@
   @Mock Source source;
   @Mock SourcesCollection sources;
   @Mock DynamicItem<EventDispatcher> eventDispatcher;
+  @Mock PullReplicationApiRequestMetrics apiRequestMetrics;
 
   @SuppressWarnings("rawtypes")
   @Mock
@@ -75,7 +76,7 @@
 
     when(fetchReplicationStateFactory.create(any())).thenReturn(state);
     when(sources.getByRemoteName(label)).thenReturn(Optional.of(source));
-    when(source.schedule(eq(projectName), eq(REF_NAME_TO_FETCH), eq(state), any()))
+    when(source.schedule(eq(projectName), eq(REF_NAME_TO_FETCH), eq(state), any(), any()))
         .thenReturn(CompletableFuture.completedFuture(null));
     objectUnderTest =
         new FetchCommand(fetchReplicationStateFactory, fetchStateLog, sources, eventDispatcher);
@@ -87,16 +88,18 @@
           TimeoutException {
     objectUnderTest.fetchSync(projectName, label, REF_NAME_TO_FETCH);
 
-    verify(source, times(1)).schedule(projectName, REF_NAME_TO_FETCH, state, SYNC);
+    verify(source, times(1))
+        .schedule(projectName, REF_NAME_TO_FETCH, state, SYNC, Optional.empty());
   }
 
   @Test
   public void shouldScheduleRefFetchWithDelay()
       throws InterruptedException, ExecutionException, RemoteConfigurationMissingException,
           TimeoutException {
-    objectUnderTest.fetchAsync(projectName, label, REF_NAME_TO_FETCH);
+    objectUnderTest.fetchAsync(projectName, label, REF_NAME_TO_FETCH, apiRequestMetrics);
 
-    verify(source, times(1)).schedule(projectName, REF_NAME_TO_FETCH, state, ASYNC);
+    verify(source, times(1))
+        .schedule(projectName, REF_NAME_TO_FETCH, state, ASYNC, Optional.of(apiRequestMetrics));
   }
 
   @Test
@@ -105,7 +108,8 @@
           TimeoutException {
     objectUnderTest.fetchSync(projectName, label, REF_NAME_TO_FETCH);
 
-    verify(source, times(1)).schedule(projectName, REF_NAME_TO_FETCH, state, SYNC);
+    verify(source, times(1))
+        .schedule(projectName, REF_NAME_TO_FETCH, state, SYNC, Optional.empty());
     verify(state, times(1)).markAllFetchTasksScheduled();
   }
 
@@ -122,7 +126,8 @@
   public void shouldUpdateStateWhenInterruptedException()
       throws InterruptedException, ExecutionException, TimeoutException {
     when(future.get(anyLong(), eq(TimeUnit.SECONDS))).thenThrow(new InterruptedException());
-    when(source.schedule(projectName, REF_NAME_TO_FETCH, state, SYNC)).thenReturn(future);
+    when(source.schedule(projectName, REF_NAME_TO_FETCH, state, SYNC, Optional.empty()))
+        .thenReturn(future);
 
     InterruptedException e =
         assertThrows(
@@ -137,7 +142,8 @@
       throws InterruptedException, ExecutionException, TimeoutException {
     when(future.get(anyLong(), eq(TimeUnit.SECONDS)))
         .thenThrow(new ExecutionException(new Exception()));
-    when(source.schedule(projectName, REF_NAME_TO_FETCH, state, SYNC)).thenReturn(future);
+    when(source.schedule(projectName, REF_NAME_TO_FETCH, state, SYNC, Optional.empty()))
+        .thenReturn(future);
 
     ExecutionException e =
         assertThrows(
@@ -151,7 +157,8 @@
   public void shouldUpdateStateWhenTimeoutException()
       throws InterruptedException, ExecutionException, TimeoutException {
     when(future.get(anyLong(), eq(TimeUnit.SECONDS))).thenThrow(new TimeoutException());
-    when(source.schedule(projectName, REF_NAME_TO_FETCH, state, SYNC)).thenReturn(future);
+    when(source.schedule(projectName, REF_NAME_TO_FETCH, state, SYNC, Optional.empty()))
+        .thenReturn(future);
 
     TimeoutException e =
         assertThrows(
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionActionIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionActionIT.java
index f5c8d4c..2953ed0 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionActionIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionActionIT.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
 import javax.servlet.http.HttpServletResponse;
+import org.apache.http.client.methods.HttpRequestBase;
 import org.junit.Test;
 
 public class ProjectDeletionActionIT extends ActionITBase {
@@ -35,21 +36,18 @@
     httpClientFactory
         .create(source)
         .execute(
-            createDeleteRequest(),
-            assertHttpResponseCode(HttpServletResponse.SC_UNAUTHORIZED),
-            getAnonymousContext());
+            createDeleteRequest(), assertHttpResponseCode(HttpServletResponse.SC_UNAUTHORIZED));
   }
 
   @Test
   public void shouldDeleteRepositoryWhenUserHasProjectDeletionCapabilities() throws Exception {
     String testProjectName = project.get();
-    url = getURL(testProjectName);
+    url = getURLWithAuthenticationPrefix(testProjectName);
     httpClientFactory
         .create(source)
         .execute(
-            createDeleteRequest(),
-            assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN),
-            getUserContext());
+            withBasicAuthenticationAsUser(createDeleteRequest()),
+            assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN));
 
     projectOperations
         .project(allProjects)
@@ -60,55 +58,52 @@
     httpClientFactory
         .create(source)
         .execute(
-            createDeleteRequest(),
-            assertHttpResponseCode(HttpServletResponse.SC_OK),
-            getUserContext());
+            withBasicAuthenticationAsUser(createDeleteRequest()),
+            assertHttpResponseCode(HttpServletResponse.SC_OK));
   }
 
   @Test
   public void shouldReturnOKWhenProjectIsDeleted() throws Exception {
     String testProjectName = project.get();
-    url = getURL(testProjectName);
+    url = getURLWithAuthenticationPrefix(testProjectName);
 
     httpClientFactory
         .create(source)
         .execute(
-            createDeleteRequest(), assertHttpResponseCode(HttpServletResponse.SC_OK), getContext());
+            withBasicAuthenticationAsAdmin(createDeleteRequest()),
+            assertHttpResponseCode(HttpServletResponse.SC_OK));
   }
 
   @Test
   public void shouldReturnInternalServerErrorIfProjectCannotBeDeleted() throws Exception {
-    url = getURL(INVALID_TEST_PROJECT_NAME);
+    url = getURLWithAuthenticationPrefix(INVALID_TEST_PROJECT_NAME);
 
     httpClientFactory
         .create(source)
         .execute(
-            createDeleteRequest(),
-            assertHttpResponseCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR),
-            getContext());
+            withBasicAuthenticationAsAdmin(createDeleteRequest()),
+            assertHttpResponseCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR));
   }
 
   @Test
   @GerritConfig(name = "container.replica", value = "true")
-  public void shouldReturnUnauthorizedForUserWithoutPermissionsOnReplica() throws Exception {
+  public void shouldReturnForbiddenForUserWithoutPermissionsOnReplica() throws Exception {
     httpClientFactory
         .create(source)
-        .execute(
-            createDeleteRequest(),
-            assertHttpResponseCode(HttpServletResponse.SC_UNAUTHORIZED),
-            getAnonymousContext());
+        .execute(createDeleteRequest(), assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN));
   }
 
   @Test
   @GerritConfig(name = "container.replica", value = "true")
   public void shouldReturnOKWhenProjectIsDeletedOnReplica() throws Exception {
     String testProjectName = project.get();
-    url = getURL(testProjectName);
+    url = getURLWithAuthenticationPrefix(testProjectName);
 
     httpClientFactory
         .create(source)
         .execute(
-            createDeleteRequest(), assertHttpResponseCode(HttpServletResponse.SC_OK), getContext());
+            withBasicAuthenticationAsAdmin(createDeleteRequest()),
+            assertHttpResponseCode(HttpServletResponse.SC_OK));
   }
 
   @Test
@@ -116,13 +111,12 @@
   public void shouldDeleteRepositoryWhenUserHasProjectDeletionCapabilitiesAndNodeIsAReplica()
       throws Exception {
     String testProjectName = project.get();
-    url = getURL(testProjectName);
+    url = getURLWithAuthenticationPrefix(testProjectName);
+    HttpRequestBase deleteRequest = withBasicAuthenticationAsUser(createDeleteRequest());
+
     httpClientFactory
         .create(source)
-        .execute(
-            createDeleteRequest(),
-            assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN),
-            getUserContext());
+        .execute(deleteRequest, assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN));
 
     projectOperations
         .project(allProjects)
@@ -132,28 +126,52 @@
 
     httpClientFactory
         .create(source)
-        .execute(
-            createDeleteRequest(),
-            assertHttpResponseCode(HttpServletResponse.SC_OK),
-            getUserContext());
+        .execute(deleteRequest, assertHttpResponseCode(HttpServletResponse.SC_OK));
   }
 
   @Test
   @GerritConfig(name = "container.replica", value = "true")
   public void shouldReturnInternalServerErrorIfProjectCannotBeDeletedWhenNodeIsAReplica()
       throws Exception {
-    url = getURL(INVALID_TEST_PROJECT_NAME);
+    url = getURLWithAuthenticationPrefix(INVALID_TEST_PROJECT_NAME);
 
     httpClientFactory
         .create(source)
         .execute(
-            createDeleteRequest(),
-            assertHttpResponseCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR),
-            getContext());
+            withBasicAuthenticationAsAdmin(createDeleteRequest()),
+            assertHttpResponseCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR));
+  }
+
+  @Test
+  @GerritConfig(name = "container.replica", value = "true")
+  @GerritConfig(name = "auth.bearerToken", value = "some-bearer-token")
+  public void shouldReturnOKWhenProjectIsDeletedOnReplicaWithBearerToken() throws Exception {
+    String testProjectName = project.get();
+    url = getURLWithoutAuthenticationPrefix(testProjectName);
+
+    httpClientFactory
+        .create(source)
+        .execute(
+            withBearerTokenAuthentication(createDeleteRequest(), "some-bearer-token"),
+            assertHttpResponseCode(HttpServletResponse.SC_OK));
+  }
+
+  @Test
+  @GerritConfig(name = "container.replica", value = "false")
+  @GerritConfig(name = "auth.bearerToken", value = "some-bearer-token")
+  public void shouldReturnOKWhenProjectIsDeletedOnPrimaryWithBearerToken() throws Exception {
+    String testProjectName = project.get();
+    url = getURLWithoutAuthenticationPrefix(testProjectName);
+
+    httpClientFactory
+        .create(source)
+        .execute(
+            withBearerTokenAuthentication(createDeleteRequest(), "some-bearer-token"),
+            assertHttpResponseCode(HttpServletResponse.SC_OK));
   }
 
   @Override
-  protected String getURL(String projectName) {
+  protected String getURLWithAuthenticationPrefix(String projectName) {
     return String.format(
         "%s/a/projects/%s/pull-replication~delete-project",
         adminRestSession.url(), Url.encode(projectName));
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationActionIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationActionIT.java
index ca4fa31..99b3336 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationActionIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationActionIT.java
@@ -15,7 +15,6 @@
 package com.googlesource.gerrit.plugins.replication.pull.api;
 
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
-import static com.googlesource.gerrit.plugins.replication.pull.api.ProjectInitializationAction.getProjectInitializationUrl;
 
 import com.google.common.net.MediaType;
 import com.google.gerrit.acceptance.config.GerritConfig;
@@ -27,6 +26,7 @@
 import javax.servlet.http.HttpServletResponse;
 import org.apache.http.client.methods.HttpGet;
 import org.apache.http.client.methods.HttpPut;
+import org.apache.http.client.methods.HttpRequestBase;
 import org.apache.http.message.BasicHeader;
 import org.junit.Test;
 
@@ -40,8 +40,7 @@
         .create(source)
         .execute(
             createPutRequestWithHeaders(),
-            assertHttpResponseCode(HttpServletResponse.SC_UNAUTHORIZED),
-            getAnonymousContext());
+            assertHttpResponseCode(HttpServletResponse.SC_UNAUTHORIZED));
   }
 
   @Test
@@ -49,40 +48,37 @@
     httpClientFactory
         .create(source)
         .execute(
-            createPutRequestWithoutHeaders(),
-            assertHttpResponseCode(HttpServletResponse.SC_BAD_REQUEST),
-            getContext());
+            withBasicAuthenticationAsAdmin(createPutRequestWithoutHeaders()),
+            assertHttpResponseCode(HttpServletResponse.SC_BAD_REQUEST));
   }
 
   @Test
   public void shouldCreateRepository() throws Exception {
     String newProjectName = "new/newProjectForPrimary";
-    url = getURL(newProjectName);
+    url = getURLWithAuthenticationPrefix(newProjectName);
     httpClientFactory
         .create(source)
         .execute(
-            createPutRequestWithHeaders(),
-            assertHttpResponseCode(HttpServletResponse.SC_CREATED),
-            getContext());
+            withBasicAuthenticationAsAdmin(createPutRequestWithHeaders()),
+            assertHttpResponseCode(HttpServletResponse.SC_CREATED));
 
-    HttpGet getNewProjectRequest =
-        new HttpGet(userRestSession.url() + "/a/projects/" + Url.encode(newProjectName));
+    HttpRequestBase getNewProjectRequest =
+        withBasicAuthenticationAsAdmin(
+            new HttpGet(userRestSession.url() + "/a/projects/" + Url.encode(newProjectName)));
+
     httpClientFactory
         .create(source)
-        .execute(
-            getNewProjectRequest, assertHttpResponseCode(HttpServletResponse.SC_OK), getContext());
+        .execute(getNewProjectRequest, assertHttpResponseCode(HttpServletResponse.SC_OK));
   }
 
   @Test
   public void shouldCreateRepositoryWhenUserHasProjectCreationCapabilities() throws Exception {
     String newProjectName = "new/newProjectForUserWithCapabilities";
-    url = getURL(newProjectName);
+    url = getURLWithAuthenticationPrefix(newProjectName);
+    HttpRequestBase put = withBasicAuthenticationAsUser(createPutRequestWithHeaders());
     httpClientFactory
         .create(source)
-        .execute(
-            createPutRequestWithHeaders(),
-            assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN),
-            getUserContext());
+        .execute(put, assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN));
 
     projectOperations
         .project(allProjects)
@@ -94,10 +90,7 @@
 
     httpClientFactory
         .create(source)
-        .execute(
-            createPutRequestWithHeaders(),
-            assertHttpResponseCode(HttpServletResponse.SC_CREATED),
-            getUserContext());
+        .execute(put, assertHttpResponseCode(HttpServletResponse.SC_CREATED));
   }
 
   @Test
@@ -105,22 +98,20 @@
     httpClientFactory
         .create(source)
         .execute(
-            createPutRequestWithHeaders(),
-            assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN),
-            getUserContext());
+            withBasicAuthenticationAsUser(createPutRequestWithHeaders()),
+            assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN));
   }
 
   @Test
   @GerritConfig(name = "container.replica", value = "true")
   public void shouldCreateRepositoryWhenNodeIsAReplica() throws Exception {
     String newProjectName = "new/newProjectForReplica";
-    url = getURL(newProjectName);
+    url = getURLWithAuthenticationPrefix(newProjectName);
     httpClientFactory
         .create(source)
         .execute(
-            createPutRequestWithHeaders(),
-            assertHttpResponseCode(HttpServletResponse.SC_CREATED),
-            getContext());
+            withBasicAuthenticationAsAdmin(createPutRequestWithHeaders()),
+            assertHttpResponseCode(HttpServletResponse.SC_CREATED));
   }
 
   @Test
@@ -129,9 +120,8 @@
     httpClientFactory
         .create(source)
         .execute(
-            createPutRequestWithHeaders(),
-            assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN),
-            getUserContext());
+            withBasicAuthenticationAsUser(createPutRequestWithHeaders()),
+            assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN));
   }
 
   @Test
@@ -139,13 +129,11 @@
   public void shouldCreateRepositoryWhenUserHasProjectCreationCapabilitiesAndNodeIsAReplica()
       throws Exception {
     String newProjectName = "new/newProjectForUserWithCapabilitiesReplica";
-    url = getURL(newProjectName);
+    url = getURLWithAuthenticationPrefix(newProjectName);
+    HttpRequestBase put = withBasicAuthenticationAsUser(createPutRequestWithHeaders());
     httpClientFactory
         .create(source)
-        .execute(
-            createPutRequestWithHeaders(),
-            assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN),
-            getUserContext());
+        .execute(put, assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN));
 
     projectOperations
         .project(allProjects)
@@ -157,24 +145,19 @@
 
     httpClientFactory
         .create(source)
-        .execute(
-            createPutRequestWithHeaders(),
-            assertHttpResponseCode(HttpServletResponse.SC_CREATED),
-            getUserContext());
+        .execute(put, assertHttpResponseCode(HttpServletResponse.SC_CREATED));
   }
 
   @Test
   @GerritConfig(name = "container.replica", value = "true")
   public void shouldReturnInternalServerErrorIfProjectCannotBeCreatedWhenNodeIsAReplica()
       throws Exception {
-    url = getURL(INVALID_TEST_PROJECT_NAME);
-
+    url = getURLWithAuthenticationPrefix(INVALID_TEST_PROJECT_NAME);
     httpClientFactory
         .create(source)
         .execute(
-            createPutRequestWithHeaders(),
-            assertHttpResponseCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR),
-            getContext());
+            withBasicAuthenticationAsAdmin(createPutRequestWithHeaders()),
+            assertHttpResponseCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR));
   }
 
   @Test
@@ -183,28 +166,50 @@
     httpClientFactory
         .create(source)
         .execute(
-            createPutRequestWithoutHeaders(),
-            assertHttpResponseCode(HttpServletResponse.SC_BAD_REQUEST),
-            getContext());
+            withBasicAuthenticationAsAdmin(createPutRequestWithoutHeaders()),
+            assertHttpResponseCode(HttpServletResponse.SC_BAD_REQUEST));
   }
 
   @Test
   @GerritConfig(name = "container.replica", value = "true")
-  public void shouldReturnUnauthorizedForUserWithoutPermissionsWhenNodeIsAReplica()
-      throws Exception {
+  public void shouldReturnForbiddenForUserWithoutPermissionsWhenNodeIsAReplica() throws Exception {
     httpClientFactory
         .create(source)
         .execute(
             createPutRequestWithHeaders(),
-            assertHttpResponseCode(HttpServletResponse.SC_UNAUTHORIZED),
-            getAnonymousContext());
+            assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN));
+  }
+
+  @Test
+  @GerritConfig(name = "container.replica", value = "true")
+  @GerritConfig(name = "auth.bearerToken", value = "some-bearer-token")
+  public void shouldCreateRepositoryWhenNodeIsAReplicaWithBearerToken() throws Exception {
+    String newProjectName = "new/newProjectForReplica";
+    url = getURLWithoutAuthenticationPrefix(newProjectName);
+    httpClientFactory
+        .create(source)
+        .execute(
+            withBearerTokenAuthentication(createPutRequestWithHeaders(), "some-bearer-token"),
+            assertHttpResponseCode(HttpServletResponse.SC_CREATED));
+  }
+
+  @Test
+  @GerritConfig(name = "container.replica", value = "false")
+  @GerritConfig(name = "auth.bearerToken", value = "some-bearer-token")
+  public void shouldCreateRepositoryWhenNodeIsAPrimaryWithBearerToken() throws Exception {
+    String newProjectName = "new/newProjectForReplica";
+    url = getURLWithoutAuthenticationPrefix(newProjectName);
+    httpClientFactory
+        .create(source)
+        .execute(
+            withBearerTokenAuthentication(createPutRequestWithHeaders(), "some-bearer-token"),
+            assertHttpResponseCode(HttpServletResponse.SC_CREATED));
   }
 
   @Override
-  protected String getURL(String projectName) {
+  protected String getURLWithAuthenticationPrefix(String projectName) {
     return userRestSession.url()
-        + "/"
-        + getProjectInitializationUrl("pull-replication", Url.encode(projectName));
+        + String.format("/a/plugins/pull-replication/init-project/%s.git", Url.encode(projectName));
   }
 
   protected HttpPut createPutRequestWithHeaders() {
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilterTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilterTest.java
new file mode 100644
index 0000000..2ed1466
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilterTest.java
@@ -0,0 +1,331 @@
+package com.googlesource.gerrit.plugins.replication.pull.api;
+
+import static com.google.common.net.HttpHeaders.ACCEPT;
+import static com.google.gerrit.httpd.restapi.RestApiServlet.SC_UNPROCESSABLE_ENTITY;
+import static javax.servlet.http.HttpServletResponse.SC_CONFLICT;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.internal.verification.VerificationModeFactory.atLeastOnce;
+import static org.mockito.internal.verification.VerificationModeFactory.times;
+
+import com.google.common.net.MediaType;
+import com.google.gerrit.extensions.restapi.*;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.ProjectsCollection;
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletOutputStream;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class PullReplicationFilterTest {
+
+  @Mock HttpServletRequest request;
+  @Mock HttpServletResponse response;
+  @Mock FilterChain filterChain;
+  @Mock private FetchAction fetchAction;
+  @Mock private ApplyObjectAction applyObjectAction;
+  @Mock private ApplyObjectsAction applyObjectsAction;
+  @Mock private ProjectInitializationAction projectInitializationAction;
+  @Mock private UpdateHeadAction updateHEADAction;
+  @Mock private ProjectDeletionAction projectDeletionAction;
+  @Mock private ProjectsCollection projectsCollection;
+  @Mock private ProjectResource projectResource;
+  @Mock private ServletOutputStream outputStream;
+  @Mock private PrintWriter printWriter;
+  private final String PLUGIN_NAME = "pull-replication";
+  private final String PROJECT_NAME = "some-project";
+  private final String PROJECT_NAME_GIT = "some-project.git";
+  private final String FETCH_URI =
+      String.format("any-prefix/projects/%s/%s~fetch", PROJECT_NAME, PLUGIN_NAME);
+  private final String APPLY_OBJECT_URI =
+      String.format("any-prefix/projects/%s/%s~apply-object", PROJECT_NAME, PLUGIN_NAME);
+  private final String APPLY_OBJECTS_URI =
+      String.format("any-prefix/projects/%s/%s~apply-objects", PROJECT_NAME, PLUGIN_NAME);
+  private final String HEAD_URI = String.format("any-prefix/projects/%s/HEAD", PROJECT_NAME);
+  private final String DELETE_PROJECT_URI =
+      String.format("any-prefix/projects/%s/%s~delete-project", PROJECT_NAME, PLUGIN_NAME);
+  private final String INIT_PROJECT_URI =
+      String.format("any-prefix/%s/init-project/%s", PLUGIN_NAME, PROJECT_NAME_GIT);
+
+  private final Response OK_RESPONSE = Response.ok();
+
+  private PullReplicationFilter createPullReplicationFilter() {
+    return new PullReplicationFilter(
+        fetchAction,
+        applyObjectAction,
+        applyObjectsAction,
+        projectInitializationAction,
+        updateHEADAction,
+        projectDeletionAction,
+        projectsCollection,
+        PLUGIN_NAME);
+  }
+
+  private void defineBehaviours(byte[] payload, String uri) throws Exception {
+    when(request.getRequestURI()).thenReturn(uri);
+    InputStream is = new ByteArrayInputStream(payload);
+    BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(is));
+    when(request.getReader()).thenReturn(bufferedReader);
+    when(projectsCollection.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(PROJECT_NAME)))
+        .thenReturn(projectResource);
+    when(response.getWriter()).thenReturn(printWriter);
+  }
+
+  private void verifyBehaviours() throws Exception {
+    verify(request, atLeastOnce()).getRequestURI();
+    verify(request).getReader();
+    verify(projectsCollection).parse(TopLevelResource.INSTANCE, IdString.fromDecoded(PROJECT_NAME));
+    verify(response).getWriter();
+    verify(response).setContentType("application/json");
+    verify(response).setStatus(HttpServletResponse.SC_OK);
+  }
+
+  @Test
+  public void shouldFilterFetchAction() throws Exception {
+    byte[] payloadFetch =
+        ("{"
+                + "\"label\":\"Replication\", "
+                + "\"ref_name\": \"refs/heads/master\", "
+                + "\"async\":false"
+                + "}")
+            .getBytes(StandardCharsets.UTF_8);
+
+    defineBehaviours(payloadFetch, FETCH_URI);
+    when(fetchAction.apply(any(), any())).thenReturn(OK_RESPONSE);
+
+    PullReplicationFilter pullReplicationFilter = createPullReplicationFilter();
+    pullReplicationFilter.doFilter(request, response, filterChain);
+
+    verifyBehaviours();
+    verify(fetchAction).apply(eq(projectResource), any());
+  }
+
+  @Test
+  public void shouldFilterApplyObjectAction() throws Exception {
+
+    byte[] payloadApplyObject =
+        ("{\"label\":\"Replication\",\"ref_name\":\"refs/heads/master\","
+                + "\"revision_data\":{"
+                + "\"commit_object\":{\"type\":1,\"content\":\"some-content\"},"
+                + "\"tree_object\":{\"type\":2,\"content\":\"some-content\"},"
+                + "\"blobs\":[]}"
+                + "}")
+            .getBytes(StandardCharsets.UTF_8);
+
+    defineBehaviours(payloadApplyObject, APPLY_OBJECT_URI);
+
+    when(applyObjectAction.apply(any(), any())).thenReturn(OK_RESPONSE);
+
+    PullReplicationFilter pullReplicationFilter = createPullReplicationFilter();
+    pullReplicationFilter.doFilter(request, response, filterChain);
+
+    verifyBehaviours();
+    verify(applyObjectAction).apply(eq(projectResource), any());
+  }
+
+  @Test
+  public void shouldFilterApplyObjectsAction() throws Exception {
+
+    byte[] payloadApplyObjects =
+        ("{\"label\":\"Replication\",\"ref_name\":\"refs/heads/master\","
+                + "\"revisions_data\":[{"
+                + "\"commit_object\":{\"type\":1,\"content\":\"some-content\"},"
+                + "\"tree_object\":{\"type\":2,\"content\":\"some-content\"},"
+                + "\"blobs\":[]}]}")
+            .getBytes(StandardCharsets.UTF_8);
+
+    defineBehaviours(payloadApplyObjects, APPLY_OBJECTS_URI);
+
+    when(applyObjectsAction.apply(any(), any())).thenReturn(OK_RESPONSE);
+
+    PullReplicationFilter pullReplicationFilter = createPullReplicationFilter();
+    pullReplicationFilter.doFilter(request, response, filterChain);
+
+    verifyBehaviours();
+    verify(applyObjectsAction).apply(eq(projectResource), any());
+  }
+
+  @Test
+  public void shouldFilterProjectInitializationAction() throws Exception {
+
+    when(request.getRequestURI()).thenReturn(INIT_PROJECT_URI);
+    when(request.getHeader(ACCEPT)).thenReturn(MediaType.PLAIN_TEXT_UTF_8.toString());
+    when(projectInitializationAction.initProject(PROJECT_NAME_GIT)).thenReturn(true);
+    when(response.getWriter()).thenReturn(printWriter);
+
+    final PullReplicationFilter pullReplicationFilter = createPullReplicationFilter();
+    pullReplicationFilter.doFilter(request, response, filterChain);
+
+    verify(request, times(5)).getRequestURI();
+    verify(projectInitializationAction).initProject(eq(PROJECT_NAME_GIT));
+    verify(response).getWriter();
+  }
+
+  @Test
+  public void shouldFilterUpdateHEADAction() throws Exception {
+
+    byte[] payloadUpdateHead = "{\"ref\":\"some-ref\"}".getBytes(StandardCharsets.UTF_8);
+    defineBehaviours(payloadUpdateHead, HEAD_URI);
+    when(request.getMethod()).thenReturn("PUT");
+    when(updateHEADAction.apply(any(), any())).thenReturn(OK_RESPONSE);
+
+    final PullReplicationFilter pullReplicationFilter = createPullReplicationFilter();
+    pullReplicationFilter.doFilter(request, response, filterChain);
+
+    verifyBehaviours();
+    verify(updateHEADAction).apply(eq(projectResource), any());
+  }
+
+  @Test
+  public void shouldFilterProjectDeletionAction() throws Exception {
+    when(request.getRequestURI()).thenReturn(DELETE_PROJECT_URI);
+    when(request.getMethod()).thenReturn("DELETE");
+    when(projectsCollection.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(PROJECT_NAME)))
+        .thenReturn(projectResource);
+    when(projectDeletionAction.apply(any(), any())).thenReturn(OK_RESPONSE);
+    when(response.getWriter()).thenReturn(printWriter);
+
+    final PullReplicationFilter pullReplicationFilter = createPullReplicationFilter();
+    pullReplicationFilter.doFilter(request, response, filterChain);
+
+    verify(request, times(7)).getRequestURI();
+    verify(projectsCollection).parse(TopLevelResource.INSTANCE, IdString.fromDecoded(PROJECT_NAME));
+    verify(projectDeletionAction).apply(eq(projectResource), any());
+    verify(response).getWriter();
+    verify(response).setContentType("application/json");
+    verify(response).setStatus(OK_RESPONSE.statusCode());
+  }
+
+  @Test
+  public void shouldGoNextInChainWhenUriDoesNotMatch() throws Exception {
+    when(request.getRequestURI()).thenReturn("any-url");
+    final PullReplicationFilter pullReplicationFilter = createPullReplicationFilter();
+    pullReplicationFilter.doFilter(request, response, filterChain);
+    verify(filterChain).doFilter(request, response);
+  }
+
+  @Test
+  public void shouldBe404WhenJsonIsMalformed() throws Exception {
+    byte[] payloadMalformedJson = "some-json-malformed".getBytes(StandardCharsets.UTF_8);
+    InputStream is = new ByteArrayInputStream(payloadMalformedJson);
+    BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(is));
+    when(request.getRequestURI()).thenReturn(FETCH_URI);
+    when(request.getReader()).thenReturn(bufferedReader);
+    when(response.getOutputStream()).thenReturn(outputStream);
+
+    PullReplicationFilter pullReplicationFilter = createPullReplicationFilter();
+    pullReplicationFilter.doFilter(request, response, filterChain);
+
+    verify(response).setStatus(HttpServletResponse.SC_BAD_REQUEST);
+  }
+
+  @Test
+  public void shouldBe500WhenProjectCannotBeInitiated() throws Exception {
+    when(request.getRequestURI()).thenReturn(INIT_PROJECT_URI);
+    when(request.getHeader(ACCEPT)).thenReturn(MediaType.PLAIN_TEXT_UTF_8.toString());
+    when(projectInitializationAction.initProject(PROJECT_NAME_GIT)).thenReturn(false);
+    when(response.getOutputStream()).thenReturn(outputStream);
+
+    final PullReplicationFilter pullReplicationFilter = createPullReplicationFilter();
+    pullReplicationFilter.doFilter(request, response, filterChain);
+
+    verify(response).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+  }
+
+  @Test
+  public void shouldBe500WhenResourceNotFound() throws Exception {
+    when(request.getRequestURI()).thenReturn(DELETE_PROJECT_URI);
+    when(request.getMethod()).thenReturn("DELETE");
+    when(projectsCollection.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(PROJECT_NAME)))
+        .thenReturn(projectResource);
+    when(projectDeletionAction.apply(any(), any()))
+        .thenThrow(new ResourceNotFoundException("resource not found"));
+    when(response.getOutputStream()).thenReturn(outputStream);
+
+    final PullReplicationFilter pullReplicationFilter = createPullReplicationFilter();
+    pullReplicationFilter.doFilter(request, response, filterChain);
+
+    verify(response).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+  }
+
+  @Test
+  public void shouldBe403WhenUserIsNotAuthorised() throws Exception {
+    byte[] payloadFetchAction =
+        ("{"
+                + "\"label\":\"Replication\", "
+                + "\"ref_name\": \"refs/heads/master\", "
+                + "\"async\":false"
+                + "}")
+            .getBytes(StandardCharsets.UTF_8);
+
+    defineBehaviours(payloadFetchAction, FETCH_URI);
+    when(fetchAction.apply(any(), any()))
+        .thenThrow(new AuthException("The user is not authorised"));
+    when(response.getOutputStream()).thenReturn(outputStream);
+
+    PullReplicationFilter pullReplicationFilter = createPullReplicationFilter();
+    pullReplicationFilter.doFilter(request, response, filterChain);
+
+    verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN);
+  }
+
+  @Test
+  public void shouldBe422WhenEntityCannotBeProcessed() throws Exception {
+    byte[] payloadFetchAction =
+        ("{"
+                + "\"label\":\"Replication\", "
+                + "\"ref_name\": \"refs/heads/master\", "
+                + "\"async\":false"
+                + "}")
+            .getBytes(StandardCharsets.UTF_8);
+
+    defineBehaviours(payloadFetchAction, FETCH_URI);
+    when(fetchAction.apply(any(), any()))
+        .thenThrow(new UnprocessableEntityException("Entity cannot be processed"));
+    when(response.getOutputStream()).thenReturn(outputStream);
+
+    PullReplicationFilter pullReplicationFilter = createPullReplicationFilter();
+    pullReplicationFilter.doFilter(request, response, filterChain);
+
+    verify(response).setStatus(SC_UNPROCESSABLE_ENTITY);
+  }
+
+  @Test
+  public void shouldBe409WhenThereIsResourceConflict() throws Exception {
+    when(request.getRequestURI()).thenReturn(DELETE_PROJECT_URI);
+    when(request.getMethod()).thenReturn("DELETE");
+    when(projectsCollection.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(PROJECT_NAME)))
+        .thenReturn(projectResource);
+
+    when(projectDeletionAction.apply(any(), any()))
+        .thenThrow(new ResourceConflictException("Resource conflict"));
+    when(response.getOutputStream()).thenReturn(outputStream);
+
+    PullReplicationFilter pullReplicationFilter = createPullReplicationFilter();
+    pullReplicationFilter.doFilter(request, response, filterChain);
+
+    verify(response).setStatus(SC_CONFLICT);
+  }
+
+  @Test
+  public void shouldBe400WhenProjectNameIsNotPresentInURL() throws Exception {
+    when(request.getRequestURI())
+        .thenReturn(String.format("any-prefix/projects/%s~delete-project", PLUGIN_NAME));
+    when(request.getMethod()).thenReturn("DELETE");
+    when(response.getOutputStream()).thenReturn(outputStream);
+
+    PullReplicationFilter pullReplicationFilter = createPullReplicationFilter();
+    pullReplicationFilter.doFilter(request, response, filterChain);
+
+    verify(response).setStatus(HttpServletResponse.SC_BAD_REQUEST);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadActionIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadActionIT.java
index aa07a7c..7c725b3 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadActionIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadActionIT.java
@@ -26,6 +26,8 @@
 import com.google.gson.Gson;
 import com.google.inject.Inject;
 import javax.servlet.http.HttpServletResponse;
+import org.apache.http.client.methods.HttpRequestBase;
+import org.junit.Ignore;
 import org.junit.Test;
 
 public class UpdateHeadActionIT extends ActionITBase {
@@ -39,8 +41,7 @@
         .create(source)
         .execute(
             createPutRequest(headInput("some/branch")),
-            assertHttpResponseCode(HttpServletResponse.SC_UNAUTHORIZED),
-            getAnonymousContext());
+            assertHttpResponseCode(HttpServletResponse.SC_UNAUTHORIZED));
   }
 
   @Test
@@ -48,9 +49,8 @@
     httpClientFactory
         .create(source)
         .execute(
-            createPutRequest(headInput("")),
-            assertHttpResponseCode(HttpServletResponse.SC_BAD_REQUEST),
-            getContext());
+            withBasicAuthenticationAsAdmin(createPutRequest(headInput(""))),
+            assertHttpResponseCode(HttpServletResponse.SC_BAD_REQUEST));
   }
 
   @Test
@@ -61,13 +61,11 @@
     BranchInput input = new BranchInput();
     input.revision = master;
     gApi.projects().name(testProjectName).branch(newBranch).create(input);
-
     httpClientFactory
         .create(source)
         .execute(
-            createPutRequest(headInput(newBranch)),
-            assertHttpResponseCode(HttpServletResponse.SC_OK),
-            getContext());
+            withBasicAuthenticationAsAdmin(createPutRequest(headInput(newBranch))),
+            assertHttpResponseCode(HttpServletResponse.SC_OK));
 
     assertThat(gApi.projects().name(testProjectName).head()).isEqualTo(newBranch);
   }
@@ -78,9 +76,8 @@
     httpClientFactory
         .create(source)
         .execute(
-            createPutRequest(headInput("")),
-            assertHttpResponseCode(HttpServletResponse.SC_BAD_REQUEST),
-            getContext());
+            withBasicAuthenticationAsAdmin(createPutRequest(headInput(""))),
+            assertHttpResponseCode(HttpServletResponse.SC_BAD_REQUEST));
   }
 
   @Test
@@ -92,13 +89,11 @@
     BranchInput input = new BranchInput();
     input.revision = master;
     gApi.projects().name(testProjectName).branch(newBranch).create(input);
-
     httpClientFactory
         .create(source)
         .execute(
-            createPutRequest(headInput(newBranch)),
-            assertHttpResponseCode(HttpServletResponse.SC_OK),
-            getContext());
+            withBasicAuthenticationAsAdmin(createPutRequest(headInput(newBranch))),
+            assertHttpResponseCode(HttpServletResponse.SC_OK));
 
     assertThat(gApi.projects().name(testProjectName).head()).isEqualTo(newBranch);
   }
@@ -108,9 +103,8 @@
     httpClientFactory
         .create(source)
         .execute(
-            createPutRequest(headInput("some/new/head")),
-            assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN),
-            getUserContext());
+            withBasicAuthenticationAsUser(createPutRequest(headInput("some/new/head"))),
+            assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN));
   }
 
   @Test
@@ -121,13 +115,10 @@
     BranchInput input = new BranchInput();
     input.revision = master;
     gApi.projects().name(testProjectName).branch(newBranch).create(input);
-
+    HttpRequestBase put = withBasicAuthenticationAsUser(createPutRequest(headInput(newBranch)));
     httpClientFactory
         .create(source)
-        .execute(
-            createPutRequest(headInput(newBranch)),
-            assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN),
-            getUserContext());
+        .execute(put, assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN));
 
     projectOperations
         .project(project)
@@ -137,10 +128,7 @@
 
     httpClientFactory
         .create(source)
-        .execute(
-            createPutRequest(headInput(newBranch)),
-            assertHttpResponseCode(HttpServletResponse.SC_OK),
-            getUserContext());
+        .execute(put, assertHttpResponseCode(HttpServletResponse.SC_OK));
   }
 
   @Test
@@ -149,9 +137,52 @@
     httpClientFactory
         .create(source)
         .execute(
-            createPutRequest(headInput("some/new/head")),
-            assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN),
-            getUserContext());
+            withBasicAuthenticationAsUser(createPutRequest(headInput("some/new/head"))),
+            assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN));
+  }
+
+  @Test
+  @GerritConfig(name = "container.replica", value = "true")
+  @GerritConfig(name = "auth.bearerToken", value = "some-bearer-token")
+  @Ignore("Waiting for resolving: Issue 16332: Not able to update the HEAD from internal user")
+  public void shouldReturnOKWhenHeadIsUpdatedInReplicaWithBearerToken() throws Exception {
+    String testProjectName = project.get();
+    url = getURLWithoutAuthenticationPrefix(testProjectName);
+    String newBranch = "refs/heads/mybranch";
+    String master = "refs/heads/master";
+    BranchInput input = new BranchInput();
+    input.revision = master;
+    gApi.projects().name(testProjectName).branch(newBranch).create(input);
+    httpClientFactory
+        .create(source)
+        .execute(
+            withBearerTokenAuthentication(
+                createPutRequest(headInput(newBranch)), "some-bearer-token"),
+            assertHttpResponseCode(HttpServletResponse.SC_OK));
+
+    assertThat(gApi.projects().name(testProjectName).head()).isEqualTo(newBranch);
+  }
+
+  @Test
+  @GerritConfig(name = "container.replica", value = "false")
+  @GerritConfig(name = "auth.bearerToken", value = "some-bearer-token")
+  @Ignore("Waiting for resolving: Issue 16332: Not able to update the HEAD from internal user")
+  public void shouldReturnOKWhenHeadIsUpdatedInPrimaryWithBearerToken() throws Exception {
+    String testProjectName = project.get();
+    url = getURLWithoutAuthenticationPrefix(testProjectName);
+    String newBranch = "refs/heads/mybranch";
+    String master = "refs/heads/master";
+    BranchInput input = new BranchInput();
+    input.revision = master;
+    gApi.projects().name(testProjectName).branch(newBranch).create(input);
+    httpClientFactory
+        .create(source)
+        .execute(
+            withBearerTokenAuthentication(
+                createPutRequest(headInput(newBranch)), "some-bearer-token"),
+            assertHttpResponseCode(HttpServletResponse.SC_OK));
+
+    assertThat(gApi.projects().name(testProjectName).head()).isEqualTo(newBranch);
   }
 
   private String headInput(String ref) {
@@ -161,7 +192,7 @@
   }
 
   @Override
-  protected String getURL(String projectName) {
+  protected String getURLWithAuthenticationPrefix(String projectName) {
     return String.format("%s/a/projects/%s/HEAD", adminRestSession.url(), projectName);
   }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientBase.java
similarity index 73%
rename from src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientTest.java
rename to src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientBase.java
index f0b8b99..a2389d7 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientBase.java
@@ -16,9 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-import static javax.servlet.http.HttpServletResponse.SC_CREATED;
 import static org.mockito.Mockito.any;
-import static org.mockito.Mockito.anyString;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -30,6 +28,7 @@
 import com.google.gerrit.entities.RefNames;
 import com.googlesource.gerrit.plugins.replication.CredentialsFactory;
 import com.googlesource.gerrit.plugins.replication.ReplicationFileBasedConfig;
+import com.googlesource.gerrit.plugins.replication.pull.BearerTokenProvider;
 import com.googlesource.gerrit.plugins.replication.pull.Source;
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData;
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionObjectData;
@@ -38,32 +37,26 @@
 import java.io.InputStreamReader;
 import java.net.URISyntaxException;
 import java.nio.ByteBuffer;
-import java.util.Optional;
+import java.util.Collections;
 import org.apache.http.Header;
 import org.apache.http.client.ClientProtocolException;
 import org.apache.http.client.methods.HttpDelete;
 import org.apache.http.client.methods.HttpPost;
 import org.apache.http.client.methods.HttpPut;
+import org.apache.http.client.methods.HttpRequestBase;
 import org.apache.http.message.BasicHeader;
 import org.eclipse.jgit.lib.Constants;
 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.util.IO;
 import org.eclipse.jgit.util.RawParseUtils;
-import org.junit.Before;
 import org.junit.Test;
-import org.junit.runner.RunWith;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Captor;
 import org.mockito.Mock;
-import org.mockito.invocation.InvocationOnMock;
-import org.mockito.junit.MockitoJUnitRunner;
-import org.mockito.stubbing.Answer;
 
-@RunWith(MockitoJUnitRunner.class)
-public class FetchRestApiClientTest {
+public abstract class FetchRestApiClientBase {
   private static final boolean IS_REF_UPDATE = false;
 
   @Mock CredentialsProvider credentialProvider;
@@ -73,10 +66,10 @@
   @Mock FileBasedConfig config;
   @Mock ReplicationFileBasedConfig replicationConfig;
   @Mock Source source;
+  @Mock BearerTokenProvider bearerTokenProvider;
   @Captor ArgumentCaptor<HttpPost> httpPostCaptor;
   @Captor ArgumentCaptor<HttpPut> httpPutCaptor;
   @Captor ArgumentCaptor<HttpDelete> httpDeleteCaptor;
-
   String api = "http://gerrit-host";
   String pluginName = "pull-replication";
   String instanceId = "Replication";
@@ -89,10 +82,22 @@
   Header expectedHeader = new BasicHeader("Content-Type", "application/json");
   SyncRefsFilter syncRefsFilter;
 
+  String commitObjectId = "9f8d52853089a3cf00c02ff7bd0817bd4353a95a";
+  String treeObjectId = "77814d216a6cab2ddb9f2877fbbd0febdc0fa608";
+  String blobObjectId = "bb383f5249c68a4cc8c82bdd1228b4a8883ff6e8";
+
   String expectedSendObjectPayload =
-      "{\"label\":\"Replication\",\"ref_name\":\"refs/heads/master\",\"revision_data\":{\"commit_object\":{\"type\":1,\"content\":\"dHJlZSA3NzgxNGQyMTZhNmNhYjJkZGI5ZjI4NzdmYmJkMGZlYmRjMGZhNjA4CnBhcmVudCA5ODNmZjFhM2NmNzQ3MjVhNTNhNWRlYzhkMGMwNjEyMjEyOGY1YThkCmF1dGhvciBHZXJyaXQgVXNlciAxMDAwMDAwIDwxMDAwMDAwQDY5ZWMzOGYwLTM1MGUtNGQ5Yy05NmQ0LWJjOTU2ZjJmYWFhYz4gMTYxMDU3ODY0OCArMDEwMApjb21taXR0ZXIgR2Vycml0IENvZGUgUmV2aWV3IDxyb290QG1hY3plY2gtWFBTLTE1PiAxNjEwNTc4NjQ4ICswMTAwCgpVcGRhdGUgcGF0Y2ggc2V0IDEKClBhdGNoIFNldCAxOgoKKDEgY29tbWVudCkKClBhdGNoLXNldDogMQo\\u003d\"},\"tree_object\":{\"type\":2,\"content\":\"MTAwNjQ0IGJsb2IgYmIzODNmNTI0OWM2OGE0Y2M4YzgyYmRkMTIyOGI0YTg4ODNmZjZlOCAgICBmNzVhNjkwMDRhOTNiNGNjYzhjZTIxNWMxMjgwODYzNmMyYjc1Njc1\"},\"blobs\":[{\"type\":3,\"content\":\"ewogICJjb21tZW50cyI6IFsKICAgIHsKICAgICAgImtleSI6IHsKICAgICAgICAidXVpZCI6ICI5MGI1YWJmZl80ZjY3NTI2YSIsCiAgICAgICAgImZpbGVuYW1lIjogIi9DT01NSVRfTVNHIiwKICAgICAgICAicGF0Y2hTZXRJZCI6IDEKICAgICAgfSwKICAgICAgImxpbmVOYnIiOiA5LAogICAgICAiYXV0aG9yIjogewogICAgICAgICJpZCI6IDEwMDAwMDAKICAgICAgfSwKICAgICAgIndyaXR0ZW5PbiI6ICIyMDIxLTAxLTEzVDIyOjU3OjI4WiIsCiAgICAgICJzaWRlIjogMSwKICAgICAgIm1lc3NhZ2UiOiAidGVzdCBjb21tZW50IiwKICAgICAgInJhbmdlIjogewogICAgICAgICJzdGFydExpbmUiOiA5LAogICAgICAgICJzdGFydENoYXIiOiAyMSwKICAgICAgICAiZW5kTGluZSI6IDksCiAgICAgICAgImVuZENoYXIiOiAzNAogICAgICB9LAogICAgICAicmV2SWQiOiAiZjc1YTY5MDA0YTkzYjRjY2M4Y2UyMTVjMTI4MDg2MzZjMmI3NTY3NSIsCiAgICAgICJzZXJ2ZXJJZCI6ICI2OWVjMzhmMC0zNTBlLTRkOWMtOTZkNC1iYzk1NmYyZmFhYWMiLAogICAgICAidW5yZXNvbHZlZCI6IHRydWUKICAgIH0KICBdCn0\\u003d\"}]}}";
+      "{\"label\":\"Replication\",\"ref_name\":\"refs/heads/master\",\"revision_data\":{\"commit_object\":{\"sha1\":\""
+          + commitObjectId
+          + "\",\"type\":1,\"content\":\"dHJlZSA3NzgxNGQyMTZhNmNhYjJkZGI5ZjI4NzdmYmJkMGZlYmRjMGZhNjA4CnBhcmVudCA5ODNmZjFhM2NmNzQ3MjVhNTNhNWRlYzhkMGMwNjEyMjEyOGY1YThkCmF1dGhvciBHZXJyaXQgVXNlciAxMDAwMDAwIDwxMDAwMDAwQDY5ZWMzOGYwLTM1MGUtNGQ5Yy05NmQ0LWJjOTU2ZjJmYWFhYz4gMTYxMDU3ODY0OCArMDEwMApjb21taXR0ZXIgR2Vycml0IENvZGUgUmV2aWV3IDxyb290QG1hY3plY2gtWFBTLTE1PiAxNjEwNTc4NjQ4ICswMTAwCgpVcGRhdGUgcGF0Y2ggc2V0IDEKClBhdGNoIFNldCAxOgoKKDEgY29tbWVudCkKClBhdGNoLXNldDogMQo\\u003d\"},\"tree_object\":{\"sha1\":\""
+          + treeObjectId
+          + "\",\"type\":2,\"content\":\"MTAwNjQ0IGJsb2IgYmIzODNmNTI0OWM2OGE0Y2M4YzgyYmRkMTIyOGI0YTg4ODNmZjZlOCAgICBmNzVhNjkwMDRhOTNiNGNjYzhjZTIxNWMxMjgwODYzNmMyYjc1Njc1\"},\"blobs\":[{\"sha1\":\""
+          + blobObjectId
+          + "\",\"type\":3,\"content\":\"ewogICJjb21tZW50cyI6IFsKICAgIHsKICAgICAgImtleSI6IHsKICAgICAgICAidXVpZCI6ICI5MGI1YWJmZl80ZjY3NTI2YSIsCiAgICAgICAgImZpbGVuYW1lIjogIi9DT01NSVRfTVNHIiwKICAgICAgICAicGF0Y2hTZXRJZCI6IDEKICAgICAgfSwKICAgICAgImxpbmVOYnIiOiA5LAogICAgICAiYXV0aG9yIjogewogICAgICAgICJpZCI6IDEwMDAwMDAKICAgICAgfSwKICAgICAgIndyaXR0ZW5PbiI6ICIyMDIxLTAxLTEzVDIyOjU3OjI4WiIsCiAgICAgICJzaWRlIjogMSwKICAgICAgIm1lc3NhZ2UiOiAidGVzdCBjb21tZW50IiwKICAgICAgInJhbmdlIjogewogICAgICAgICJzdGFydExpbmUiOiA5LAogICAgICAgICJzdGFydENoYXIiOiAyMSwKICAgICAgICAiZW5kTGluZSI6IDksCiAgICAgICAgImVuZENoYXIiOiAzNAogICAgICB9LAogICAgICAicmV2SWQiOiAiZjc1YTY5MDA0YTkzYjRjY2M4Y2UyMTVjMTI4MDg2MzZjMmI3NTY3NSIsCiAgICAgICJzZXJ2ZXJJZCI6ICI2OWVjMzhmMC0zNTBlLTRkOWMtOTZkNC1iYzk1NmYyZmFhYWMiLAogICAgICAidW5yZXNvbHZlZCI6IHRydWUKICAgIH0KICBdCn0\\u003d\"}]}}";
   String commitObject =
-      "tree 77814d216a6cab2ddb9f2877fbbd0febdc0fa608\n"
+      "tree "
+          + treeObjectId
+          + "\n"
           + "parent 983ff1a3cf74725a53a5dec8d0c06122128f5a8d\n"
           + "author Gerrit User 1000000 <1000000@69ec38f0-350e-4d9c-96d4-bc956f2faaac> 1610578648 +0100\n"
           + "committer Gerrit Code Review <root@maczech-XPS-15> 1610578648 +0100\n"
@@ -105,7 +110,7 @@
           + "\n"
           + "Patch-set: 1\n";
   String treeObject =
-      "100644 blob bb383f5249c68a4cc8c82bdd1228b4a8883ff6e8    f75a69004a93b4ccc8ce215c12808636c2b75675";
+      "100644 blob " + blobObjectId + "    f75a69004a93b4ccc8ce215c12808636c2b75675";
   String blobObject =
       "{\n"
           + "  \"comments\": [\n"
@@ -137,43 +142,9 @@
 
   FetchApiClient objectUnderTest;
 
-  @Before
-  public void setup() throws ClientProtocolException, IOException {
-    when(credentialProvider.supports(any()))
-        .thenAnswer(
-            new Answer<Boolean>() {
+  protected abstract String urlAuthenticationPrefix();
 
-              @Override
-              public Boolean answer(InvocationOnMock invocation) throws Throwable {
-                CredentialItem.Username user = (CredentialItem.Username) invocation.getArgument(0);
-                CredentialItem.Password password =
-                    (CredentialItem.Password) invocation.getArgument(1);
-                user.setValue("admin");
-                password.setValue("secret".toCharArray());
-                return true;
-              }
-            });
-
-    when(credentialProvider.get(any(), any(CredentialItem.class))).thenReturn(true);
-    when(credentials.create(anyString())).thenReturn(credentialProvider);
-    when(replicationConfig.getConfig()).thenReturn(config);
-    when(config.getStringList("replication", null, "syncRefs")).thenReturn(new String[0]);
-    when(source.getRemoteConfigName()).thenReturn("Replication");
-
-    HttpResult httpResult = new HttpResult(SC_CREATED, Optional.of("result message"));
-    when(httpClient.execute(any(HttpPost.class), any(), any())).thenReturn(httpResult);
-    when(httpClientFactory.create(any())).thenReturn(httpClient);
-    syncRefsFilter = new SyncRefsFilter(replicationConfig);
-    objectUnderTest =
-        new FetchRestApiClient(
-            credentials,
-            httpClientFactory,
-            replicationConfig,
-            syncRefsFilter,
-            pluginName,
-            instanceId,
-            source);
-  }
+  protected abstract void assertAuthentication(HttpRequestBase httpRequest);
 
   @Test
   public void shouldCallFetchEndpoint()
@@ -181,32 +152,24 @@
 
     objectUnderTest.callFetch(Project.nameKey("test_repo"), refName, new URIish(api));
 
-    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any(), any());
+    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any());
 
     HttpPost httpPost = httpPostCaptor.getValue();
     assertThat(httpPost.getURI().getHost()).isEqualTo("gerrit-host");
     assertThat(httpPost.getURI().getPath())
-        .isEqualTo("/a/projects/test_repo/pull-replication~fetch");
+        .isEqualTo(
+            String.format(
+                "%s/projects/test_repo/pull-replication~fetch", urlAuthenticationPrefix()));
+    assertAuthentication(httpPost);
   }
 
   @Test
   public void shouldByDefaultCallSyncFetchForAllRefs()
       throws ClientProtocolException, IOException, URISyntaxException {
 
-    syncRefsFilter = new SyncRefsFilter(replicationConfig);
-    objectUnderTest =
-        new FetchRestApiClient(
-            credentials,
-            httpClientFactory,
-            replicationConfig,
-            syncRefsFilter,
-            pluginName,
-            instanceId,
-            source);
-
     objectUnderTest.callFetch(Project.nameKey("test_repo"), refName, new URIish(api));
 
-    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any(), any());
+    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any());
 
     HttpPost httpPost = httpPostCaptor.getValue();
     assertThat(readPayload(httpPost)).isEqualTo(expectedPayload);
@@ -227,11 +190,12 @@
             syncRefsFilter,
             pluginName,
             instanceId,
+            bearerTokenProvider,
             source);
 
     objectUnderTest.callFetch(Project.nameKey("test_repo"), refName, new URIish(api));
 
-    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any(), any());
+    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any());
 
     HttpPost httpPost = httpPostCaptor.getValue();
     assertThat(readPayload(httpPost)).isEqualTo(expectedAsyncPayload);
@@ -255,15 +219,16 @@
             syncRefsFilter,
             pluginName,
             instanceId,
+            bearerTokenProvider,
             source);
 
     objectUnderTest.callFetch(Project.nameKey("test_repo"), refName, new URIish(api));
-    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any(), any());
+    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any());
     HttpPost httpPost = httpPostCaptor.getValue();
     assertThat(readPayload(httpPost)).isEqualTo(expectedAsyncPayload);
 
     objectUnderTest.callFetch(Project.nameKey("test_repo"), metaRefName, new URIish(api));
-    verify(httpClient, times(2)).execute(httpPostCaptor.capture(), any(), any());
+    verify(httpClient, times(2)).execute(httpPostCaptor.capture(), any());
     httpPost = httpPostCaptor.getValue();
     assertThat(readPayload(httpPost)).isEqualTo(expectedMetaRefPayload);
   }
@@ -274,7 +239,7 @@
 
     objectUnderTest.callFetch(Project.nameKey("test_repo"), refName, new URIish(api));
 
-    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any(), any());
+    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any());
 
     HttpPost httpPost = httpPostCaptor.getValue();
     assertThat(readPayload(httpPost)).isEqualTo(expectedPayload);
@@ -286,7 +251,7 @@
 
     objectUnderTest.callFetch(Project.nameKey("test_repo"), refName, new URIish(api));
 
-    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any(), any());
+    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any());
 
     HttpPost httpPost = httpPostCaptor.getValue();
     assertThat(httpPost.getLastHeader("Content-Type").getValue())
@@ -304,12 +269,15 @@
         createSampleRevisionData(),
         new URIish(api));
 
-    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any(), any());
+    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any());
 
     HttpPost httpPost = httpPostCaptor.getValue();
     assertThat(httpPost.getURI().getHost()).isEqualTo("gerrit-host");
     assertThat(httpPost.getURI().getPath())
-        .isEqualTo("/a/projects/test_repo/pull-replication~apply-object");
+        .isEqualTo(
+            String.format(
+                "%s/projects/test_repo/pull-replication~apply-object", urlAuthenticationPrefix()));
+    assertAuthentication(httpPost);
   }
 
   @Test
@@ -323,7 +291,7 @@
         createSampleRevisionData(),
         new URIish(api));
 
-    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any(), any());
+    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any());
 
     HttpPost httpPost = httpPostCaptor.getValue();
     assertThat(readPayload(httpPost)).isEqualTo(expectedSendObjectPayload);
@@ -335,7 +303,7 @@
 
     objectUnderTest.callFetch(Project.nameKey("test_repo"), refName, new URIish(api));
 
-    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any(), any());
+    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any());
 
     HttpPost httpPost = httpPostCaptor.getValue();
     assertThat(httpPost.getLastHeader("Content-Type").getValue())
@@ -354,6 +322,7 @@
                 syncRefsFilter,
                 pluginName,
                 null,
+                bearerTokenProvider,
                 source));
   }
 
@@ -369,6 +338,7 @@
                 syncRefsFilter,
                 pluginName,
                 " ",
+                bearerTokenProvider,
                 source));
   }
 
@@ -384,6 +354,7 @@
                 syncRefsFilter,
                 pluginName,
                 "",
+                bearerTokenProvider,
                 source));
   }
 
@@ -399,10 +370,11 @@
             syncRefsFilter,
             pluginName,
             "",
+            bearerTokenProvider,
             source);
     objectUnderTest.callFetch(Project.nameKey("test_repo"), refName, new URIish(api));
 
-    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any(), any());
+    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any());
 
     HttpPost httpPost = httpPostCaptor.getValue();
     assertThat(readPayload(httpPost)).isEqualTo(expectedPayload);
@@ -413,12 +385,16 @@
 
     objectUnderTest.initProject(Project.nameKey("test_repo"), new URIish(api));
 
-    verify(httpClient, times(1)).execute(httpPutCaptor.capture(), any(), any());
+    verify(httpClient, times(1)).execute(httpPutCaptor.capture(), any());
 
     HttpPut httpPut = httpPutCaptor.getValue();
     assertThat(httpPut.getURI().getHost()).isEqualTo("gerrit-host");
     assertThat(httpPut.getURI().getPath())
-        .isEqualTo("/a/plugins/pull-replication/init-project/test_repo.git");
+        .isEqualTo(
+            String.format(
+                "%s/plugins/pull-replication/init-project/test_repo.git",
+                urlAuthenticationPrefix()));
+    assertAuthentication(httpPut);
   }
 
   @Test
@@ -426,12 +402,16 @@
 
     objectUnderTest.deleteProject(Project.nameKey("test_repo"), new URIish(api));
 
-    verify(httpClient, times(1)).execute(httpDeleteCaptor.capture(), any(), any());
+    verify(httpClient, times(1)).execute(httpDeleteCaptor.capture(), any());
 
     HttpDelete httpDelete = httpDeleteCaptor.getValue();
     assertThat(httpDelete.getURI().getHost()).isEqualTo("gerrit-host");
     assertThat(httpDelete.getURI().getPath())
-        .isEqualTo("/a/projects/test_repo/pull-replication~delete-project");
+        .isEqualTo(
+            String.format(
+                "%s/projects/test_repo/pull-replication~delete-project",
+                urlAuthenticationPrefix()));
+    assertAuthentication(httpDelete);
   }
 
   @Test
@@ -440,7 +420,7 @@
     String projectName = "aProject";
     objectUnderTest.updateHead(Project.nameKey(projectName), newHead, new URIish(api));
 
-    verify(httpClient, times(1)).execute(httpPutCaptor.capture(), any(), any());
+    verify(httpClient, times(1)).execute(httpPutCaptor.capture(), any());
 
     HttpPut httpPut = httpPutCaptor.getValue();
     String payload =
@@ -449,8 +429,11 @@
 
     assertThat(httpPut.getURI().getHost()).isEqualTo("gerrit-host");
     assertThat(httpPut.getURI().getPath())
-        .isEqualTo(String.format("/a/projects/%s/pull-replication~HEAD", projectName));
+        .isEqualTo(
+            String.format(
+                "%s/projects/%s/pull-replication~HEAD", urlAuthenticationPrefix(), projectName));
     assertThat(payload).isEqualTo(String.format("{\"ref\": \"%s\"}", newHead));
+    assertAuthentication(httpPut);
   }
 
   public String readPayload(HttpPost entity) throws UnsupportedOperationException, IOException {
@@ -460,9 +443,12 @@
 
   private RevisionData createSampleRevisionData() {
     RevisionObjectData commitData =
-        new RevisionObjectData(Constants.OBJ_COMMIT, commitObject.getBytes());
-    RevisionObjectData treeData = new RevisionObjectData(Constants.OBJ_TREE, treeObject.getBytes());
-    RevisionObjectData blobData = new RevisionObjectData(Constants.OBJ_BLOB, blobObject.getBytes());
-    return new RevisionData(commitData, treeData, Lists.newArrayList(blobData));
+        new RevisionObjectData(commitObjectId, Constants.OBJ_COMMIT, commitObject.getBytes());
+    RevisionObjectData treeData =
+        new RevisionObjectData(treeObjectId, Constants.OBJ_TREE, treeObject.getBytes());
+    RevisionObjectData blobData =
+        new RevisionObjectData(blobObjectId, Constants.OBJ_BLOB, blobObject.getBytes());
+    return new RevisionData(
+        Collections.emptyList(), commitData, treeData, Lists.newArrayList(blobData));
   }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientWithBasicAuthenticationTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientWithBasicAuthenticationTest.java
new file mode 100644
index 0000000..644afce
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientWithBasicAuthenticationTest.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2022 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.pull.client;
+
+import static com.google.common.truth.Truth.assertThat;
+import static javax.servlet.http.HttpServletResponse.SC_CREATED;
+import static org.mockito.Mockito.*;
+
+import com.googlesource.gerrit.plugins.replication.pull.filter.SyncRefsFilter;
+import java.io.IOException;
+import java.util.Optional;
+import org.apache.http.Header;
+import org.apache.http.HttpHeaders;
+import org.apache.http.client.ClientProtocolException;
+import org.apache.http.client.methods.HttpRequestBase;
+import org.eclipse.jgit.transport.CredentialItem;
+import org.junit.Before;
+import org.junit.runner.RunWith;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.mockito.stubbing.Answer;
+
+@RunWith(MockitoJUnitRunner.class)
+public class FetchRestApiClientWithBasicAuthenticationTest extends FetchRestApiClientBase {
+
+  @Before
+  public void setup() throws ClientProtocolException, IOException {
+    when(bearerTokenProvider.get()).thenReturn(Optional.empty());
+    when(credentialProvider.supports(any()))
+        .thenAnswer(
+            new Answer<Boolean>() {
+
+              @Override
+              public Boolean answer(InvocationOnMock invocation) throws Throwable {
+                CredentialItem.Username user = (CredentialItem.Username) invocation.getArgument(0);
+                CredentialItem.Password password =
+                    (CredentialItem.Password) invocation.getArgument(1);
+                user.setValue("admin");
+                password.setValue("secret".toCharArray());
+                return true;
+              }
+            });
+
+    when(credentialProvider.get(any(), any(CredentialItem.class))).thenReturn(true);
+    when(credentials.create(anyString())).thenReturn(credentialProvider);
+    when(replicationConfig.getConfig()).thenReturn(config);
+    when(config.getStringList("replication", null, "syncRefs")).thenReturn(new String[0]);
+    when(source.getRemoteConfigName()).thenReturn("Replication");
+
+    HttpResult httpResult = new HttpResult(SC_CREATED, Optional.of("result message"));
+    when(httpClient.execute(any(HttpRequestBase.class), any())).thenReturn(httpResult);
+    when(httpClientFactory.create(any())).thenReturn(httpClient);
+    syncRefsFilter = new SyncRefsFilter(replicationConfig);
+    objectUnderTest =
+        new FetchRestApiClient(
+            credentials,
+            httpClientFactory,
+            replicationConfig,
+            syncRefsFilter,
+            pluginName,
+            instanceId,
+            bearerTokenProvider,
+            source);
+    verify(bearerTokenProvider).get();
+  }
+
+  @Override
+  protected String urlAuthenticationPrefix() {
+    return "/a";
+  }
+
+  @Override
+  protected void assertAuthentication(HttpRequestBase httpRequest) {
+    Header[] authorizationHeaders = httpRequest.getHeaders(HttpHeaders.AUTHORIZATION);
+    assertThat(authorizationHeaders.length).isEqualTo(1);
+    assertThat(authorizationHeaders[0].getValue()).contains("Basic");
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientWithBearerTokenTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientWithBearerTokenTest.java
new file mode 100644
index 0000000..90d71ad
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientWithBearerTokenTest.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2022 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.pull.client;
+
+import static com.google.common.truth.Truth.assertThat;
+import static javax.servlet.http.HttpServletResponse.SC_CREATED;
+import static org.mockito.Mockito.*;
+
+import com.googlesource.gerrit.plugins.replication.pull.filter.SyncRefsFilter;
+import java.io.IOException;
+import java.util.Optional;
+import org.apache.http.Header;
+import org.apache.http.HttpHeaders;
+import org.apache.http.client.ClientProtocolException;
+import org.apache.http.client.methods.HttpRequestBase;
+import org.junit.Before;
+import org.junit.runner.RunWith;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class FetchRestApiClientWithBearerTokenTest extends FetchRestApiClientBase {
+
+  @Before
+  public void setup() throws ClientProtocolException, IOException {
+    when(bearerTokenProvider.get()).thenReturn(Optional.of("some-bearer-token"));
+    when(replicationConfig.getConfig()).thenReturn(config);
+    when(config.getStringList("replication", null, "syncRefs")).thenReturn(new String[0]);
+    HttpResult httpResult = new HttpResult(SC_CREATED, Optional.of("result message"));
+    when(httpClient.execute(any(HttpRequestBase.class), any())).thenReturn(httpResult);
+    when(httpClientFactory.create(any())).thenReturn(httpClient);
+
+    syncRefsFilter = new SyncRefsFilter(replicationConfig);
+
+    objectUnderTest =
+        new FetchRestApiClient(
+            credentials,
+            httpClientFactory,
+            replicationConfig,
+            syncRefsFilter,
+            pluginName,
+            instanceId,
+            bearerTokenProvider,
+            source);
+    verify(bearerTokenProvider).get();
+  }
+
+  @Override
+  protected String urlAuthenticationPrefix() {
+    return "";
+  }
+
+  @Override
+  protected void assertAuthentication(HttpRequestBase httpRequest) {
+    Header[] authorizationHeaders = httpRequest.getHeaders(HttpHeaders.AUTHORIZATION);
+    assertThat(authorizationHeaders.length).isEqualTo(1);
+    assertThat(authorizationHeaders[0].getValue()).isEqualTo("Bearer " + "some-bearer-token");
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpResultTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpResultTest.java
new file mode 100644
index 0000000..d82f3a5
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpResultTest.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2022 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.pull.client;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.Arrays;
+import java.util.Optional;
+import javax.servlet.http.HttpServletResponse;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+@RunWith(Parameterized.class)
+public class HttpResultTest {
+
+  @Parameterized.Parameters(name = "HTTP Status = {0} is successful: {1}")
+  public static Iterable<Object[]> data() {
+    return Arrays.asList(
+        new Object[][] {
+          {HttpServletResponse.SC_OK, true},
+          {HttpServletResponse.SC_CREATED, true},
+          {HttpServletResponse.SC_ACCEPTED, true},
+          {HttpServletResponse.SC_NO_CONTENT, true},
+          {HttpServletResponse.SC_BAD_REQUEST, false},
+          {HttpServletResponse.SC_CONFLICT, false}
+        });
+  }
+
+  private Integer httpStatus;
+  private boolean isSuccessful;
+
+  public HttpResultTest(Integer httpStatus, Boolean isSuccessful) {
+    this.httpStatus = httpStatus;
+    this.isSuccessful = isSuccessful;
+  }
+
+  @Test
+  public void httpResultIsSuccessful() {
+    HttpResult httpResult = new HttpResult(httpStatus, Optional.empty());
+    assertThat(httpResult.isSuccessful()).isEqualTo(isSuccessful);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/event/StreamEventListenerTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/event/StreamEventListenerTest.java
index 8f80883..c673011 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/event/StreamEventListenerTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/event/StreamEventListenerTest.java
@@ -14,12 +14,14 @@
 
 package com.googlesource.gerrit.plugins.replication.pull.event;
 
+import static com.google.common.truth.Truth.assertThat;
 import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.eq;
 import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.data.RefUpdateAttribute;
@@ -28,36 +30,47 @@
 import com.google.gerrit.server.events.RefUpdatedEvent;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.googlesource.gerrit.plugins.replication.pull.api.FetchAction.FetchJob;
-import com.googlesource.gerrit.plugins.replication.pull.api.FetchCommand;
+import com.googlesource.gerrit.plugins.replication.pull.FetchOne;
+import com.googlesource.gerrit.plugins.replication.pull.api.FetchAction.Input;
+import com.googlesource.gerrit.plugins.replication.pull.api.FetchJob;
 import com.googlesource.gerrit.plugins.replication.pull.api.ProjectInitializationAction;
+import com.googlesource.gerrit.plugins.replication.pull.api.PullReplicationApiRequestMetrics;
 import java.util.concurrent.ScheduledExecutorService;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
 import org.mockito.Mock;
 import org.mockito.junit.MockitoJUnitRunner;
 
 @RunWith(MockitoJUnitRunner.class)
 public class StreamEventListenerTest {
 
+  private static final String TEST_REF_NAME = "refs/changes/01/1/1";
   private static final String TEST_PROJECT = "test-project";
   private static final String INSTANCE_ID = "node_instance_id";
   private static final String REMOTE_INSTANCE_ID = "remote_node_instance_id";
 
-  @Mock private FetchCommand fetchCommand;
   @Mock private ProjectInitializationAction projectInitializationAction;
   @Mock private WorkQueue workQueue;
   @Mock private ScheduledExecutorService executor;
+  @Mock private FetchJob fetchJob;
+  @Mock private FetchJob.Factory fetchJobFactory;
+  @Captor ArgumentCaptor<Input> inputCaptor;
+  @Mock private PullReplicationApiRequestMetrics metrics;
 
   private StreamEventListener objectUnderTest;
 
   @Before
   public void setup() {
     when(workQueue.getDefaultQueue()).thenReturn(executor);
+    when(fetchJobFactory.create(eq(Project.nameKey(TEST_PROJECT)), any(), any()))
+        .thenReturn(fetchJob);
     objectUnderTest =
-        new StreamEventListener(INSTANCE_ID, fetchCommand, projectInitializationAction, workQueue);
+        new StreamEventListener(
+            INSTANCE_ID, projectInitializationAction, workQueue, fetchJobFactory, () -> metrics);
   }
 
   @Test
@@ -89,7 +102,7 @@
   public void shouldScheduleFetchJobForRefUpdateEvent() {
     RefUpdatedEvent event = new RefUpdatedEvent();
     RefUpdateAttribute refUpdate = new RefUpdateAttribute();
-    refUpdate.refName = "refs/changes/01/1/1";
+    refUpdate.refName = TEST_REF_NAME;
     refUpdate.project = TEST_PROJECT;
 
     event.instanceId = REMOTE_INSTANCE_ID;
@@ -97,6 +110,12 @@
 
     objectUnderTest.onEvent(event);
 
+    verify(fetchJobFactory).create(eq(Project.nameKey(TEST_PROJECT)), inputCaptor.capture(), any());
+
+    Input input = inputCaptor.getValue();
+    assertThat(input.label).isEqualTo(REMOTE_INSTANCE_ID);
+    assertThat(input.refName).isEqualTo(TEST_REF_NAME);
+
     verify(executor).submit(any(FetchJob.class));
   }
 
@@ -109,8 +128,7 @@
 
     objectUnderTest.onEvent(event);
 
-    verify(projectInitializationAction, times(1))
-        .initProject(String.format("%s.git", TEST_PROJECT));
+    verify(projectInitializationAction).initProject(String.format("%s.git", TEST_PROJECT));
   }
 
   @Test
@@ -121,6 +139,12 @@
 
     objectUnderTest.onEvent(event);
 
+    verify(fetchJobFactory).create(eq(Project.nameKey(TEST_PROJECT)), inputCaptor.capture(), any());
+
+    Input input = inputCaptor.getValue();
+    assertThat(input.label).isEqualTo(REMOTE_INSTANCE_ID);
+    assertThat(input.refName).isEqualTo(FetchOne.ALL_REFS);
+
     verify(executor).submit(any(FetchJob.class));
   }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObjectIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObjectIT.java
index e2a162e..161830b 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObjectIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObjectIT.java
@@ -79,14 +79,36 @@
 
     Optional<RevisionData> revisionData =
         reader.read(
-            Project.nameKey(testRepoProjectName), pushResult.getCommit().toObjectId(), refName);
+            Project.nameKey(testRepoProjectName), pushResult.getCommit().toObjectId(), refName, 0);
 
     RefSpec refSpec = new RefSpec(refName);
-    objectUnderTest.apply(project, refSpec, revisionData.get());
+    objectUnderTest.apply(project, refSpec, toArray(revisionData));
     try (Repository repo = repoManager.openRepository(project);
         TestRepository<Repository> testRepo = new TestRepository<>(repo); ) {
       Optional<RevisionData> newRevisionData =
-          reader.read(project, repo.exactRef(refName).getObjectId(), refName);
+          reader.read(project, repo.exactRef(refName).getObjectId(), refName, 0);
+      compareObjects(revisionData.get(), newRevisionData);
+      testRepo.fsck();
+    }
+  }
+
+  @Test
+  public void shouldApplyRefSequencesChanges() throws Exception {
+    String testRepoProjectName = project + TEST_REPLICATION_SUFFIX;
+    testRepo = cloneProject(createTestProject(testRepoProjectName));
+
+    createChange();
+    String seqChangesRef = RefNames.REFS_SEQUENCES + "changes";
+
+    Optional<RevisionData> revisionData = reader.read(allProjects, seqChangesRef, 0);
+
+    RefSpec refSpec = new RefSpec(seqChangesRef);
+    objectUnderTest.apply(project, refSpec, toArray(revisionData));
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<Repository> testRepo = new TestRepository<>(repo); ) {
+
+      Optional<RevisionData> newRevisionData =
+          reader.read(project, repo.exactRef(seqChangesRef).getObjectId(), seqChangesRef, 0);
       compareObjects(revisionData.get(), newRevisionData);
       testRepo.fsck();
     }
@@ -105,8 +127,8 @@
     NameKey testRepoKey = Project.nameKey(testRepoProjectName);
     try (Repository repo = repoManager.openRepository(testRepoKey)) {
       Optional<RevisionData> revisionData =
-          reader.read(testRepoKey, repo.exactRef(refName).getObjectId(), refName);
-      objectUnderTest.apply(project, refSpec, revisionData.get());
+          reader.read(testRepoKey, repo.exactRef(refName).getObjectId(), refName, 0);
+      objectUnderTest.apply(project, refSpec, toArray(revisionData));
     }
 
     ReviewInput reviewInput = new ReviewInput();
@@ -117,12 +139,12 @@
     try (Repository repo = repoManager.openRepository(project);
         TestRepository<Repository> testRepo = new TestRepository<>(repo)) {
       Optional<RevisionData> revisionDataWithComment =
-          reader.read(testRepoKey, repo.exactRef(refName).getObjectId(), refName);
+          reader.read(testRepoKey, repo.exactRef(refName).getObjectId(), refName, 0);
 
-      objectUnderTest.apply(project, refSpec, revisionDataWithComment.get());
+      objectUnderTest.apply(project, refSpec, toArray(revisionDataWithComment));
 
       Optional<RevisionData> newRevisionData =
-          reader.read(project, repo.exactRef(refName).getObjectId(), refName);
+          reader.read(project, repo.exactRef(refName).getObjectId(), refName, 0);
 
       compareObjects(revisionDataWithComment.get(), newRevisionData);
 
@@ -147,12 +169,12 @@
       gApi.changes().id(changeId.get()).current().review(reviewInput);
 
       Optional<RevisionData> revisionData =
-          reader.read(createTestProject, repo.exactRef(refName).getObjectId(), refName);
+          reader.read(createTestProject, repo.exactRef(refName).getObjectId(), refName, 0);
 
       RefSpec refSpec = new RefSpec(refName);
       assertThrows(
           MissingParentObjectException.class,
-          () -> objectUnderTest.apply(project, refSpec, revisionData.get()));
+          () -> objectUnderTest.apply(project, refSpec, toArray(revisionData)));
     }
   }
 
@@ -173,6 +195,9 @@
   }
 
   private void compareContent(RevisionObjectData expected, RevisionObjectData actual) {
+    if (expected == actual) {
+      return;
+    }
     assertThat(actual.getType()).isEqualTo(expected.getType());
     assertThat(Bytes.asList(actual.getContent()))
         .containsExactlyElementsIn(Bytes.asList(expected.getContent()))
@@ -205,4 +230,10 @@
       bind(ApplyObject.class);
     }
   }
+
+  private RevisionData[] toArray(Optional<RevisionData> optional) {
+    ImmutableList.Builder<RevisionData> listBuilder = ImmutableList.builder();
+    optional.ifPresent(listBuilder::add);
+    return listBuilder.build().toArray(new RevisionData[1]);
+  }
 }