Merge branch 'stable-3.1' into stable-3.2

* stable-3.1:
  Propagate FetchRefReplicatedEvent
  Send small refs as a fetch call payload
  Send ref content as a fetch call payload
  Index change upon propagation
  Propagate FetchRefReplicatedEvent upon a Ref fetch

Change-Id: I89e4d6104a590a10ed2ecd709d0595f59d1f7f99
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
new file mode 100644
index 0000000..78b6ddb
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ApplyObjectMetrics.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2020 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.extensions.annotations.PluginName;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.server.logging.PluginMetadata;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class ApplyObjectMetrics {
+  private final Timer1<String> executionTime;
+
+  @Inject
+  ApplyObjectMetrics(@PluginName String pluginName, MetricMaker metricMaker) {
+    Field<String> SOURCE_FIELD =
+        Field.ofString(
+                "source",
+                (metadataBuilder, fieldValue) ->
+                    metadataBuilder
+                        .pluginName(pluginName)
+                        .addPluginMetadata(PluginMetadata.create("source", fieldValue)))
+            .build();
+
+    executionTime =
+        metricMaker.newTimer(
+            "apply_object_latency",
+            new Description("Time spent applying object from remote source.")
+                .setCumulative()
+                .setUnit(Description.Units.MILLISECONDS),
+            SOURCE_FIELD);
+  }
+
+  /**
+   * Start the replication latency timer from a source.
+   *
+   * @param name the source name.
+   * @return the timer context.
+   */
+  public Timer1.Context<String> start(String name) {
+    return executionTime.start(name);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchRefReplicatedEvent.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchRefReplicatedEvent.java
index eb825b8..cad6dfb 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchRefReplicatedEvent.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchRefReplicatedEvent.java
@@ -42,6 +42,10 @@
     this.refUpdateResult = refUpdateResult;
   }
 
+  public String getStatus() {
+    return status;
+  }
+
   @Override
   public int hashCode() {
     return Objects.hash(project, ref, status, refUpdateResult);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchResultProcessing.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchResultProcessing.java
index cffc1f6..2d51239 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchResultProcessing.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/FetchResultProcessing.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.server.events.EventDispatcher;
 import com.google.gerrit.server.events.RefEvent;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
 import java.lang.ref.WeakReference;
 import java.util.concurrent.atomic.AtomicBoolean;
 import org.eclipse.jgit.lib.RefUpdate;
@@ -69,11 +70,15 @@
   }
 
   public static class CommandProcessing extends FetchResultProcessing {
+    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
     private WeakReference<Command> sshCommand;
     private AtomicBoolean hasError = new AtomicBoolean();
+    private final EventDispatcher dispatcher;
 
-    public CommandProcessing(Command sshCommand) {
+    @Inject
+    public CommandProcessing(Command sshCommand, EventDispatcher dispatcher) {
       this.sshCommand = new WeakReference<>(sshCommand);
+      this.dispatcher = dispatcher;
     }
 
     @Override
@@ -112,6 +117,14 @@
         sb.append(")");
       }
       writeStdOut(sb.toString());
+      try {
+        dispatcher.postEvent(
+            new FetchRefReplicatedEvent(
+                project, ref, resolveNodeName(uri), status, refUpdateResult));
+      } catch (PermissionBackendException e) {
+        logger.atSevere().withCause(e).log(
+            "Cannot post event for ref '%s', project %s", ref, project);
+      }
     }
 
     @Override
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 9add07c..50ab913 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
@@ -44,6 +44,8 @@
 import com.googlesource.gerrit.plugins.replication.pull.client.FetchRestApiClient;
 import com.googlesource.gerrit.plugins.replication.pull.client.HttpClient;
 import com.googlesource.gerrit.plugins.replication.pull.client.SourceHttpClient;
+import com.googlesource.gerrit.plugins.replication.pull.event.FetchRefReplicatedEventModule;
+import com.googlesource.gerrit.plugins.replication.pull.fetch.ApplyObject;
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
@@ -65,8 +67,13 @@
   @Override
   protected void configure() {
 
+    bind(RevisionReader.class).in(Scopes.SINGLETON);
+    bind(ApplyObject.class);
+
     install(new PullReplicationApiModule());
 
+    install(new FetchRefReplicatedEventModule());
+
     install(
         new FactoryModuleBuilder()
             .implement(HttpClient.class, SourceHttpClient.class)
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 fc5ed7d..6c8ada0 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
@@ -17,6 +17,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.Queues;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -26,16 +27,22 @@
 import com.google.inject.Provider;
 import com.googlesource.gerrit.plugins.replication.ObservableQueue;
 import com.googlesource.gerrit.plugins.replication.pull.FetchResultProcessing.GitUpdateProcessing;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData;
+import com.googlesource.gerrit.plugins.replication.pull.api.exception.MissingParentObjectException;
+import com.googlesource.gerrit.plugins.replication.pull.api.exception.RefUpdateException;
 import com.googlesource.gerrit.plugins.replication.pull.client.FetchRestApiClient;
 import com.googlesource.gerrit.plugins.replication.pull.client.HttpResult;
+import java.io.IOException;
 import java.net.URISyntaxException;
 import java.util.HashSet;
+import java.util.Optional;
 import java.util.Queue;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ForkJoinPool;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
+import java.util.function.Consumer;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.transport.URIish;
 import org.slf4j.Logger;
@@ -59,6 +66,7 @@
   private FetchRestApiClient.Factory fetchClientFactory;
   private Integer fetchCallsTimeout;
   private RefsFilter refsFilter;
+  private RevisionReader revisionReader;
 
   @Inject
   ReplicationQueue(
@@ -67,7 +75,8 @@
       DynamicItem<EventDispatcher> dis,
       ReplicationStateListeners sl,
       FetchRestApiClient.Factory fetchClientFactory,
-      RefsFilter refsFilter) {
+      RefsFilter refsFilter,
+      RevisionReader revReader) {
     workQueue = wq;
     dispatcher = dis;
     sources = rd;
@@ -75,6 +84,7 @@
     beforeStartupEventsQueue = Queues.newConcurrentLinkedQueue();
     this.fetchClientFactory = fetchClientFactory;
     this.refsFilter = refsFilter;
+    this.revisionReader = revReader;
   }
 
   @Override
@@ -141,17 +151,10 @@
     ForkJoinPool fetchCallsPool = null;
     try {
       fetchCallsPool = new ForkJoinPool(sources.get().getAll().size());
+
+      final Consumer<Source> callFunction = callFunction(project, refName, state);
       fetchCallsPool
-          .submit(
-              () ->
-                  sources
-                      .get()
-                      .getAll()
-                      .parallelStream()
-                      .forEach(
-                          source -> {
-                            callFetch(source, project, refName, state);
-                          }))
+          .submit(() -> sources.get().getAll().parallelStream().forEach(callFunction))
           .get(fetchCallsTimeout, TimeUnit.MILLISECONDS);
     } catch (InterruptedException | ExecutionException | TimeoutException e) {
       stateLog.error(
@@ -167,6 +170,74 @@
     }
   }
 
+  private Consumer<Source> callFunction(NameKey project, String refName, ReplicationState state) {
+    CallFunction call = getCallFunction(project, refName, state);
+
+    return (source) -> {
+      try {
+        call.call(source);
+      } catch (MissingParentObjectException e) {
+        callFetch(source, project, refName, state);
+      }
+    };
+  }
+
+  private CallFunction getCallFunction(NameKey project, String refName, ReplicationState state) {
+    try {
+      Optional<RevisionData> revisionData = revisionReader.read(project, refName);
+      if (revisionData.isPresent()) {
+        return ((source) -> callSendObject(source, project, refName, revisionData.get(), state));
+      }
+    } catch (IOException | RefUpdateException e) {
+      stateLog.error(
+          String.format(
+              "Exception during reading ref: %s, project:%s, message: %s",
+              refName, project.get(), e.getMessage()),
+          e,
+          state);
+    }
+
+    return (source) -> callFetch(source, project, refName, state);
+  }
+
+  private void callSendObject(
+      Source source,
+      Project.NameKey project,
+      String refName,
+      RevisionData revision,
+      ReplicationState state)
+      throws MissingParentObjectException {
+    if (source.wouldFetchProject(project) && source.wouldFetchRef(refName)) {
+      for (String apiUrl : source.getApis()) {
+        try {
+          URIish uri = new URIish(apiUrl);
+          FetchRestApiClient fetchClient = fetchClientFactory.create(source);
+
+          HttpResult result = fetchClient.callSendObject(project, refName, revision, uri);
+          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 (result.isParentObjectMissing()) {
+              throw new MissingParentObjectException(
+                  project, refName, source.getRemoteConfigName());
+            }
+          }
+        } catch (URISyntaxException e) {
+          stateLog.error(String.format("Cannot parse pull replication api url:%s", apiUrl), state);
+        } catch (IOException e) {
+          stateLog.error(
+              String.format(
+                  "Exception during the pull replication fetch rest api call. Endpoint url:%s, message:%s",
+                  apiUrl, e.getMessage()),
+              e,
+              state);
+        }
+      }
+    }
+  }
+
   private void callFetch(
       Source source, Project.NameKey project, String refName, ReplicationState state) {
     if (source.wouldFetchProject(project) && source.wouldFetchRef(refName)) {
@@ -176,7 +247,6 @@
           FetchRestApiClient fetchClient = fetchClientFactory.create(source);
 
           HttpResult result = fetchClient.callFetch(project, refName, uri);
-
           if (!result.isSuccessful()) {
             stateLog.warn(
                 String.format(
@@ -185,7 +255,7 @@
                 state);
           }
         } catch (URISyntaxException e) {
-          stateLog.warn(String.format("Cannot parse pull replication api url:%s", apiUrl), state);
+          stateLog.error(String.format("Cannot parse pull replication api url:%s", apiUrl), state);
         } catch (Exception e) {
           stateLog.error(
               String.format(
@@ -227,4 +297,9 @@
 
     public abstract ObjectId objectId();
   }
+
+  @FunctionalInterface
+  private interface CallFunction {
+    void 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
new file mode 100644
index 0000000..d88c5f5
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReader.java
@@ -0,0 +1,162 @@
+// Copyright (C) 2020 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 static com.googlesource.gerrit.plugins.replication.pull.PullReplicationLogger.repLog;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionObjectData;
+import com.googlesource.gerrit.plugins.replication.pull.api.exception.RefUpdateException;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffEntry.ChangeType;
+import org.eclipse.jgit.errors.CorruptObjectException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.LargeObjectException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
+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;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.filter.TreeFilter;
+
+public class RevisionReader {
+  private static final String CONFIG_MAX_API_PAYLOAD_SIZE = "maxApiPayloadSize";
+  private static final Long DEFAULT_MAX_PAYLOAD_SIZE_IN_BYTES = 10000L;
+  private GitRepositoryManager gitRepositoryManager;
+  private Long maxRefSize;
+
+  @Inject
+  public RevisionReader(GitRepositoryManager gitRepositoryManager, Config cfg) {
+    this.gitRepositoryManager = gitRepositoryManager;
+    this.maxRefSize =
+        cfg.getLong("replication", CONFIG_MAX_API_PAYLOAD_SIZE, DEFAULT_MAX_PAYLOAD_SIZE_IN_BYTES);
+  }
+
+  public Optional<RevisionData> read(Project.NameKey project, String refName)
+      throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
+          RepositoryNotFoundException, RefUpdateException, IOException {
+    try (Repository git = gitRepositoryManager.openRepository(project)) {
+      Ref head = git.exactRef(refName);
+      if (head == null) {
+        throw new RefUpdateException(
+            String.format("Cannot find ref %s in project %s", refName, project.get()));
+      }
+
+      Long totalRefSize = 0l;
+
+      ObjectLoader commitLoader = git.open(head.getObjectId());
+      totalRefSize += commitLoader.getSize();
+      verifySize(totalRefSize, commitLoader);
+
+      RevCommit commit = RevCommit.parse(commitLoader.getCachedBytes());
+      RevisionObjectData commitRev =
+          new RevisionObjectData(commit.getType(), commitLoader.getCachedBytes());
+
+      RevTree tree = commit.getTree();
+      ObjectLoader treeLoader = git.open(commit.getTree().toObjectId());
+      totalRefSize += treeLoader.getSize();
+      verifySize(totalRefSize, treeLoader);
+
+      RevisionObjectData treeRev =
+          new RevisionObjectData(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);
+        } else {
+          walk.setRecursive(true);
+          walk.addTree(tree);
+          blobs = readBlobs(git, totalRefSize, walk);
+        }
+      }
+      return Optional.of(new RevisionData(commitRev, treeRev, blobs));
+    } catch (LargeObjectException e) {
+      repLog.trace(
+          "Ref %s size for project %s is greater than configured '%s'",
+          refName, project, CONFIG_MAX_API_PAYLOAD_SIZE);
+      return Optional.empty();
+    }
+  }
+
+  private List<DiffEntry> readDiffs(Repository git, RevCommit commit, RevTree tree, TreeWalk walk)
+      throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
+          IOException {
+    walk.setFilter(TreeFilter.ANY_DIFF);
+    walk.reset(getParentTree(git, commit), tree);
+    return DiffEntry.scan(walk, true);
+  }
+
+  private List<RevisionObjectData> readBlobs(Repository git, Long totalRefSize, TreeWalk walk)
+      throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
+          IOException {
+    List<RevisionObjectData> blobs = Lists.newLinkedList();
+    while (walk.next()) {
+      ObjectId objectId = walk.getObjectId(0);
+      ObjectLoader objectLoader = git.open(objectId);
+      totalRefSize += objectLoader.getSize();
+      verifySize(totalRefSize, objectLoader);
+
+      RevisionObjectData rev =
+          new RevisionObjectData(objectLoader.getType(), objectLoader.getCachedBytes());
+      blobs.add(rev);
+    }
+    return blobs;
+  }
+
+  private List<RevisionObjectData> readBlobs(
+      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());
+        totalRefSize += objectLoader.getSize();
+        verifySize(totalRefSize, objectLoader);
+        RevisionObjectData rev =
+            new RevisionObjectData(objectLoader.getType(), objectLoader.getCachedBytes());
+        blobs.add(rev);
+      }
+    }
+    return blobs;
+  }
+
+  private RevTree getParentTree(Repository git, RevCommit commit)
+      throws MissingObjectException, IOException {
+    RevCommit parent = commit.getParent(0);
+    ObjectLoader parentLoader = git.open(parent.getId());
+    RevCommit parentCommit = RevCommit.parse(parentLoader.getCachedBytes());
+    return parentCommit.getTree();
+  }
+
+  private void verifySize(Long totalRefSize, ObjectLoader loader) throws LargeObjectException {
+    if (loader.isLarge() || totalRefSize > maxRefSize) {
+      throw new LargeObjectException();
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/StartFetchCommand.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/StartFetchCommand.java
index a3497e6..6ababe9 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/StartFetchCommand.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/StartFetchCommand.java
@@ -15,6 +15,8 @@
 package com.googlesource.gerrit.plugins.replication.pull;
 
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.server.events.EventDispatcher;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
@@ -54,6 +56,8 @@
 
   @Inject private ReplicationState.Factory fetchReplicationStateFactory;
 
+  @Inject private DynamicItem<EventDispatcher> eventDispatcher;
+
   @Override
   protected void run() throws Failure {
     if (all && projectPatterns.size() > 0) {
@@ -61,7 +65,8 @@
     }
 
     ReplicationState state =
-        fetchReplicationStateFactory.create(new FetchResultProcessing.CommandProcessing(this));
+        fetchReplicationStateFactory.create(
+            new FetchResultProcessing.CommandProcessing(this, eventDispatcher.get()));
     Future<?> future = null;
 
     ReplicationFilter projectFilter;
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
new file mode 100644
index 0000000..db4edf5
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectAction.java
@@ -0,0 +1,151 @@
+// Copyright (C) 2020 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.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;
+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.config.UrlFormatter;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.ioutil.HexFormat;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionInput;
+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.Objects;
+import java.util.Optional;
+
+public class ApplyObjectAction implements RestModifyView<ProjectResource, RevisionInput> {
+
+  private final ApplyObjectCommand command;
+  private final WorkQueue workQueue;
+  private final DynamicItem<UrlFormatter> urlFormatter;
+  private final FetchPreconditions preConditions;
+
+  @Inject
+  public ApplyObjectAction(
+      ApplyObjectCommand command,
+      WorkQueue workQueue,
+      DynamicItem<UrlFormatter> urlFormatter,
+      FetchPreconditions preConditions) {
+    this.command = command;
+    this.workQueue = workQueue;
+    this.urlFormatter = urlFormatter;
+    this.preConditions = preConditions;
+  }
+
+  @Override
+  public Response<?> apply(ProjectResource resource, RevisionInput 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");
+      }
+
+      if (Objects.isNull(input.getRevisionData())) {
+        throw new BadRequestException("Ref-update revision data cannot be null or empty");
+      }
+
+      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");
+      }
+
+      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");
+      }
+
+      if (input.isAsync()) {
+        return applyAsync(resource.getNameKey(), input);
+      }
+      return applySync(resource.getNameKey(), input);
+    } catch (MissingParentObjectException e) {
+      throw new ResourceConflictException(e.getMessage(), e);
+    } catch (NumberFormatException | IOException e) {
+      throw RestApiException.wrap(e.getMessage(), e);
+    } catch (RefUpdateException e) {
+      throw new UnprocessableEntityException(e.getMessage());
+    }
+  }
+
+  private Response<?> applySync(Project.NameKey project, RevisionInput input)
+      throws NumberFormatException, IOException, RefUpdateException, MissingParentObjectException {
+    command.applyObject(project, input.getRefName(), input.getRevisionData(), input.getLabel());
+    return Response.created(input);
+  }
+
+  private Response.Accepted applyAsync(Project.NameKey project, RevisionInput input) {
+    @SuppressWarnings("unchecked")
+    WorkQueue.Task<Void> task =
+        (WorkQueue.Task<Void>)
+            workQueue.getDefaultQueue().submit(new ApplyObjectJob(command, project, input));
+    Optional<String> url =
+        urlFormatter
+            .get()
+            .getRestUrl("a/config/server/tasks/" + HexFormat.fromInt(task.getTaskId()));
+    // We're in a HTTP handler, so must be present.
+    checkState(url.isPresent());
+    return Response.accepted(url.get());
+  }
+
+  private static class ApplyObjectJob implements Runnable {
+    private static final FluentLogger log = FluentLogger.forEnclosingClass();
+
+    private ApplyObjectCommand command;
+    private Project.NameKey project;
+    private RevisionInput input;
+
+    public ApplyObjectJob(
+        ApplyObjectCommand command, Project.NameKey project, RevisionInput input) {
+      this.command = command;
+      this.project = project;
+      this.input = input;
+    }
+
+    @Override
+    public void run() {
+      try {
+        command.applyObject(project, input.getRefName(), input.getRevisionData(), input.getLabel());
+      } catch (IOException | RefUpdateException | MissingParentObjectException e) {
+        log.atSevere().withCause(e).log(
+            "Exception during the applyObject call for project {} and ref name {}",
+            project.get(),
+            input.getRefName());
+      }
+    }
+  }
+}
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
new file mode 100644
index 0000000..ac7e79b
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommand.java
@@ -0,0 +1,120 @@
+// Copyright (C) 2020 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 static java.util.concurrent.TimeUnit.NANOSECONDS;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.server.events.EventDispatcher;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.replication.pull.ApplyObjectMetrics;
+import com.googlesource.gerrit.plugins.replication.pull.FetchRefReplicatedEvent;
+import com.googlesource.gerrit.plugins.replication.pull.PullReplicationStateLogger;
+import com.googlesource.gerrit.plugins.replication.pull.ReplicationState;
+import com.googlesource.gerrit.plugins.replication.pull.ReplicationState.RefFetchResult;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData;
+import com.googlesource.gerrit.plugins.replication.pull.api.exception.MissingParentObjectException;
+import com.googlesource.gerrit.plugins.replication.pull.api.exception.RefUpdateException;
+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.Set;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.transport.RefSpec;
+
+public class ApplyObjectCommand {
+
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final Set<RefUpdate.Result> SUCCESSFUL_RESULTS =
+      ImmutableSet.of(
+          RefUpdate.Result.NEW,
+          RefUpdate.Result.FORCED,
+          RefUpdate.Result.NO_CHANGE,
+          RefUpdate.Result.FAST_FORWARD);
+
+  private final PullReplicationStateLogger fetchStateLog;
+  private final ApplyObject applyObject;
+  private final ApplyObjectMetrics metrics;
+  private final DynamicItem<EventDispatcher> eventDispatcher;
+
+  @Inject
+  public ApplyObjectCommand(
+      PullReplicationStateLogger fetchStateLog,
+      ApplyObject applyObject,
+      ApplyObjectMetrics metrics,
+      DynamicItem<EventDispatcher> eventDispatcher) {
+    this.fetchStateLog = fetchStateLog;
+    this.applyObject = applyObject;
+    this.metrics = metrics;
+    this.eventDispatcher = eventDispatcher;
+  }
+
+  public void applyObject(
+      Project.NameKey name, String refName, RevisionData revisionData, String sourceLabel)
+      throws IOException, RefUpdateException, MissingParentObjectException {
+
+    repLog.info("Apply object from {} for project {}, ref name {}", sourceLabel, name, refName);
+    Timer1.Context<String> context = metrics.start(sourceLabel);
+    RefUpdateState refUpdateState = applyObject.apply(name, new RefSpec(refName), revisionData);
+    long elapsed = NANOSECONDS.toMillis(context.stop());
+
+    try {
+      eventDispatcher
+          .get()
+          .postEvent(
+              new FetchRefReplicatedEvent(
+                  name.get(),
+                  refName,
+                  sourceLabel,
+                  getStatus(refUpdateState),
+                  refUpdateState.getResult()));
+    } catch (PermissionBackendException e) {
+      logger.atSevere().withCause(e).log(
+          "Cannot post event for ref '%s', project %s", refName, name);
+    }
+
+    if (!isSuccessful(refUpdateState.getResult())) {
+      String message =
+          String.format(
+              "RefUpdate failed with result %s for: sourceLcabel=%s, project=%s, refName=%s",
+              refUpdateState.getResult().name(), sourceLabel, name, refName);
+      fetchStateLog.error(message);
+      throw new RefUpdateException(message);
+    }
+    repLog.info(
+        "Apply object from {} for project {}, ref name {} completed in {}ms",
+        sourceLabel,
+        name,
+        refName,
+        elapsed);
+  }
+
+  private RefFetchResult getStatus(RefUpdateState refUpdateState) {
+    return isSuccessful(refUpdateState.getResult())
+        ? ReplicationState.RefFetchResult.SUCCEEDED
+        : ReplicationState.RefFetchResult.FAILED;
+  }
+
+  private Boolean isSuccessful(RefUpdate.Result result) {
+    return SUCCESSFUL_RESULTS.contains(result);
+  }
+}
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 7470c2c..1088d9b 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
@@ -15,6 +15,8 @@
 package com.googlesource.gerrit.plugins.replication.pull.api;
 
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.server.events.EventDispatcher;
 import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.replication.pull.Command;
 import com.googlesource.gerrit.plugins.replication.pull.FetchResultProcessing;
@@ -34,22 +36,26 @@
   private ReplicationState.Factory fetchReplicationStateFactory;
   private PullReplicationStateLogger fetchStateLog;
   private SourcesCollection sources;
+  private final DynamicItem<EventDispatcher> eventDispatcher;
 
   @Inject
   public FetchCommand(
       ReplicationState.Factory fetchReplicationStateFactory,
       PullReplicationStateLogger fetchStateLog,
-      SourcesCollection sources) {
+      SourcesCollection sources,
+      DynamicItem<EventDispatcher> eventDispatcher) {
     this.fetchReplicationStateFactory = fetchReplicationStateFactory;
     this.fetchStateLog = fetchStateLog;
     this.sources = sources;
+    this.eventDispatcher = eventDispatcher;
   }
 
   public void fetch(Project.NameKey name, String label, String refName)
       throws InterruptedException, ExecutionException, RemoteConfigurationMissingException,
           TimeoutException {
     ReplicationState state =
-        fetchReplicationStateFactory.create(new FetchResultProcessing.CommandProcessing(this));
+        fetchReplicationStateFactory.create(
+            new FetchResultProcessing.CommandProcessing(this, eventDispatcher.get()));
     Optional<Source> source =
         sources.getAll().stream().filter(s -> s.getRemoteConfigName().equals(label)).findFirst();
     if (!source.isPresent()) {
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 1663ad2..f94f49b 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
@@ -26,7 +26,9 @@
   @Override
   protected void configure() {
     bind(FetchAction.class).in(Scopes.SINGLETON);
+    bind(ApplyObjectAction.class).in(Scopes.SINGLETON);
     post(PROJECT_KIND, "fetch").to(FetchAction.class);
+    post(PROJECT_KIND, "apply-object").to(ApplyObjectAction.class);
 
     bind(FetchPreconditions.class).in(Scopes.SINGLETON);
     bind(CapabilityDefinition.class)
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
new file mode 100644
index 0000000..bcd4e05
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionData.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2020 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.List;
+
+public class RevisionData {
+  private RevisionObjectData commitObject;
+
+  private RevisionObjectData treeObject;
+
+  private List<RevisionObjectData> blobs;
+
+  public RevisionData(
+      RevisionObjectData commitObject,
+      RevisionObjectData treeObject,
+      List<RevisionObjectData> blobs) {
+    this.commitObject = commitObject;
+    this.treeObject = treeObject;
+    this.blobs = blobs;
+  }
+
+  public RevisionObjectData getCommitObject() {
+    return commitObject;
+  }
+
+  public RevisionObjectData getTreeObject() {
+    return treeObject;
+  }
+
+  public List<RevisionObjectData> getBlobs() {
+    return 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
new file mode 100644
index 0000000..160a720
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionInput.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2020 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;
+
+public class RevisionInput {
+  private String label;
+
+  private String refName;
+
+  private RevisionData revisionData;
+
+  private boolean async;
+
+  public RevisionInput(String label, String refName, RevisionData revisionData) {
+    this(label, refName, revisionData, false);
+  }
+
+  public RevisionInput(String label, String refName, RevisionData revisionData, boolean async) {
+    this.label = label;
+    this.refName = refName;
+    this.revisionData = revisionData;
+    this.async = async;
+  }
+
+  public String getLabel() {
+    return label;
+  }
+
+  public String getRefName() {
+    return refName;
+  }
+
+  public RevisionData getRevisionData() {
+    return revisionData;
+  }
+
+  public boolean isAsync() {
+    return async;
+  }
+}
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
new file mode 100644
index 0000000..02ba06c
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/RevisionObjectData.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2020 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.Base64;
+
+public class RevisionObjectData {
+  private final Integer type;
+  private final String content;
+
+  public RevisionObjectData(int type, byte[] content) {
+    this.type = type;
+    this.content = content == null ? "" : Base64.getEncoder().encodeToString(content);
+  }
+
+  public Integer getType() {
+    return type;
+  }
+
+  public byte[] getContent() {
+    return Base64.getDecoder().decode(content);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/exception/MissingParentObjectException.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/exception/MissingParentObjectException.java
new file mode 100644
index 0000000..a96c1ea
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/exception/MissingParentObjectException.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2020 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.exception;
+
+import com.google.gerrit.entities.Project;
+import org.eclipse.jgit.lib.ObjectId;
+
+public class MissingParentObjectException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public MissingParentObjectException(
+      Project.NameKey project, String refName, ObjectId parentObjectId) {
+    super(
+        String.format(
+            "Missing parent object %s for project %s ref name: %s",
+            parentObjectId.getName(), project.get(), refName));
+  }
+
+  public MissingParentObjectException(Project.NameKey project, String refName, String targetName) {
+    super(
+        String.format(
+            "Missing parent object on %s for project %s ref name: %s",
+            targetName, project.get(), refName));
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/exception/RefUpdateException.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/exception/RefUpdateException.java
new file mode 100644
index 0000000..f02f9c5
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/exception/RefUpdateException.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2020 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.exception;
+
+public class RefUpdateException extends Exception {
+
+  private static final long serialVersionUID = 1L;
+
+  public RefUpdateException(String msg) {
+    super(msg);
+  }
+}
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 bdfc1c3..eed1afb 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
@@ -14,17 +14,24 @@
 
 package com.googlesource.gerrit.plugins.replication.pull.client;
 
+import static com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.net.MediaType;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
 import com.google.inject.Inject;
 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.Source;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionInput;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.util.Optional;
@@ -48,6 +55,9 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   static String GERRIT_ADMIN_PROTOCOL_PREFIX = "gerrit+";
 
+  private static final Gson GSON =
+      new GsonBuilder().setFieldNamingPolicy(LOWER_CASE_WITH_UNDERSCORES).create();
+
   public interface Factory {
     FetchRestApiClient create(Source source);
   }
@@ -56,16 +66,19 @@
   private final SourceHttpClient.Factory httpClientFactory;
   private final Source source;
   private final String instanceLabel;
+  private final String pluginName;
 
   @Inject
   FetchRestApiClient(
       CredentialsFactory credentials,
       SourceHttpClient.Factory httpClientFactory,
       ReplicationConfig replicationConfig,
+      @PluginName String pluginName,
       @Assisted Source source) {
     this.credentials = credentials;
     this.httpClientFactory = httpClientFactory;
     this.source = source;
+    this.pluginName = pluginName;
     this.instanceLabel =
         Strings.nullToEmpty(
                 replicationConfig.getConfig().getString("replication", null, "instanceLabel"))
@@ -90,6 +103,23 @@
     return httpClientFactory.create(source).execute(post, this, getContext(targetUri));
   }
 
+  public HttpResult callSendObject(
+      Project.NameKey project, String refName, RevisionData revisionData, URIish targetUri)
+      throws ClientProtocolException, IOException {
+
+    RevisionInput input = new RevisionInput(instanceLabel, refName, revisionData);
+
+    String url =
+        String.format(
+            "%s/a/projects/%s/%s~apply-object",
+            targetUri.toString(), Url.encode(project.get()), pluginName);
+
+    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));
+  }
+
   @Override
   public HttpResult handleResponse(HttpResponse response) {
     Optional<String> responseBody = Optional.empty();
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 8a2b66d..dc01295 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
@@ -14,6 +14,7 @@
 
 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;
@@ -36,4 +37,8 @@
   public boolean isSuccessful() {
     return responseCode == SC_CREATED || responseCode == SC_NO_CONTENT || responseCode == SC_OK;
   }
+
+  public boolean isParentObjectMissing() {
+    return responseCode == SC_CONFLICT;
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/FetchRefReplicatedEventHandler.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/FetchRefReplicatedEventHandler.java
new file mode 100644
index 0000000..fc96394
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/FetchRefReplicatedEventHandler.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2021 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.event;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.EventListener;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.replication.pull.FetchRefReplicatedEvent;
+import com.googlesource.gerrit.plugins.replication.pull.ReplicationState;
+
+public class FetchRefReplicatedEventHandler implements EventListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private ChangeIndexer changeIndexer;
+
+  @Inject
+  FetchRefReplicatedEventHandler(ChangeIndexer changeIndexer) {
+    this.changeIndexer = changeIndexer;
+  }
+
+  @Override
+  public void onEvent(Event event) {
+    if (event instanceof FetchRefReplicatedEvent) {
+      FetchRefReplicatedEvent fetchRefReplicatedEvent = (FetchRefReplicatedEvent) event;
+      Project.NameKey projectNameKey = fetchRefReplicatedEvent.getProjectNameKey();
+      logger.atFine().log(
+          "Indexing ref '%s' for project %s",
+          fetchRefReplicatedEvent.getRefName(), projectNameKey.get());
+
+      Change.Id changeId = Change.Id.fromRef(fetchRefReplicatedEvent.getRefName());
+      if (changeId != null
+          && fetchRefReplicatedEvent
+              .getStatus()
+              .equals(ReplicationState.RefFetchResult.SUCCEEDED.toString())) {
+        changeIndexer.index(projectNameKey, changeId);
+      } else {
+        logger.atWarning().log(
+            "Couldn't get changeId from refName. Skipping indexing of change %s for project %s",
+            fetchRefReplicatedEvent.getRefName(), projectNameKey.get());
+      }
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/FetchRefReplicatedEventModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/FetchRefReplicatedEventModule.java
new file mode 100644
index 0000000..675563a
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/FetchRefReplicatedEventModule.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2021 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.event;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.events.EventListener;
+
+public class FetchRefReplicatedEventModule extends LifecycleModule {
+
+  @Override
+  protected void configure() {
+    DynamicSet.bind(binder(), EventListener.class).to(FetchRefReplicatedEventHandler.class);
+  }
+}
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
new file mode 100644
index 0000000..da63168
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObject.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2020 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.fetch;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.LocalDiskRepositoryManager;
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionObjectData;
+import com.googlesource.gerrit.plugins.replication.pull.api.exception.MissingParentObjectException;
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.RefSpec;
+
+public class ApplyObject {
+
+  private final GitRepositoryManager gitManager;
+
+  @Inject
+  public ApplyObject(LocalDiskRepositoryManager gitManager) {
+    this.gitManager = gitManager;
+  }
+
+  public RefUpdateState apply(Project.NameKey name, RefSpec refSpec, RevisionData revisionData)
+      throws MissingParentObjectException, IOException {
+    try (Repository git = gitManager.openRepository(name)) {
+
+      ObjectId newObjectID = null;
+      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());
+          }
+        }
+        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();
+      }
+      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/resources/Documentation/about.md b/src/main/resources/Documentation/about.md
index d921e0b..22b52e7 100644
--- a/src/main/resources/Documentation/about.md
+++ b/src/main/resources/Documentation/about.md
@@ -15,3 +15,8 @@
 To be allowed to trigger pull replication a user must be a member of a
 group that is granted the 'Pull Replication' capability (provided
 by this plugin) or the 'Administrate Server' capability.
+
+Change Indexing
+--------
+
+Changes will be automatically indexed upon replication.
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index ae2c2fd..c3e5ad4 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -181,6 +181,13 @@
 
     By default, all other refs are included.
 
+replication.maxApiPayloadSize
+:	Maximum size in bytes of the ref to be sent as a REST Api call
+	payload. For refs larger than threshold git fetch operation
+	will be used.
+
+	Default: 10000
+
 remote.NAME.url
 :	Address of the remote server to fetch from. Single URL can be
 	specified within a single remote block. A remote node can request
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/FetchGitUpdateProcessingTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/FetchGitUpdateProcessingTest.java
index f0f1c54..77dc947 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/FetchGitUpdateProcessingTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/FetchGitUpdateProcessingTest.java
@@ -21,6 +21,7 @@
 
 import com.google.gerrit.server.events.EventDispatcher;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.googlesource.gerrit.plugins.replication.pull.FetchResultProcessing.CommandProcessing;
 import com.googlesource.gerrit.plugins.replication.pull.FetchResultProcessing.GitUpdateProcessing;
 import com.googlesource.gerrit.plugins.replication.pull.ReplicationState.RefFetchResult;
 import java.net.URISyntaxException;
@@ -32,15 +33,20 @@
 public class FetchGitUpdateProcessingTest {
   private EventDispatcher dispatcherMock;
   private GitUpdateProcessing gitUpdateProcessing;
+  private CommandProcessing commandProcessing;
+  private Command sshCommandMock;
 
   @Before
   public void setUp() throws Exception {
     dispatcherMock = mock(EventDispatcher.class);
     gitUpdateProcessing = new GitUpdateProcessing(dispatcherMock);
+    sshCommandMock = mock(Command.class);
+    commandProcessing = new CommandProcessing(sshCommandMock, dispatcherMock);
   }
 
   @Test
-  public void headRefReplicated() throws URISyntaxException, PermissionBackendException {
+  public void headRefReplicatedInGitUpdateProcessing()
+      throws URISyntaxException, PermissionBackendException {
     FetchRefReplicatedEvent expectedEvent =
         new FetchRefReplicatedEvent(
             "someProject",
@@ -59,6 +65,26 @@
   }
 
   @Test
+  public void headRefReplicatedInCommandProcessing()
+      throws URISyntaxException, PermissionBackendException {
+    FetchRefReplicatedEvent expectedEvent =
+        new FetchRefReplicatedEvent(
+            "someProject",
+            "refs/heads/master",
+            "someHost",
+            RefFetchResult.SUCCEEDED,
+            RefUpdate.Result.NEW);
+
+    commandProcessing.onOneProjectReplicationDone(
+        "someProject",
+        "refs/heads/master",
+        new URIish("git://someHost/someProject.git"),
+        RefFetchResult.SUCCEEDED,
+        RefUpdate.Result.NEW);
+    verify(dispatcherMock, times(1)).postEvent(eq(expectedEvent));
+  }
+
+  @Test
   public void changeRefReplicated() throws URISyntaxException, PermissionBackendException {
     FetchRefReplicatedEvent expectedEvent =
         new FetchRefReplicatedEvent(
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 61520f4..44b69db 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
@@ -15,7 +15,14 @@
 package com.googlesource.gerrit.plugins.replication.pull;
 
 import static java.nio.file.Files.createTempDirectory;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyString;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
@@ -27,41 +34,129 @@
 import com.google.inject.Provider;
 import com.googlesource.gerrit.plugins.replication.ReplicationConfig;
 import com.googlesource.gerrit.plugins.replication.ReplicationFileBasedConfig;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData;
+import com.googlesource.gerrit.plugins.replication.pull.api.exception.RefUpdateException;
 import com.googlesource.gerrit.plugins.replication.pull.client.FetchRestApiClient;
+import com.googlesource.gerrit.plugins.replication.pull.client.HttpResult;
 import java.io.IOException;
 import java.nio.file.Path;
+import java.util.Optional;
+import org.apache.http.client.ClientProtocolException;
+import org.eclipse.jgit.errors.LargeObjectException;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.Mock;
-import org.mockito.Mockito;
 import org.mockito.junit.MockitoJUnitRunner;
 
 @RunWith(MockitoJUnitRunner.class)
 public class ReplicationQueueTest {
+  private static int CONNECTION_TIMEOUT = 1000000;
+
   @Mock private WorkQueue wq;
+  @Mock private Source source;
+  @Mock private SourcesCollection sourceCollection;
   @Mock private Provider<SourcesCollection> rd;
   @Mock private DynamicItem<EventDispatcher> dis;
   @Mock ReplicationStateListeners sl;
+  @Mock FetchRestApiClient fetchRestApiClient;
   @Mock FetchRestApiClient.Factory fetchClientFactory;
   @Mock AccountInfo accountInfo;
+  @Mock RevisionReader revReader;
+  @Mock RevisionData revisionData;
+  @Mock HttpResult httpResult;
 
-  RefsFilter refsFilter;
-
+  private RefsFilter refsFilter;
   private ReplicationQueue objectUnderTest;
   private SitePaths sitePaths;
   private Path pluginDataPath;
 
   @Before
-  public void setup() throws IOException {
+  public void setup() throws IOException, LargeObjectException, RefUpdateException {
     Path sitePath = createTempPath("site");
     sitePaths = new SitePaths(sitePath);
     Path pluginDataPath = createTempPath("data");
     ReplicationConfig replicationConfig = new ReplicationFileBasedConfig(sitePaths, pluginDataPath);
     refsFilter = new RefsFilter(replicationConfig);
-    objectUnderTest = new ReplicationQueue(wq, rd, dis, sl, fetchClientFactory, refsFilter);
+    when(source.getConnectionTimeout()).thenReturn(CONNECTION_TIMEOUT);
+    when(source.wouldFetchProject(any())).thenReturn(true);
+    when(source.wouldFetchRef(anyString())).thenReturn(true);
+    ImmutableList<String> apis = ImmutableList.of("http://localhost:18080");
+    when(source.getApis()).thenReturn(apis);
+    when(sourceCollection.getAll()).thenReturn(Lists.newArrayList(source));
+    when(rd.get()).thenReturn(sourceCollection);
+    when(revReader.read(any(), anyString())).thenReturn(Optional.of(revisionData));
+    when(fetchClientFactory.create(any())).thenReturn(fetchRestApiClient);
+    when(fetchRestApiClient.callSendObject(any(), anyString(), any(), any()))
+        .thenReturn(httpResult);
+    when(fetchRestApiClient.callFetch(any(), anyString(), any())).thenReturn(httpResult);
+    when(httpResult.isSuccessful()).thenReturn(true);
+
+    objectUnderTest =
+        new ReplicationQueue(wq, rd, dis, sl, fetchClientFactory, refsFilter, revReader);
+  }
+
+  @Test
+  public void shouldCallSendObjectWhenMetaRef() throws ClientProtocolException, IOException {
+    Event event = new TestEvent("refs/changes/01/1/meta");
+    objectUnderTest.start();
+    objectUnderTest.onGitReferenceUpdated(event);
+
+    verify(fetchRestApiClient).callSendObject(any(), anyString(), any(), any());
+  }
+
+  @Test
+  public void shouldCallSendObjectWhenPatchSetRef() throws ClientProtocolException, IOException {
+    Event event = new TestEvent("refs/changes/01/1/1");
+    objectUnderTest.start();
+    objectUnderTest.onGitReferenceUpdated(event);
+
+    verify(fetchRestApiClient).callSendObject(any(), anyString(), any(), any());
+  }
+
+  @Test
+  public void shouldFallbackToCallFetchWhenIOException()
+      throws ClientProtocolException, IOException, LargeObjectException, RefUpdateException {
+    Event event = new TestEvent("refs/changes/01/1/meta");
+    objectUnderTest.start();
+
+    when(revReader.read(any(), anyString())).thenThrow(IOException.class);
+
+    objectUnderTest.onGitReferenceUpdated(event);
+
+    verify(fetchRestApiClient).callFetch(any(), anyString(), any());
+  }
+
+  @Test
+  public void shouldFallbackToCallFetchWhenLargeRef()
+      throws ClientProtocolException, IOException, LargeObjectException, RefUpdateException {
+    Event event = new TestEvent("refs/changes/01/1/1");
+    objectUnderTest.start();
+
+    when(revReader.read(any(), anyString())).thenReturn(Optional.empty());
+
+    objectUnderTest.onGitReferenceUpdated(event);
+
+    verify(fetchRestApiClient).callFetch(any(), anyString(), any());
+  }
+
+  @Test
+  public void shouldFallbackToCallFetchWhenParentObjectIsMissing()
+      throws ClientProtocolException, IOException {
+    Event event = new TestEvent("refs/changes/01/1/meta");
+    objectUnderTest.start();
+
+    when(httpResult.isSuccessful()).thenReturn(false);
+    when(httpResult.isParentObjectMissing()).thenReturn(true);
+    when(fetchRestApiClient.callSendObject(any(), anyString(), any(), any()))
+        .thenReturn(httpResult);
+
+    objectUnderTest.onGitReferenceUpdated(event);
+
+    verify(fetchRestApiClient).callFetch(any(), anyString(), any());
   }
 
   @Test
@@ -69,7 +164,7 @@
     Event event = new TestEvent("refs/users/00/1000000");
     objectUnderTest.onGitReferenceUpdated(event);
 
-    Mockito.verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
+    verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
   }
 
   @Test
@@ -77,7 +172,7 @@
     Event event = new TestEvent("refs/groups/a1/a16d5b33cc789d60b682c654f03f9cc2feb12975");
     objectUnderTest.onGitReferenceUpdated(event);
 
-    Mockito.verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
+    verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
   }
 
   @Test
@@ -85,7 +180,7 @@
     Event event = new TestEvent("refs/meta/group-names");
     objectUnderTest.onGitReferenceUpdated(event);
 
-    Mockito.verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
+    verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
   }
 
   @Test
@@ -93,7 +188,7 @@
     Event event = new TestEvent("refs/sequences/changes");
     objectUnderTest.onGitReferenceUpdated(event);
 
-    Mockito.verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
+    verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
   }
 
   @Test
@@ -104,11 +199,13 @@
     fileConfig.save();
     ReplicationConfig replicationConfig = new ReplicationFileBasedConfig(sitePaths, pluginDataPath);
     refsFilter = new RefsFilter(replicationConfig);
-    objectUnderTest = new ReplicationQueue(wq, rd, dis, sl, fetchClientFactory, refsFilter);
+
+    objectUnderTest =
+        new ReplicationQueue(wq, rd, dis, sl, fetchClientFactory, refsFilter, revReader);
     Event event = new TestEvent("refs/multi-site/version");
     objectUnderTest.onGitReferenceUpdated(event);
 
-    Mockito.verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
+    verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
   }
 
   @Test
@@ -116,7 +213,7 @@
     Event event = new TestEvent("refs/starred-changes/41/2941/1000000");
     objectUnderTest.onGitReferenceUpdated(event);
 
-    Mockito.verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
+    verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
   }
 
   @Test
@@ -124,7 +221,7 @@
     Event event = new TestEvent("refs/meta/config");
     objectUnderTest.onGitReferenceUpdated(event);
 
-    Mockito.verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
+    verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
   }
 
   @Test
@@ -132,7 +229,7 @@
     Event event = new TestEvent("refs/meta/external-ids");
     objectUnderTest.onGitReferenceUpdated(event);
 
-    Mockito.verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
+    verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
   }
 
   protected static Path createTempPath(String prefix) throws IOException {
@@ -141,9 +238,17 @@
 
   private class TestEvent implements GitReferenceUpdatedListener.Event {
     private String refName;
+    private String projectName;
+    private ObjectId newObjectId;
 
     public TestEvent(String refName) {
+      this(refName, "defaultProject", ObjectId.zeroId());
+    }
+
+    public TestEvent(String refName, String projectName, ObjectId newObjectId) {
       this.refName = refName;
+      this.projectName = projectName;
+      this.newObjectId = newObjectId;
     }
 
     @Override
@@ -153,7 +258,7 @@
 
     @Override
     public String getProjectName() {
-      return null;
+      return projectName;
     }
 
     @Override
@@ -168,7 +273,7 @@
 
     @Override
     public String getNewObjectId() {
-      return null;
+      return newObjectId.getName();
     }
 
     @Override
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
new file mode 100644
index 0000000..ca410d5
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReaderIT.java
@@ -0,0 +1,121 @@
+// Copyright (C) 2020 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 static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.entities.Change;
+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.inject.Inject;
+import com.google.inject.Scopes;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionObjectData;
+import com.googlesource.gerrit.plugins.replication.pull.fetch.ApplyObject;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Constants;
+import org.junit.Test;
+
+@UseLocalDisk
+@TestPlugin(
+    name = "pull-replication",
+    sysModule = "com.googlesource.gerrit.plugins.replication.pull.RevisionReaderIT$TestModule")
+public class RevisionReaderIT extends LightweightPluginDaemonTest {
+  @Inject RevisionReader objectUnderTest;
+
+  @Test
+  public void shouldReadRefMetaObject() throws Exception {
+    Result pushResult = createChange();
+    String refName = RefNames.changeMetaRef(pushResult.getChange().getId());
+
+    Optional<RevisionData> revisionDataOption = objectUnderTest.read(project, refName);
+
+    assertThat(revisionDataOption.isPresent()).isTrue();
+    RevisionData revisionData = revisionDataOption.get();
+    assertThat(revisionData).isNotNull();
+    assertThat(revisionData.getCommitObject()).isNotNull();
+    assertThat(revisionData.getCommitObject().getType()).isEqualTo(Constants.OBJ_COMMIT);
+    assertThat(revisionData.getCommitObject().getContent()).isNotEmpty();
+
+    assertThat(revisionData.getTreeObject()).isNotNull();
+    assertThat(revisionData.getTreeObject().getType()).isEqualTo(Constants.OBJ_TREE);
+    assertThat(revisionData.getTreeObject().getContent()).isEmpty();
+
+    assertThat(revisionData.getBlobs()).isEmpty();
+  }
+
+  @Test
+  public void shouldReadRefMetaObjectWithComments() throws Exception {
+    Result pushResult = createChange();
+    Change.Id changeId = pushResult.getChange().getId();
+    String refName = RefNames.changeMetaRef(changeId);
+
+    CommentInput comment = createCommentInput(1, 0, 1, 1, "Test comment");
+
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment));
+    gApi.changes().id(changeId.get()).current().review(reviewInput);
+
+    Optional<RevisionData> revisionDataOption = objectUnderTest.read(project, refName);
+
+    assertThat(revisionDataOption.isPresent()).isTrue();
+    RevisionData revisionData = revisionDataOption.get();
+    assertThat(revisionData).isNotNull();
+    assertThat(revisionData.getCommitObject()).isNotNull();
+    assertThat(revisionData.getCommitObject().getType()).isEqualTo(Constants.OBJ_COMMIT);
+    assertThat(revisionData.getCommitObject().getContent()).isNotEmpty();
+
+    assertThat(revisionData.getTreeObject()).isNotNull();
+    assertThat(revisionData.getTreeObject().getType()).isEqualTo(Constants.OBJ_TREE);
+    assertThat(revisionData.getTreeObject().getContent()).isNotEmpty();
+
+    assertThat(revisionData.getBlobs()).hasSize(1);
+    RevisionObjectData blobObject = revisionData.getBlobs().get(0);
+    assertThat(blobObject.getType()).isEqualTo(Constants.OBJ_BLOB);
+    assertThat(blobObject.getContent()).isNotEmpty();
+  }
+
+  private CommentInput createCommentInput(
+      int startLine, int startCharacter, int endLine, int endCharacter, String message) {
+    CommentInput comment = new CommentInput();
+    comment.range = new Comment.Range();
+    comment.range.startLine = startLine;
+    comment.range.startCharacter = startCharacter;
+    comment.range.endLine = endLine;
+    comment.range.endCharacter = endCharacter;
+    comment.message = message;
+    comment.path = Patch.COMMIT_MSG;
+    return comment;
+  }
+
+  @SuppressWarnings("unused")
+  private static class TestModule extends FactoryModule {
+    @Override
+    protected void configure() {
+      bind(RevisionReader.class).in(Scopes.SINGLETON);
+      bind(ApplyObject.class);
+    }
+  }
+}
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
new file mode 100644
index 0000000..55fdee8
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionTest.java
@@ -0,0 +1,239 @@
+// Copyright (C) 2020 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.google.common.truth.Truth.assertThat;
+import static org.apache.http.HttpStatus.SC_ACCEPTED;
+import static org.apache.http.HttpStatus.SC_CREATED;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.registration.DynamicItem;
+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.server.config.UrlFormatter;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.WorkQueue.Task;
+import com.google.gerrit.server.project.ProjectResource;
+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.RevisionObjectData;
+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.Optional;
+import java.util.concurrent.ScheduledExecutorService;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.mockito.stubbing.Answer;
+
+@RunWith(MockitoJUnitRunner.class)
+public class ApplyObjectActionTest {
+  ApplyObjectAction applyObjectAction;
+  String label = "instance-2-label";
+  String url = "file:///gerrit-host/instance-1/git/${name}.git";
+  String refName = "refs/heads/master";
+  String location = "http://gerrit-host/a/config/server/tasks/08d173e9";
+  int taskId = 1234;
+
+  private String sampleCommitContent =
+      "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\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"
+          + "\n"
+          + "Update patch set 1\n"
+          + "\n"
+          + "Change has been successfully merged by Administrator\n"
+          + "\n"
+          + "Patch-set: 1\n"
+          + "Status: merged\n"
+          + "Tag: autogenerated:gerrit:merged\n"
+          + "Reviewer: Gerrit User 1000000 <1000000@69ec38f0-350e-4d9c-96d4-bc956f2faaac>\n"
+          + "Label: SUBM=+1\n"
+          + "Submission-id: 1904-1610471611558-783c0a2f\n"
+          + "Submitted-with: OK\n"
+          + "Submitted-with: OK: Code-Review: Gerrit User 1000000 <1000000@69ec38f0-350e-4d9c-96d4-bc956f2faaac>";
+
+  @Mock ApplyObjectCommand applyObjectCommand;
+  @Mock ProjectResource projectResource;
+  @Mock WorkQueue workQueue;
+  @Mock ScheduledExecutorService exceutorService;
+  @Mock DynamicItem<UrlFormatter> urlFormatterDynamicItem;
+  @Mock UrlFormatter urlFormatter;
+  @Mock WorkQueue.Task<Void> task;
+  @Mock FetchPreconditions preConditions;
+
+  @Before
+  public void setup() {
+    when(workQueue.getDefaultQueue()).thenReturn(exceutorService);
+    when(urlFormatter.getRestUrl(anyString())).thenReturn(Optional.of(location));
+    when(exceutorService.submit(any(Runnable.class)))
+        .thenAnswer(
+            new Answer<WorkQueue.Task<Void>>() {
+              @Override
+              public Task<Void> answer(InvocationOnMock invocation) throws Throwable {
+                return task;
+              }
+            });
+    when(urlFormatterDynamicItem.get()).thenReturn(urlFormatter);
+    when(task.getTaskId()).thenReturn(taskId);
+    when(preConditions.canCallFetchApi()).thenReturn(true);
+
+    applyObjectAction =
+        new ApplyObjectAction(
+            applyObjectCommand, workQueue, urlFormatterDynamicItem, preConditions);
+  }
+
+  @Test
+  public void shouldReturnCreatedResponseCode() throws RestApiException {
+    RevisionInput inputParams = new RevisionInput(label, refName, createSampleRevisionData());
+
+    Response<?> response = applyObjectAction.apply(projectResource, inputParams);
+
+    assertThat(response.statusCode()).isEqualTo(SC_CREATED);
+  }
+
+  @SuppressWarnings("cast")
+  @Test
+  public void shouldReturnSourceUrlAndrefNameAsAResponseBody() throws Exception {
+    RevisionInput inputParams = new RevisionInput(label, refName, createSampleRevisionData());
+    Response<?> response = applyObjectAction.apply(projectResource, inputParams);
+
+    assertThat((RevisionInput) response.value()).isEqualTo(inputParams);
+  }
+
+  @Test(expected = BadRequestException.class)
+  public void shouldThrowBadRequestExceptionWhenMissingLabel() throws Exception {
+    RevisionInput inputParams = new RevisionInput(null, refName, createSampleRevisionData());
+
+    applyObjectAction.apply(projectResource, inputParams);
+  }
+
+  @Test(expected = BadRequestException.class)
+  public void shouldThrowBadRequestExceptionWhenEmptyLabel() throws Exception {
+    RevisionInput inputParams = new RevisionInput("", refName, createSampleRevisionData());
+
+    applyObjectAction.apply(projectResource, inputParams);
+  }
+
+  @Test(expected = BadRequestException.class)
+  public void shouldThrowBadRequestExceptionWhenMissingRefName() throws Exception {
+    RevisionInput inputParams = new RevisionInput(label, null, createSampleRevisionData());
+
+    applyObjectAction.apply(projectResource, inputParams);
+  }
+
+  @Test(expected = BadRequestException.class)
+  public void shouldThrowBadRequestExceptionWhenEmptyRefName() throws Exception {
+    RevisionInput inputParams = new RevisionInput(label, "", createSampleRevisionData());
+
+    applyObjectAction.apply(projectResource, inputParams);
+  }
+
+  @Test(expected = BadRequestException.class)
+  public void shouldThrowBadRequestExceptionWhenMissingRevisionData() throws Exception {
+    RevisionInput inputParams = new RevisionInput(label, refName, null);
+
+    applyObjectAction.apply(projectResource, inputParams);
+  }
+
+  @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[] {});
+    RevisionInput inputParams =
+        new RevisionInput(label, refName, createSampleRevisionData(commitData, treeData));
+
+    applyObjectAction.apply(projectResource, inputParams);
+  }
+
+  @Test(expected = BadRequestException.class)
+  public void shouldThrowBadRequestExceptionWhenMissingTreeObject() throws Exception {
+    RevisionObjectData commitData =
+        new RevisionObjectData(Constants.OBJ_COMMIT, sampleCommitContent.getBytes());
+    RevisionInput inputParams =
+        new RevisionInput(label, refName, createSampleRevisionData(commitData, null));
+
+    applyObjectAction.apply(projectResource, inputParams);
+  }
+
+  @Test(expected = AuthException.class)
+  public void shouldThrowAuthExceptionWhenCallFetchActionCapabilityNotAssigned()
+      throws RestApiException {
+    RevisionInput inputParams = new RevisionInput(label, refName, createSampleRevisionData());
+
+    when(preConditions.canCallFetchApi()).thenReturn(false);
+
+    applyObjectAction.apply(projectResource, inputParams);
+  }
+
+  @Test(expected = ResourceConflictException.class)
+  public void shouldThrowResourceConflictExceptionWhenMissingParentObject()
+      throws RestApiException, IOException, RefUpdateException, MissingParentObjectException {
+    RevisionInput inputParams = new RevisionInput(label, refName, createSampleRevisionData());
+
+    doThrow(
+            new MissingParentObjectException(
+                Project.nameKey("test_projects"), refName, ObjectId.zeroId()))
+        .when(applyObjectCommand)
+        .applyObject(any(), anyString(), any(), anyString());
+
+    applyObjectAction.apply(projectResource, inputParams);
+  }
+
+  @Test
+  public void shouldReturnScheduledTaskForAsyncCall() throws RestApiException {
+    RevisionInput inputParams = new RevisionInput(label, refName, createSampleRevisionData(), true);
+
+    Response<?> response = applyObjectAction.apply(projectResource, inputParams);
+    assertThat(response.statusCode()).isEqualTo(SC_ACCEPTED);
+  }
+
+  @Test
+  public void shouldLocationHeaderForAsyncCall() throws Exception {
+    RevisionInput inputParams = new RevisionInput(label, refName, createSampleRevisionData(), true);
+
+    Response<?> response = applyObjectAction.apply(projectResource, inputParams);
+    assertThat(response).isInstanceOf(Response.Accepted.class);
+    Response.Accepted acceptResponse = (Response.Accepted) response;
+    assertThat(acceptResponse.location()).isEqualTo(location);
+  }
+
+  private RevisionData createSampleRevisionData() {
+    RevisionObjectData commitData =
+        new RevisionObjectData(Constants.OBJ_COMMIT, sampleCommitContent.getBytes());
+    RevisionObjectData treeData = new RevisionObjectData(Constants.OBJ_TREE, new byte[] {});
+    return createSampleRevisionData(commitData, treeData);
+  }
+
+  private RevisionData createSampleRevisionData(
+      RevisionObjectData commitData, RevisionObjectData treeData) {
+    return new RevisionData(commitData, treeData, Lists.newArrayList());
+  }
+}
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
new file mode 100644
index 0000000..51051c0
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommandTest.java
@@ -0,0 +1,100 @@
+// Copyright (C) 2021 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.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;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.EventDispatcher;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.googlesource.gerrit.plugins.replication.pull.ApplyObjectMetrics;
+import com.googlesource.gerrit.plugins.replication.pull.FetchRefReplicatedEvent;
+import com.googlesource.gerrit.plugins.replication.pull.PullReplicationStateLogger;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionObjectData;
+import com.googlesource.gerrit.plugins.replication.pull.api.exception.MissingParentObjectException;
+import com.googlesource.gerrit.plugins.replication.pull.api.exception.RefUpdateException;
+import com.googlesource.gerrit.plugins.replication.pull.fetch.ApplyObject;
+import com.googlesource.gerrit.plugins.replication.pull.fetch.RefUpdateState;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.RefUpdate;
+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 ApplyObjectCommandTest {
+  private static final String TEST_SOURCE_LABEL = "test-source-label";
+  private static final String TEST_REF_NAME = "refs/changes/01/1/1";
+  private static final NameKey TEST_PROJECT_NAME = Project.nameKey("test-project");
+  private static final String TEST_REMOTE_NAME = "test-remote-name";
+
+  @Mock private PullReplicationStateLogger fetchStateLog;
+  @Mock private ApplyObject applyObject;
+  @Mock private ApplyObjectMetrics metrics;
+  @Mock private DynamicItem<EventDispatcher> eventDispatcherDataItem;
+  @Mock private EventDispatcher eventDispatcher;
+  @Mock private Timer1.Context<String> timetContext;
+  @Captor ArgumentCaptor<Event> eventCaptor;
+
+  private ApplyObjectCommand objectUnderTest;
+
+  @Before
+  public void setup() throws MissingParentObjectException, IOException {
+    RefUpdateState state = new RefUpdateState(TEST_REMOTE_NAME, RefUpdate.Result.NEW);
+    when(eventDispatcherDataItem.get()).thenReturn(eventDispatcher);
+    when(metrics.start(anyString())).thenReturn(timetContext);
+    when(timetContext.stop()).thenReturn(100L);
+    when(applyObject.apply(any(), any(), any())).thenReturn(state);
+
+    objectUnderTest =
+        new ApplyObjectCommand(fetchStateLog, applyObject, metrics, eventDispatcherDataItem);
+  }
+
+  @Test
+  public void shouldSendEventWhenApplyObject()
+      throws PermissionBackendException, IOException, RefUpdateException,
+          MissingParentObjectException {
+    objectUnderTest.applyObject(
+        TEST_PROJECT_NAME, TEST_REF_NAME, createSampleRevisionData(), TEST_SOURCE_LABEL);
+
+    verify(eventDispatcher).postEvent(eventCaptor.capture());
+    Event sentEvent = eventCaptor.getValue();
+    assertThat(sentEvent).isInstanceOf(FetchRefReplicatedEvent.class);
+    FetchRefReplicatedEvent fetchEvent = (FetchRefReplicatedEvent) sentEvent;
+    assertThat(fetchEvent.getProjectNameKey()).isEqualTo(TEST_PROJECT_NAME);
+    assertThat(fetchEvent.getRefName()).isEqualTo(TEST_REF_NAME);
+  }
+
+  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());
+  }
+}
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 e625e0f..4cdad48 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
@@ -25,6 +25,8 @@
 
 import com.google.common.collect.Lists;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.server.events.EventDispatcher;
 import com.googlesource.gerrit.plugins.replication.pull.PullReplicationStateLogger;
 import com.googlesource.gerrit.plugins.replication.pull.ReplicationState;
 import com.googlesource.gerrit.plugins.replication.pull.Source;
@@ -51,6 +53,7 @@
   @Mock PullReplicationStateLogger fetchStateLog;
   @Mock Source source;
   @Mock SourcesCollection sources;
+  @Mock DynamicItem<EventDispatcher> eventDispatcher;
 
   @SuppressWarnings("rawtypes")
   @Mock
@@ -73,7 +76,8 @@
     when(sources.getAll()).thenReturn(Lists.newArrayList(source));
     when(source.schedule(projectName, REF_NAME_TO_FETCH, state, true))
         .thenReturn(CompletableFuture.completedFuture(null));
-    objectUnderTest = new FetchCommand(fetchReplicationStateFactory, fetchStateLog, sources);
+    objectUnderTest =
+        new FetchCommand(fetchReplicationStateFactory, fetchStateLog, sources, eventDispatcher);
   }
 
   @Test
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/FetchRestApiClientTest.java
index c62ddab..771acff 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/FetchRestApiClientTest.java
@@ -23,11 +23,14 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import com.google.common.collect.Lists;
 import com.google.gerrit.entities.Project;
 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.Source;
+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.net.URISyntaxException;
 import java.nio.ByteBuffer;
@@ -36,6 +39,7 @@
 import org.apache.http.client.ClientProtocolException;
 import org.apache.http.client.methods.HttpPost;
 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;
@@ -64,12 +68,59 @@
   @Captor ArgumentCaptor<HttpPost> httpPostCaptor;
 
   String api = "http://gerrit-host";
+  String pluginName = "pull-replication";
   String label = "Replication";
   String refName = RefNames.REFS_HEADS + "master";
 
   String expectedPayload = "{\"label\":\"Replication\", \"ref_name\": \"" + refName + "\"}";
   Header expectedHeader = new BasicHeader("Content-Type", "application/json");
 
+  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\"}]},\"async\":false}";
+  String commitObject =
+      "tree 77814d216a6cab2ddb9f2877fbbd0febdc0fa608\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"
+          + "\n"
+          + "Update patch set 1\n"
+          + "\n"
+          + "Patch Set 1:\n"
+          + "\n"
+          + "(1 comment)\n"
+          + "\n"
+          + "Patch-set: 1\n";
+  String treeObject =
+      "100644 blob bb383f5249c68a4cc8c82bdd1228b4a8883ff6e8    f75a69004a93b4ccc8ce215c12808636c2b75675";
+  String blobObject =
+      "{\n"
+          + "  \"comments\": [\n"
+          + "    {\n"
+          + "      \"key\": {\n"
+          + "        \"uuid\": \"90b5abff_4f67526a\",\n"
+          + "        \"filename\": \"/COMMIT_MSG\",\n"
+          + "        \"patchSetId\": 1\n"
+          + "      },\n"
+          + "      \"lineNbr\": 9,\n"
+          + "      \"author\": {\n"
+          + "        \"id\": 1000000\n"
+          + "      },\n"
+          + "      \"writtenOn\": \"2021-01-13T22:57:28Z\",\n"
+          + "      \"side\": 1,\n"
+          + "      \"message\": \"test comment\",\n"
+          + "      \"range\": {\n"
+          + "        \"startLine\": 9,\n"
+          + "        \"startChar\": 21,\n"
+          + "        \"endLine\": 9,\n"
+          + "        \"endChar\": 34\n"
+          + "      },\n"
+          + "      \"revId\": \"f75a69004a93b4ccc8ce215c12808636c2b75675\",\n"
+          + "      \"serverId\": \"69ec38f0-350e-4d9c-96d4-bc956f2faaac\",\n"
+          + "      \"unresolved\": true\n"
+          + "    }\n"
+          + "  ]\n"
+          + "}";
+
   FetchRestApiClient objectUnderTest;
 
   @Before
@@ -99,7 +150,8 @@
     when(httpClient.execute(any(HttpPost.class), any(), any())).thenReturn(httpResult);
     when(httpClientFactory.create(any())).thenReturn(httpClient);
     objectUnderTest =
-        new FetchRestApiClient(credentials, httpClientFactory, replicationConfig, source);
+        new FetchRestApiClient(
+            credentials, httpClientFactory, replicationConfig, pluginName, source);
   }
 
   @Test
@@ -142,11 +194,54 @@
   }
 
   @Test
+  public void shouldCallSendObjectEndpoint()
+      throws ClientProtocolException, IOException, URISyntaxException {
+
+    objectUnderTest.callSendObject(
+        Project.nameKey("test_repo"), refName, createSampleRevisionData(), new URIish(api));
+
+    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any(), 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");
+  }
+
+  @Test
+  public void shouldCallSendObjectEndpointWithPayload()
+      throws ClientProtocolException, IOException, URISyntaxException {
+
+    objectUnderTest.callSendObject(
+        Project.nameKey("test_repo"), refName, createSampleRevisionData(), new URIish(api));
+
+    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any(), any());
+
+    HttpPost httpPost = httpPostCaptor.getValue();
+    assertThat(readPayload(httpPost)).isEqualTo(expectedSendObjectPayload);
+  }
+
+  @Test
+  public void shouldSetContentTypeHeaderForSendObjectCall()
+      throws ClientProtocolException, IOException, URISyntaxException {
+
+    objectUnderTest.callFetch(Project.nameKey("test_repo"), refName, new URIish(api));
+
+    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any(), any());
+
+    HttpPost httpPost = httpPostCaptor.getValue();
+    assertThat(httpPost.getLastHeader("Content-Type").getValue())
+        .isEqualTo(expectedHeader.getValue());
+  }
+
+  @Test
   public void shouldThrowExceptionWhenInstanceLabelIsNull() {
     when(config.getString("replication", null, "instanceLabel")).thenReturn(null);
     assertThrows(
         NullPointerException.class,
-        () -> new FetchRestApiClient(credentials, httpClientFactory, replicationConfig, source));
+        () ->
+            new FetchRestApiClient(
+                credentials, httpClientFactory, replicationConfig, pluginName, source));
   }
 
   @Test
@@ -154,7 +249,9 @@
     when(config.getString("replication", null, "instanceLabel")).thenReturn(" ");
     assertThrows(
         NullPointerException.class,
-        () -> new FetchRestApiClient(credentials, httpClientFactory, replicationConfig, source));
+        () ->
+            new FetchRestApiClient(
+                credentials, httpClientFactory, replicationConfig, pluginName, source));
   }
 
   @Test
@@ -162,11 +259,21 @@
     when(config.getString("replication", null, "instanceLabel")).thenReturn("");
     assertThrows(
         NullPointerException.class,
-        () -> new FetchRestApiClient(credentials, httpClientFactory, replicationConfig, source));
+        () ->
+            new FetchRestApiClient(
+                credentials, httpClientFactory, replicationConfig, pluginName, source));
   }
 
   public String readPayload(HttpPost entity) throws UnsupportedOperationException, IOException {
     ByteBuffer buf = IO.readWholeStream(entity.getEntity().getContent(), 1024);
     return RawParseUtils.decode(buf.array(), buf.arrayOffset(), buf.limit()).trim();
   }
+
+  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));
+  }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/event/FetchRefReplicatedEventHandlerTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/event/FetchRefReplicatedEventHandlerTest.java
new file mode 100644
index 0000000..1d87195
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/event/FetchRefReplicatedEventHandlerTest.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2021 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.event;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.googlesource.gerrit.plugins.replication.pull.FetchRefReplicatedEvent;
+import com.googlesource.gerrit.plugins.replication.pull.ReplicationState;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.junit.Before;
+import org.junit.Test;
+
+public class FetchRefReplicatedEventHandlerTest {
+  private ChangeIndexer changeIndexerMock;
+  private FetchRefReplicatedEventHandler fetchRefReplicatedEventHandler;
+
+  @Before
+  public void setUp() throws Exception {
+    changeIndexerMock = mock(ChangeIndexer.class);
+    fetchRefReplicatedEventHandler = new FetchRefReplicatedEventHandler(changeIndexerMock);
+  }
+
+  @Test
+  public void onEventShouldIndexExistingChange() {
+    Project.NameKey projectNameKey = Project.nameKey("testProject");
+    String ref = "refs/changes/41/41/meta";
+    Change.Id changeId = Change.Id.fromRef(ref);
+    fetchRefReplicatedEventHandler.onEvent(
+        new FetchRefReplicatedEvent(
+            projectNameKey.get(),
+            ref,
+            "aSourceNode",
+            ReplicationState.RefFetchResult.SUCCEEDED,
+            RefUpdate.Result.FAST_FORWARD));
+    verify(changeIndexerMock, times(1)).index(eq(projectNameKey), eq(changeId));
+  }
+
+  @Test
+  public void onEventShouldNotIndexMissingChange() {
+    fetchRefReplicatedEventHandler.onEvent(
+        new FetchRefReplicatedEvent(
+            Project.nameKey("testProject").get(),
+            "invalidRef",
+            "aSourceNode",
+            ReplicationState.RefFetchResult.SUCCEEDED,
+            RefUpdate.Result.FAST_FORWARD));
+    verify(changeIndexerMock, never()).index(any(), any());
+  }
+
+  @Test
+  public void onEventShouldNotIndexFailingChange() {
+    Project.NameKey projectNameKey = Project.nameKey("testProject");
+    String ref = "refs/changes/41/41/meta";
+    fetchRefReplicatedEventHandler.onEvent(
+        new FetchRefReplicatedEvent(
+            projectNameKey.get(),
+            ref,
+            "aSourceNode",
+            ReplicationState.RefFetchResult.FAILED,
+            RefUpdate.Result.FAST_FORWARD));
+    verify(changeIndexerMock, never()).index(any(), any());
+  }
+
+  @Test
+  public void onEventShouldNotIndexNotAttemptedChange() {
+    Project.NameKey projectNameKey = Project.nameKey("testProject");
+    String ref = "refs/changes/41/41/meta";
+    fetchRefReplicatedEventHandler.onEvent(
+        new FetchRefReplicatedEvent(
+            projectNameKey.get(),
+            ref,
+            "aSourceNode",
+            ReplicationState.RefFetchResult.NOT_ATTEMPTED,
+            RefUpdate.Result.FAST_FORWARD));
+    verify(changeIndexerMock, never()).index(any(), any());
+  }
+}
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
new file mode 100644
index 0000000..5d80d94
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObjectIT.java
@@ -0,0 +1,191 @@
+// Copyright (C) 2020 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.fetch;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.primitives.Bytes;
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.SkipProjectClone;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Project;
+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.inject.Inject;
+import com.google.inject.Scopes;
+import com.googlesource.gerrit.plugins.replication.pull.RevisionReader;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionObjectData;
+import com.googlesource.gerrit.plugins.replication.pull.api.exception.MissingParentObjectException;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.RefSpec;
+import org.junit.Test;
+
+@SkipProjectClone
+@UseLocalDisk
+@TestPlugin(
+    name = "pull-replication",
+    sysModule = "com.googlesource.gerrit.plugins.replication.pull.fetch.ApplyObjectIT$TestModule")
+public class ApplyObjectIT extends LightweightPluginDaemonTest {
+  private static final String TEST_REPLICATION_SUFFIX = "suffix1";
+
+  @Inject private ProjectOperations projectOperations;
+  @Inject RevisionReader reader;
+  @Inject ApplyObject objectUnderTest;
+
+  @Test
+  public void shouldApplyRefMetaObject() throws Exception {
+    String testRepoProjectName = project + TEST_REPLICATION_SUFFIX;
+    testRepo = cloneProject(createTestProject(testRepoProjectName));
+
+    Result pushResult = createChange();
+    String refName = RefNames.changeMetaRef(pushResult.getChange().getId());
+
+    Optional<RevisionData> revisionData =
+        reader.read(Project.nameKey(testRepoProjectName), refName);
+
+    RefSpec refSpec = new RefSpec(refName);
+    objectUnderTest.apply(project, refSpec, revisionData.get());
+    Optional<RevisionData> newRevisionData = reader.read(project, refName);
+
+    compareObjects(revisionData.get(), newRevisionData);
+
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<Repository> testRepo = new TestRepository<>(repo); ) {
+      testRepo.fsck();
+    }
+  }
+
+  @Test
+  public void shouldApplyRefMetaObjectWithComments() throws Exception {
+    String testRepoProjectName = project + TEST_REPLICATION_SUFFIX;
+    testRepo = cloneProject(createTestProject(testRepoProjectName));
+
+    Result pushResult = createChange();
+    Change.Id changeId = pushResult.getChange().getId();
+    String refName = RefNames.changeMetaRef(changeId);
+    RefSpec refSpec = new RefSpec(refName);
+
+    Optional<RevisionData> revisionData =
+        reader.read(Project.nameKey(testRepoProjectName), refName);
+    objectUnderTest.apply(project, refSpec, revisionData.get());
+
+    ReviewInput reviewInput = new ReviewInput();
+    CommentInput comment = createCommentInput(1, 0, 1, 1, "Test comment");
+    reviewInput.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment));
+    gApi.changes().id(changeId.get()).current().review(reviewInput);
+
+    Optional<RevisionData> revisionDataWithComment =
+        reader.read(Project.nameKey(testRepoProjectName), refName);
+
+    objectUnderTest.apply(project, refSpec, revisionDataWithComment.get());
+
+    Optional<RevisionData> newRevisionData = reader.read(project, refName);
+
+    compareObjects(revisionDataWithComment.get(), newRevisionData);
+
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<Repository> testRepo = new TestRepository<>(repo)) {
+      testRepo.fsck();
+    }
+  }
+
+  @Test
+  public void shouldThrowExceptionWhenParentCommitObjectIsMissing() throws Exception {
+    String testRepoProjectName = project + TEST_REPLICATION_SUFFIX;
+    testRepo = cloneProject(createTestProject(testRepoProjectName));
+
+    Result pushResult = createChange();
+    Change.Id changeId = pushResult.getChange().getId();
+    String refName = RefNames.changeMetaRef(changeId);
+
+    CommentInput comment = createCommentInput(1, 0, 1, 1, "Test comment");
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment));
+    gApi.changes().id(changeId.get()).current().review(reviewInput);
+
+    Optional<RevisionData> revisionData =
+        reader.read(Project.nameKey(testRepoProjectName), refName);
+
+    RefSpec refSpec = new RefSpec(refName);
+    assertThrows(
+        MissingParentObjectException.class,
+        () -> objectUnderTest.apply(project, refSpec, revisionData.get()));
+  }
+
+  private void compareObjects(RevisionData expected, Optional<RevisionData> actualOption) {
+    assertThat(actualOption.isPresent()).isTrue();
+    RevisionData actual = actualOption.get();
+    compareContent(expected.getCommitObject(), actual.getCommitObject());
+    compareContent(expected.getTreeObject(), expected.getTreeObject());
+    List<List<Byte>> actualBlobs =
+        actual.getBlobs().stream()
+            .map(revision -> Bytes.asList(revision.getContent()))
+            .collect(Collectors.toList());
+    List<List<Byte>> expectedBlobs =
+        expected.getBlobs().stream()
+            .map(revision -> Bytes.asList(revision.getContent()))
+            .collect(Collectors.toList());
+    assertThat(actualBlobs).containsExactlyElementsIn(expectedBlobs);
+  }
+
+  private void compareContent(RevisionObjectData expected, RevisionObjectData actual) {
+    assertThat(actual.getType()).isEqualTo(expected.getType());
+    assertThat(Bytes.asList(actual.getContent()))
+        .containsExactlyElementsIn(Bytes.asList(expected.getContent()))
+        .inOrder();
+  }
+
+  private CommentInput createCommentInput(
+      int startLine, int startCharacter, int endLine, int endCharacter, String message) {
+    CommentInput comment = new CommentInput();
+    comment.range = new Comment.Range();
+    comment.range.startLine = startLine;
+    comment.range.startCharacter = startCharacter;
+    comment.range.endLine = endLine;
+    comment.range.endCharacter = endCharacter;
+    comment.message = message;
+    comment.path = Patch.COMMIT_MSG;
+    return comment;
+  }
+
+  private Project.NameKey createTestProject(String name) throws Exception {
+    return projectOperations.newProject().name(name).create();
+  }
+
+  @SuppressWarnings("unused")
+  private static class TestModule extends FactoryModule {
+    @Override
+    protected void configure() {
+      bind(RevisionReader.class).in(Scopes.SINGLETON);
+      bind(ApplyObject.class);
+    }
+  }
+}