Merge branch 'stable-2.8'

* stable-2.8:
  Update Gerrit API version to 2.8 in pom.xml

Conflicts:
	pom.xml

Change-Id: I0af06ff7633e8512f19f81ac013c2642c14d96e9
diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs
index 2f45466..17904c0 100644
--- a/.settings/org.eclipse.jdt.core.prefs
+++ b/.settings/org.eclipse.jdt.core.prefs
@@ -1,9 +1,9 @@
 #Fri Jul 16 23:39:13 PDT 2010
 eclipse.preferences.version=1
-org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.6
-org.eclipse.jdt.core.compiler.compliance=1.6
+org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.7
+org.eclipse.jdt.core.compiler.compliance=1.7
 org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
-org.eclipse.jdt.core.compiler.source=1.6
+org.eclipse.jdt.core.compiler.source=1.7
 org.eclipse.jdt.core.formatter.align_type_members_on_columns=false
 org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression=16
 org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant=16
diff --git a/BUCK b/BUCK
index d8a9358..0018323 100644
--- a/BUCK
+++ b/BUCK
@@ -17,6 +17,11 @@
   srcs = glob(['src/test/java/**/*.java']),
   deps = [
     ':replication__plugin__compile',
+    '//gerrit-common:server',
+    '//gerrit-reviewdb:server',
+    '//gerrit-server:server',
+    '//lib:easymock',
+    '//lib:gwtorm',
     '//lib:junit',
     '//lib/jgit:jgit',
   ],
diff --git a/pom.xml b/pom.xml
index 73f1f39..b09964f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -23,7 +23,7 @@
   <artifactId>replication</artifactId>
   <name>replication</name>
   <packaging>jar</packaging>
-  <version>2.8</version>
+  <version>2.9-SNAPSHOT</version>
 
   <properties>
     <Gerrit-ApiType>plugin</Gerrit-ApiType>
@@ -62,8 +62,8 @@
         <artifactId>maven-compiler-plugin</artifactId>
         <version>2.3.2</version>
         <configuration>
-          <source>1.6</source>
-          <target>1.6</target>
+          <source>1.7</source>
+          <target>1.7</target>
           <encoding>UTF-8</encoding>
         </configuration>
       </plugin>
@@ -81,7 +81,14 @@
     <dependency>
       <groupId>junit</groupId>
       <artifactId>junit</artifactId>
-      <version>4.8.1</version>
+      <version>4.11</version>
+      <scope>test</scope>
+    </dependency>
+
+    <dependency>
+      <groupId>org.easymock</groupId>
+      <artifactId>easymock</artifactId>
+      <version>3.2</version>
       <scope>test</scope>
     </dependency>
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
index 19a9359..9edf501 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
@@ -256,7 +256,7 @@
         pending.put(uri, e);
       }
       e.addRef(ref);
-      state.increasePushTaskCount();
+      state.increasePushTaskCount(project.get(), ref);
       e.addState(ref, state);
     }
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/OnStartStop.java b/src/main/java/com/googlesource/gerrit/plugins/replication/OnStartStop.java
index c752edc..37d47ca 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/OnStartStop.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/OnStartStop.java
@@ -28,15 +28,18 @@
   private final ServerInformation srvInfo;
   private final PushAll.Factory pushAll;
   private final ReplicationQueue queue;
+  private final ReplicationConfig config;
 
   @Inject
   OnStartStop(
       ServerInformation srvInfo,
       PushAll.Factory pushAll,
-      ReplicationQueue queue) {
+      ReplicationQueue queue,
+      ReplicationConfig config) {
     this.srvInfo = srvInfo;
     this.pushAll = pushAll;
     this.queue = queue;
+    this.config = config;
     this.pushAllFuture = Atomics.newReference();
   }
 
@@ -45,9 +48,8 @@
     queue.start();
 
     if (srvInfo.getState() == ServerInformation.State.STARTUP
-        && queue.replicateAllOnPluginStart) {
-      ReplicationState state =
-          new ReplicationState(ReplicationType.STARTUP);
+        && config.isReplicateAllOnPluginStart()) {
+      ReplicationState state = new ReplicationState();
       pushAllFuture.set(pushAll.create(null, state).schedule(30, TimeUnit.SECONDS));
     }
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java b/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
index 897d120..e246cd1 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
@@ -363,6 +363,8 @@
       return new PushResult();
     }
 
+    log.info("Push to " + uri + " references: " + todo);
+
     return tn.push(NullProgressMonitor.INSTANCE, todo);
   }
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/PushResultProcessing.java b/src/main/java/com/googlesource/gerrit/plugins/replication/PushResultProcessing.java
index 0452b69..bc503ac 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/PushResultProcessing.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/PushResultProcessing.java
@@ -14,17 +14,33 @@
 
 package com.googlesource.gerrit.plugins.replication;
 
+import com.google.gerrit.common.ChangeHooks;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.events.ChangeEvent;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+
 import com.googlesource.gerrit.plugins.replication.ReplicationState.RefPushResult;
 
 import org.eclipse.jgit.transport.URIish;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.lang.ref.WeakReference;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 public abstract class PushResultProcessing {
-  abstract void onOneNodeReplicated(String project, String ref, URIish uri, RefPushResult status);
 
-  abstract void onAllNodesReplicated(int totalPushTasksCount);
+  abstract void onRefReplicatedToOneNode(String project, String ref, URIish uri, RefPushResult status);
+
+  abstract void onRefReplicatedToAllNodes(String project, String ref, int nodesCount);
+
+  abstract void onAllRefsReplicatedToAllNodes(int totalPushTasksCount);
 
   void writeStdOut(final String message) {
     // Default doing nothing
@@ -57,11 +73,13 @@
     }
 
     @Override
-    void onOneNodeReplicated(String project, String ref, URIish uri,
+    void onRefReplicatedToOneNode(String project, String ref, URIish uri,
         RefPushResult status) {
       StringBuilder sb = new StringBuilder();
       sb.append("Replicate ");
       sb.append(project);
+      sb.append(" ref ");
+      sb.append(ref);
       sb.append(" to ");
       sb.append(resolveNodeName(uri));
       sb.append(", ");
@@ -84,7 +102,20 @@
     }
 
     @Override
-    void onAllNodesReplicated(int totalPushTasksCount) {
+    void onRefReplicatedToAllNodes(String project, String ref, int nodesCount) {
+      StringBuilder sb = new StringBuilder();
+      sb.append("Replication of ");
+      sb.append(project);
+      sb.append(" ref ");
+      sb.append(ref);
+      sb.append(" completed to ");
+      sb.append(nodesCount);
+      sb.append(" nodes, ");
+      writeStdOut(sb.toString());
+    }
+
+    @Override
+    void onAllRefsReplicatedToAllNodes(int totalPushTasksCount) {
       if (totalPushTasksCount == 0) {
         return;
       }
@@ -114,26 +145,76 @@
   }
 
   public static class GitUpdateProcessing extends PushResultProcessing {
-    @Override
-    void onOneNodeReplicated(String project, String ref, URIish uri,
-        RefPushResult status) {
-      //TODO: send stream events
+    static final Logger log = LoggerFactory.getLogger(GitUpdateProcessing.class);
+
+    private final ChangeHooks hooks;
+    private final SchemaFactory<ReviewDb> schema;
+
+    public GitUpdateProcessing(ChangeHooks hooks, SchemaFactory<ReviewDb> schema) {
+      this.hooks = hooks;
+      this.schema = schema;
     }
 
     @Override
-    void onAllNodesReplicated(int totalPushTasksCount) {
-      //TODO: send stream events
+    void onRefReplicatedToOneNode(String project, String ref, URIish uri,
+        RefPushResult status) {
+      RefReplicatedEvent event =
+          new RefReplicatedEvent(project, ref, resolveNodeName(uri), status);
+      postEvent(project, ref, event);
+    }
+
+    @Override
+    void onRefReplicatedToAllNodes(String project, String ref, int nodesCount) {
+      RefReplicationDoneEvent event =
+          new RefReplicationDoneEvent(project, ref, nodesCount);
+      postEvent(project, ref, event);
+    }
+
+    @Override
+    void onAllRefsReplicatedToAllNodes(int totalPushTasksCount) {
+    }
+
+    private void postEvent(String project, String ref, ChangeEvent event) {
+      if (PatchSet.isRef(ref)) {
+        try {
+          ReviewDb db = schema.open();
+          try {
+            hooks.postEvent(retrieveChange(ref, db), event, db);
+          } finally {
+            db.close();
+          }
+        } catch (Exception e) {
+          log.error("Cannot post event", e);
+        }
+      } else {
+        Branch.NameKey branch = new Branch.NameKey(Project.NameKey.parse(project), ref);
+        hooks.postEvent(branch, event);
+      }
+    }
+
+    private Change retrieveChange(String ref, ReviewDb db)
+        throws OrmException, NoSuchChangeException {
+      PatchSet.Id id = PatchSet.Id.fromRef(ref);
+      Change change = db.changes().get(id.getParentKey());
+      if (change == null) {
+        throw new NoSuchChangeException(id.getParentKey());
+      }
+      return change;
     }
   }
 
   public static class NoopProcessing extends PushResultProcessing {
     @Override
-    void onOneNodeReplicated(String project, String ref, URIish uri,
+    void onRefReplicatedToOneNode(String project, String ref, URIish uri,
         RefPushResult status) {
     }
 
     @Override
-    void onAllNodesReplicated(int totalPushTasksCount) {
+    void onRefReplicatedToAllNodes(String project, String ref, int nodesCount) {
+    }
+
+    @Override
+    void onAllRefsReplicatedToAllNodes(int totalPushTasksCount) {
     }
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/RefReplicatedEvent.java b/src/main/java/com/googlesource/gerrit/plugins/replication/RefReplicatedEvent.java
new file mode 100644
index 0000000..98435ac
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/RefReplicatedEvent.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replication;
+
+import com.google.gerrit.server.events.ChangeEvent;
+
+import com.googlesource.gerrit.plugins.replication.ReplicationState.RefPushResult;
+
+public class RefReplicatedEvent extends ChangeEvent {
+  public final String type = "ref-replicated";
+  public final String project;
+  public final String ref;
+  public final String targetNode;
+  public final String status;
+
+  public RefReplicatedEvent(String project, String ref, String targetNode,
+      RefPushResult status) {
+    this.project = project;
+    this.ref = ref;
+    this.targetNode = targetNode;
+    this.status = toStatusString(status);
+  }
+
+  private String toStatusString(RefPushResult status) {
+    return status.name().toLowerCase().replace("_", "-");
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/RefReplicationDoneEvent.java b/src/main/java/com/googlesource/gerrit/plugins/replication/RefReplicationDoneEvent.java
new file mode 100644
index 0000000..7eaed66
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/RefReplicationDoneEvent.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replication;
+
+import com.google.gerrit.server.events.ChangeEvent;
+
+public class RefReplicationDoneEvent extends ChangeEvent {
+  public final String type = "ref-replication-done";
+  public final String project;
+  public final String ref;
+  public final int nodesCount;
+
+  public RefReplicationDoneEvent(String project, String ref, int nodesCount) {
+    this.project = project;
+    this.ref = ref;
+    this.nodesCount = nodesCount;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfig.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfig.java
new file mode 100644
index 0000000..1d79750
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfig.java
@@ -0,0 +1,160 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.googlesource.gerrit.plugins.replication;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PluginUser;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.RemoteConfig;
+import org.eclipse.jgit.transport.URIish;
+import org.eclipse.jgit.util.FS;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+@Singleton
+public class ReplicationConfig {
+  static final Logger log = LoggerFactory.getLogger(ReplicationConfig.class);
+  private List<Destination> destinations;
+  private File cfgPath;
+  private boolean replicateAllOnPluginStart;
+  private Injector injector;
+  private final SchemaFactory<ReviewDb> database;
+  private final RemoteSiteUser.Factory replicationUserFactory;
+  private final PluginUser pluginUser;
+  private final GitRepositoryManager gitRepositoryManager;
+  private final GroupBackend groupBackend;
+
+  @Inject
+  public ReplicationConfig(final Injector injector, final SitePaths site,
+      final RemoteSiteUser.Factory ruf, final PluginUser pu,
+      final SchemaFactory<ReviewDb> db, final GitRepositoryManager grm,
+      final GroupBackend gb) throws ConfigInvalidException, IOException {
+    this.cfgPath = new File(site.etc_dir, "replication.config");
+    this.injector = injector;
+    this.replicationUserFactory = ruf;
+    this.pluginUser = pu;
+    this.database = db;
+    this.gitRepositoryManager = grm;
+    this.groupBackend = gb;
+    this.destinations = allDestinations(cfgPath);
+  }
+
+  public List<Destination> getDestinations() {
+    return destinations;
+  }
+
+  private List<Destination> allDestinations(File cfgPath)
+      throws ConfigInvalidException, IOException {
+    FileBasedConfig cfg = new FileBasedConfig(cfgPath, FS.DETECTED);
+    if (!cfg.getFile().exists()) {
+      log.warn("No " + cfg.getFile() + "; not replicating");
+      return Collections.emptyList();
+    }
+    if (cfg.getFile().length() == 0) {
+      log.info("Empty " + cfg.getFile() + "; not replicating");
+      return Collections.emptyList();
+    }
+
+    try {
+      cfg.load();
+    } catch (ConfigInvalidException e) {
+      throw new ConfigInvalidException(String.format(
+          "Config file %s is invalid: %s", cfg.getFile(), e.getMessage()), e);
+    } catch (IOException e) {
+      throw new IOException(String.format("Cannot read %s: %s", cfg.getFile(),
+          e.getMessage()), e);
+    }
+
+    replicateAllOnPluginStart =
+        cfg.getBoolean("gerrit", "replicateOnStartup", true);
+
+    ImmutableList.Builder<Destination> dest = ImmutableList.builder();
+    for (RemoteConfig c : allRemotes(cfg)) {
+      if (c.getURIs().isEmpty()) {
+        continue;
+      }
+
+      // If destination for push is not set assume equal to source.
+      for (RefSpec ref : c.getPushRefSpecs()) {
+        if (ref.getDestination() == null) {
+          ref.setDestination(ref.getSource());
+        }
+      }
+
+      if (c.getPushRefSpecs().isEmpty()) {
+        c.addPushRefSpec(new RefSpec().setSourceDestination("refs/*", "refs/*")
+            .setForceUpdate(true));
+      }
+
+      Destination destination =
+          new Destination(injector, c, cfg, database, replicationUserFactory,
+              pluginUser, gitRepositoryManager, groupBackend);
+
+      if (!destination.isSingleProjectMatch()) {
+        for (URIish u : c.getURIs()) {
+          if (u.getPath() == null || !u.getPath().contains("${name}")) {
+            throw new ConfigInvalidException(String.format(
+                "remote.%s.url \"%s\" lacks ${name} placeholder in %s",
+                c.getName(), u, cfg.getFile()));
+          }
+        }
+      }
+
+      dest.add(destination);
+    }
+    return dest.build();
+  }
+
+  public boolean isReplicateAllOnPluginStart() {
+    return replicateAllOnPluginStart;
+  }
+
+  private static List<RemoteConfig> allRemotes(FileBasedConfig cfg)
+      throws ConfigInvalidException {
+    Set<String> names = cfg.getSubsections("remote");
+    List<RemoteConfig> result = Lists.newArrayListWithCapacity(names.size());
+    for (String name : names) {
+      try {
+        result.add(new RemoteConfig(cfg, name));
+      } catch (URISyntaxException e) {
+        throw new ConfigInvalidException(String.format(
+            "remote %s has invalid URL in %s", name, cfg.getFile()));
+      }
+    }
+    return result;
+  }
+
+  public boolean isEmpty() {
+    return destinations.isEmpty();
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
index 77823f3..4b3b730 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.events.HeadUpdatedListener;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
 import com.google.gerrit.extensions.events.ProjectDeletedListener;
@@ -31,16 +32,17 @@
 class ReplicationModule extends AbstractModule {
   @Override
   protected void configure() {
+    bind(ReplicationConfig.class);
     bind(ReplicationQueue.class).in(Scopes.SINGLETON);
 
     DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
         .to(ReplicationQueue.class);
-
     DynamicSet.bind(binder(), NewProjectCreatedListener.class)
         .to(ReplicationQueue.class);
-
     DynamicSet.bind(binder(), ProjectDeletedListener.class)
         .to(ReplicationQueue.class);
+    DynamicSet.bind(binder(), HeadUpdatedListener.class)
+        .to(ReplicationQueue.class);
 
     bind(OnStartStop.class).in(Scopes.SINGLETON);
     bind(LifecycleListener.class)
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java
index 5b6c1e2..78725a3 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java
@@ -15,23 +15,20 @@
 package com.googlesource.gerrit.plugins.replication;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.ChangeHooks;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.events.HeadUpdatedListener;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
 import com.google.gerrit.extensions.events.ProjectDeletedListener;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PluginUser;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
-import com.google.inject.Injector;
+
+import com.googlesource.gerrit.plugins.replication.PushResultProcessing.GitUpdateProcessing;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.TransportException;
@@ -39,9 +36,6 @@
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.transport.RefSpec;
-import org.eclipse.jgit.transport.RemoteConfig;
 import org.eclipse.jgit.transport.RemoteSession;
 import org.eclipse.jgit.transport.SshSessionFactory;
 import org.eclipse.jgit.transport.URIish;
@@ -64,7 +58,8 @@
     LifecycleListener,
     GitReferenceUpdatedListener,
     NewProjectCreatedListener,
-    ProjectDeletedListener {
+    ProjectDeletedListener,
+    HeadUpdatedListener {
   static final Logger log = LoggerFactory.getLogger(ReplicationQueue.class);
   private static final WrappedLogger wrappedLog = new WrappedLogger(log);
 
@@ -80,36 +75,25 @@
     return null;
   }
 
-  private final Injector injector;
   private final WorkQueue workQueue;
-  private final List<Destination> configs;
   private final SchemaFactory<ReviewDb> database;
-  private final RemoteSiteUser.Factory replicationUserFactory;
-  private final PluginUser pluginUser;
-  private final GitRepositoryManager gitRepositoryManager;
-  private final GroupBackend groupBackend;
+  private final ChangeHooks changeHooks;
+  private final ReplicationConfig config;
   private volatile boolean running;
-  boolean replicateAllOnPluginStart;
 
   @Inject
-  ReplicationQueue(final Injector i, final WorkQueue wq, final SitePaths site,
-      final RemoteSiteUser.Factory ruf, final PluginUser pu,
-      final SchemaFactory<ReviewDb> db,
-      final GitRepositoryManager grm, final GroupBackend gb)
+  ReplicationQueue(final WorkQueue wq, final ReplicationConfig rc,
+      final SchemaFactory<ReviewDb> db, final ChangeHooks ch)
       throws ConfigInvalidException, IOException {
-    injector = i;
     workQueue = wq;
     database = db;
-    replicationUserFactory = ruf;
-    pluginUser = pu;
-    gitRepositoryManager = grm;
-    groupBackend = gb;
-    configs = allDestinations(new File(site.etc_dir, "replication.config"));
+    changeHooks = ch;
+    config = rc;
   }
 
   @Override
   public void start() {
-    for (Destination cfg : configs) {
+    for (Destination cfg : config.getDestinations()) {
       cfg.start(workQueue);
     }
     running = true;
@@ -119,7 +103,7 @@
   public void stop() {
     running = false;
     int discarded = 0;
-    for (Destination cfg : configs) {
+    for (Destination cfg : config.getDestinations()) {
       discarded += cfg.shutdown();
     }
     if (discarded > 0) {
@@ -136,7 +120,7 @@
       return;
     }
 
-    for (Destination cfg : configs) {
+    for (Destination cfg : config.getDestinations()) {
       if (cfg.wouldPushProject(project)) {
         for (URIish uri : cfg.getURIs(project, urlMatch)) {
           cfg.schedule(project, PushOne.ALL_REFS, uri, state);
@@ -147,16 +131,14 @@
 
   @Override
   public void onGitReferenceUpdated(GitReferenceUpdatedListener.Event event) {
-    ReplicationState state =
-        new ReplicationState(ReplicationType.GIT_UPDATED);
-
+    ReplicationState state = new ReplicationState(new GitUpdateProcessing(changeHooks, database));
     if (!running) {
       wrappedLog.warn("Replication plugin did not finish startup before event", state);
       return;
     }
 
     Project.NameKey project = new Project.NameKey(event.getProjectName());
-    for (Destination cfg : configs) {
+    for (Destination cfg : config.getDestinations()) {
       if (cfg.wouldPushProject(project) && cfg.wouldPushRef(event.getRefName())) {
         for (URIish uri : cfg.getURIs(project, null)) {
           cfg.schedule(project, event.getRefName(), uri, state);
@@ -166,85 +148,6 @@
     state.markAllPushTasksScheduled();
   }
 
-  private List<Destination> allDestinations(File cfgPath)
-      throws ConfigInvalidException, IOException {
-    FileBasedConfig cfg = new FileBasedConfig(cfgPath, FS.DETECTED);
-    if (!cfg.getFile().exists()) {
-      log.warn("No " + cfg.getFile() + "; not replicating");
-      return Collections.emptyList();
-    }
-    if (cfg.getFile().length() == 0) {
-      log.info("Empty " + cfg.getFile() + "; not replicating");
-      return Collections.emptyList();
-    }
-
-    try {
-      cfg.load();
-    } catch (ConfigInvalidException e) {
-      throw new ConfigInvalidException(String.format(
-          "Config file %s is invalid: %s",cfg.getFile(), e.getMessage()), e);
-    } catch (IOException e) {
-      throw new IOException(String.format(
-          "Cannot read %s: %s", cfg.getFile(),  e.getMessage()), e);
-    }
-
-    replicateAllOnPluginStart = cfg.getBoolean(
-        "gerrit", "replicateOnStartup",
-        true);
-
-    ImmutableList.Builder<Destination> dest = ImmutableList.builder();
-    for (RemoteConfig c : allRemotes(cfg)) {
-      if (c.getURIs().isEmpty()) {
-        continue;
-      }
-
-      // If destination for push is not set assume equal to source.
-      for (RefSpec ref : c.getPushRefSpecs()) {
-        if (ref.getDestination() == null) {
-          ref.setDestination(ref.getSource());
-        }
-      }
-
-      if (c.getPushRefSpecs().isEmpty()) {
-        c.addPushRefSpec(new RefSpec()
-          .setSourceDestination("refs/*", "refs/*")
-          .setForceUpdate(true));
-      }
-
-      Destination destination = new Destination(injector, c, cfg, database,
-          replicationUserFactory, pluginUser, gitRepositoryManager,
-          groupBackend);
-
-      if (!destination.isSingleProjectMatch()) {
-        for (URIish u : c.getURIs()) {
-          if (u.getPath() == null || !u.getPath().contains("${name}")) {
-            throw new ConfigInvalidException(String.format(
-                "remote.%s.url \"%s\" lacks ${name} placeholder in %s",
-                c.getName(), u, cfg.getFile()));
-          }
-        }
-      }
-
-      dest.add(destination);
-    }
-    return dest.build();
-  }
-
-  private static List<RemoteConfig> allRemotes(FileBasedConfig cfg)
-      throws ConfigInvalidException {
-    Set<String> names = cfg.getSubsections("remote");
-    List<RemoteConfig> result = Lists.newArrayListWithCapacity(names.size());
-    for (String name : names) {
-      try {
-        result.add(new RemoteConfig(cfg, name));
-      } catch (URISyntaxException e) {
-        throw new ConfigInvalidException(String.format(
-            "remote %s has invalid URL in %s", name, cfg.getFile()));
-      }
-    }
-    return result;
-  }
-
   @Override
   public void onNewProjectCreated(NewProjectCreatedListener.Event event) {
     for (URIish uri : getURIs(new Project.NameKey(event.getProjectName()), false)) {
@@ -259,9 +162,16 @@
     }
   }
 
+  @Override
+  public void onHeadUpdated(HeadUpdatedListener.Event event) {
+    for (URIish uri : getURIs(new Project.NameKey(event.getProjectName()), false)) {
+      updateHead(uri, event.getNewHeadName());
+    }
+  }
+
   private Set<URIish> getURIs(Project.NameKey projectName,
       boolean forProjectDeletion) {
-    if (configs.isEmpty()) {
+    if (config.getDestinations().isEmpty()) {
       return Collections.emptySet();
     }
     if (!running) {
@@ -270,7 +180,7 @@
     }
 
     Set<URIish> uris = Sets.newHashSet();
-    for (Destination config : configs) {
+    for (Destination config : this.config.getDestinations()) {
       if (!config.wouldPushProject(projectName)) {
         continue;
       }
@@ -426,6 +336,51 @@
     }
   }
 
+  private void updateHead(URIish replicateURI, String newHead) {
+    if (!replicateURI.isRemote()) {
+      updateHeadLocally(replicateURI, newHead);
+    } else if (isSSH(replicateURI)) {
+      updateHeadRemoteSsh(replicateURI, newHead);
+    } else {
+      log.warn(String.format("Cannot update HEAD of project on remote site %s."
+          + " Only local paths and SSH URLs are supported"
+          + " for remote HEAD update.", replicateURI));
+    }
+  }
+
+  private static void updateHeadRemoteSsh(URIish uri, String newHead) {
+    String quotedPath = QuotedString.BOURNE.quote(uri.getPath());
+    String cmd = "cd " + quotedPath
+            + " && git symbolic-ref HEAD " + QuotedString.BOURNE.quote(newHead);
+    OutputStream errStream = newErrorBufferStream();
+    try {
+      executeRemotSsh(uri, cmd, errStream);
+    } catch (IOException e) {
+      log.error(String.format(
+             "Error updating HEAD of remote repository at %s to %s:\n"
+          + "  Exception: %s\n"
+          + "  Command: %s\n"
+          + "  Output: %s",
+          uri, newHead, e, cmd, errStream), e);
+    }
+  }
+
+  private static void updateHeadLocally(URIish uri, String newHead) {
+    try {
+      Repository repo = new FileRepository(uri.getPath());
+      try {
+        if (newHead != null) {
+          RefUpdate u = repo.updateRef(Constants.HEAD);
+          u.link(newHead);
+        }
+      } finally {
+        repo.close();
+      }
+    } catch (IOException e) {
+      log.error(String.format("Failed to update HEAD of repository %s to %s", uri.getPath(), newHead), e);
+    }
+  }
+
   private static void executeRemotSsh(URIish uri, String cmd,
       OutputStream errStream) throws IOException {
     RemoteSession ssh = connect(uri);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationState.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationState.java
index 35f086f..9632d5f 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationState.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationState.java
@@ -14,16 +14,17 @@
 
 package com.googlesource.gerrit.plugins.replication;
 
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.Table;
+
+import com.googlesource.gerrit.plugins.replication.PushResultProcessing.NoopProcessing;
+
 import org.eclipse.jgit.transport.URIish;
 
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
 
-import com.googlesource.gerrit.plugins.replication.PushResultProcessing.CommandProcessing;
-import com.googlesource.gerrit.plugins.replication.PushResultProcessing.GitUpdateProcessing;
-import com.googlesource.gerrit.plugins.replication.PushResultProcessing.NoopProcessing;
-
 public class ReplicationState {
   private boolean allScheduled;
   private final PushResultProcessing pushResultProcessing;
@@ -31,31 +32,38 @@
   private final Lock countingLock = new ReentrantLock();
   private final CountDownLatch allPushTasksFinished = new CountDownLatch(1);
 
+  private static class RefReplicationStatus {
+    private final String project;
+    private final String ref;
+    private int nodesToReplicateCount;
+    private int replicatedNodesCount;
+
+    public RefReplicationStatus(String project, String ref) {
+      this.project = project;
+      this.ref = ref;
+    }
+
+    public boolean allDone() {
+      return replicatedNodesCount == nodesToReplicateCount;
+    }
+  }
+  private final Table<String, String, RefReplicationStatus> statusByProjectRef;
   private int totalPushTasksCount;
   private int finishedPushTasksCount;
 
-  public ReplicationState(ReplicationType type) {
-    this(type, null);
+  public ReplicationState() {
+    this(new NoopProcessing());
   }
 
-  public ReplicationState(ReplicationType type, StartCommand sshCommand) {
-    switch(type) {
-      case COMMAND:
-        pushResultProcessing = new CommandProcessing(sshCommand);
-        break;
-      case GIT_UPDATED:
-        pushResultProcessing = new GitUpdateProcessing();
-        break;
-      case STARTUP:
-      default:
-        pushResultProcessing = new NoopProcessing();
-        break;
-    }
+  public ReplicationState(PushResultProcessing processing) {
+    pushResultProcessing = processing;
+    statusByProjectRef = HashBasedTable.create();
   }
 
-  public void increasePushTaskCount() {
+  public void increasePushTaskCount(String project, String ref) {
     countingLock.lock();
     try {
+      getRefStatus(project, ref).nodesToReplicateCount++;
       totalPushTasksCount++;
     } finally {
       countingLock.unlock();
@@ -68,22 +76,33 @@
 
   public void notifyRefReplicated(String project, String ref, URIish uri,
       RefPushResult status) {
-    pushResultProcessing.onOneNodeReplicated(project, ref, uri, status);
+    pushResultProcessing.onRefReplicatedToOneNode(project, ref, uri, status);
 
+    RefReplicationStatus completedRefStatus = null;
+    boolean allPushTaksCompleted = false;
     countingLock.lock();
     try {
+      RefReplicationStatus refStatus = getRefStatus(project, ref);
+      refStatus.replicatedNodesCount++;
       finishedPushTasksCount++;
-      if (!allScheduled) {
-        return;
-      }
-      if (finishedPushTasksCount < totalPushTasksCount) {
-        return;
+
+      if (allScheduled) {
+        if (refStatus.allDone()) {
+          completedRefStatus = statusByProjectRef.remove(project, ref);
+        }
+        allPushTaksCompleted = finishedPushTasksCount == totalPushTasksCount;
       }
     } finally {
       countingLock.unlock();
     }
 
-    doAllPushTasksCompleted();
+    if (completedRefStatus != null) {
+      doRefPushTasksCompleted(completedRefStatus);
+    }
+
+    if (allPushTaksCompleted) {
+      doAllPushTasksCompleted();
+    }
   }
 
   public void markAllPushTasksScheduled() {
@@ -101,10 +120,35 @@
   }
 
   private void doAllPushTasksCompleted() {
-    pushResultProcessing.onAllNodesReplicated(totalPushTasksCount);
+    fireRemainingOnRefReplicatedToAllNodes();
+    pushResultProcessing.onAllRefsReplicatedToAllNodes(totalPushTasksCount);
     allPushTasksFinished.countDown();
   }
 
+  /**
+   * Some could be remaining if replication of a ref is completed before all
+   * tasks are scheduled.
+   */
+  private void fireRemainingOnRefReplicatedToAllNodes() {
+    for (RefReplicationStatus refStatus : statusByProjectRef.values()) {
+      doRefPushTasksCompleted(refStatus);
+    }
+  }
+
+  private void doRefPushTasksCompleted(RefReplicationStatus refStatus) {
+    pushResultProcessing.onRefReplicatedToAllNodes(refStatus.project,
+        refStatus.ref, refStatus.nodesToReplicateCount);
+  }
+
+  private RefReplicationStatus getRefStatus(String project, String ref) {
+    if (!statusByProjectRef.contains(project, ref)) {
+      RefReplicationStatus refStatus = new RefReplicationStatus(project, ref);
+      statusByProjectRef.put(project, ref, refStatus);
+      return refStatus;
+    }
+    return statusByProjectRef.get(project, ref);
+  }
+
   public void waitForReplication() throws InterruptedException {
     allPushTasksFinished.await();
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationType.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationType.java
deleted file mode 100644
index ff88b87..0000000
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationType.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) 2012 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;
-
-public enum ReplicationType {
-  /** Replicate all after gerrit startup. */
-  STARTUP,
-
-  /** Invoke ssh command to replicate. */
-  COMMAND,
-
-  /** After a git reference is updated, run the replicaton. */
-  GIT_UPDATED;
-}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/StartCommand.java b/src/main/java/com/googlesource/gerrit/plugins/replication/StartCommand.java
index b80c462..9db0ac5 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/StartCommand.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/StartCommand.java
@@ -21,6 +21,8 @@
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
 
+import com.googlesource.gerrit.plugins.replication.PushResultProcessing.CommandProcessing;
+
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 import org.slf4j.Logger;
@@ -65,8 +67,7 @@
       throw new UnloggedFailure(1, "error: cannot combine --all and PROJECT");
     }
 
-    ReplicationState state =
-        new ReplicationState(ReplicationType.COMMAND, this);
+    ReplicationState state = new ReplicationState(new CommandProcessing(this));
     Future<?> future = null;
     if (all) {
       future = pushAllFactory.create(urlMatch, state).schedule(0, TimeUnit.SECONDS);
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/GitUpdateProcessingTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/GitUpdateProcessingTest.java
new file mode 100644
index 0000000..93ea562
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/GitUpdateProcessingTest.java
@@ -0,0 +1,120 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replication;
+
+import static org.easymock.EasyMock.anyObject;
+import static org.easymock.EasyMock.createMock;
+import static org.easymock.EasyMock.createNiceMock;
+import static org.easymock.EasyMock.eq;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.expectLastCall;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.reset;
+import static org.easymock.EasyMock.verify;
+
+import com.google.gerrit.common.ChangeHooks;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ChangeAccess;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.client.KeyUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.gwtorm.server.StandardKeyEncoder;
+
+import com.googlesource.gerrit.plugins.replication.PushResultProcessing.GitUpdateProcessing;
+import com.googlesource.gerrit.plugins.replication.ReplicationState.RefPushResult;
+
+import junit.framework.TestCase;
+
+import org.eclipse.jgit.transport.URIish;
+
+import java.net.URISyntaxException;
+
+@SuppressWarnings("unchecked")
+public class GitUpdateProcessingTest extends TestCase {
+  static {
+    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
+  }
+
+  private ChangeHooks changeHooksMock;
+  private ChangeAccess changeAccessMock;
+  private GitUpdateProcessing gitUpdateProcessing;
+
+  protected void setUp() throws Exception {
+    super.setUp();
+    changeHooksMock = createMock(ChangeHooks.class);
+    replay(changeHooksMock);
+    changeAccessMock = createNiceMock(ChangeAccess.class);
+    replay(changeAccessMock);
+    ReviewDb reviewDbMock = createNiceMock(ReviewDb.class);
+    expect(reviewDbMock.changes()).andReturn(changeAccessMock).anyTimes();
+    replay(reviewDbMock);
+    SchemaFactory<ReviewDb> schemaMock = createMock(SchemaFactory.class);
+    expect(schemaMock.open()).andReturn(reviewDbMock).anyTimes();
+    replay(schemaMock);
+    gitUpdateProcessing = new GitUpdateProcessing(changeHooksMock, schemaMock);
+  }
+
+  public void testHeadRefReplicated() throws URISyntaxException {
+    reset(changeHooksMock);
+    RefReplicatedEvent expectedEvent =
+        new RefReplicatedEvent("someProject", "refs/heads/master", "someHost",
+            RefPushResult.SUCCEEDED);
+    changeHooksMock.postEvent(anyObject(Branch.NameKey.class),
+        RefReplicatedEventEquals.eqEvent(expectedEvent));
+    expectLastCall().once();
+    replay(changeHooksMock);
+
+    gitUpdateProcessing.onRefReplicatedToOneNode("someProject", "refs/heads/master",
+        new URIish("git://someHost/someProject.git"), RefPushResult.SUCCEEDED);
+    verify(changeHooksMock);
+  }
+
+  public void testChangeRefReplicated() throws URISyntaxException, OrmException {
+    Change expectedChange = new Change(null, null, null, null, null);
+    reset(changeAccessMock);
+    expect(changeAccessMock.get(anyObject(Change.Id.class))).andReturn(expectedChange);
+    replay(changeAccessMock);
+
+    reset(changeHooksMock);
+    RefReplicatedEvent expectedEvent =
+        new RefReplicatedEvent("someProject", "refs/changes/1/1/1", "someHost",
+            RefPushResult.FAILED);
+    changeHooksMock.postEvent(eq(expectedChange),
+        RefReplicatedEventEquals.eqEvent(expectedEvent),
+        anyObject(ReviewDb.class));
+    expectLastCall().once();
+    replay(changeHooksMock);
+
+    gitUpdateProcessing.onRefReplicatedToOneNode("someProject",
+        "refs/changes/1/1/1", new URIish("git://someHost/someProject.git"),
+        RefPushResult.FAILED);
+    verify(changeHooksMock);
+  }
+
+  public void testOnAllNodesReplicated() throws URISyntaxException {
+    reset(changeHooksMock);
+    RefReplicationDoneEvent expectedDoneEvent =
+        new RefReplicationDoneEvent("someProject", "refs/heads/master", 5);
+    changeHooksMock.postEvent(anyObject(Branch.NameKey.class),
+        RefReplicationDoneEventEquals.eqEvent(expectedDoneEvent));
+    expectLastCall().once();
+    replay(changeHooksMock);
+
+    gitUpdateProcessing.onRefReplicatedToAllNodes("someProject", "refs/heads/master", 5);
+    verify(changeHooksMock);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/RefReplicatedEventEquals.java b/src/test/java/com/googlesource/gerrit/plugins/replication/RefReplicatedEventEquals.java
new file mode 100644
index 0000000..c68ba73
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/RefReplicatedEventEquals.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replication;
+
+import org.easymock.EasyMock;
+import org.easymock.IArgumentMatcher;
+
+public class RefReplicatedEventEquals implements IArgumentMatcher {
+
+  private RefReplicatedEvent expected;
+
+  public RefReplicatedEventEquals(RefReplicatedEvent expected) {
+    this.expected = expected;
+  }
+
+  public static final RefReplicatedEvent eqEvent(RefReplicatedEvent refReplicatedEvent) {
+    EasyMock.reportMatcher(new RefReplicatedEventEquals(refReplicatedEvent));
+    return null;
+  }
+
+  @Override
+  public boolean matches(Object actual) {
+    if (!(actual instanceof RefReplicatedEvent)) {
+      return false;
+    }
+    RefReplicatedEvent actualRefReplicatedEvent = (RefReplicatedEvent)actual;
+    if (!equals(expected.project, actualRefReplicatedEvent.project)) {
+      return false;
+    }
+    if (!equals(expected.ref, actualRefReplicatedEvent.ref)) {
+      return false;
+    }
+    if (!equals(expected.targetNode, actualRefReplicatedEvent.targetNode)) {
+      return false;
+    }
+    if (!equals(expected.status, actualRefReplicatedEvent.status)) {
+      return false;
+    }
+    return true;
+  }
+
+  private static boolean equals(Object object1, Object object2) {
+    if (object1 == object2) {
+      return true;
+    }
+    if (object1 != null && !object1.equals(object2)) {
+      return false;
+    }
+    return true;
+  }
+
+  @Override
+  public void appendTo(StringBuffer buffer) {
+    buffer.append("eqEvent(");
+    buffer.append(expected.getClass().getName());
+    buffer.append(" with project \"");
+    buffer.append(expected.project);
+    buffer.append(" and ref \"");
+    buffer.append(expected.ref);
+    buffer.append(" and targetNode \"");
+    buffer.append(expected.targetNode);
+    buffer.append(" and status \"");
+    buffer.append(expected.status);
+    buffer.append("\")");
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/RefReplicationDoneEventEquals.java b/src/test/java/com/googlesource/gerrit/plugins/replication/RefReplicationDoneEventEquals.java
new file mode 100644
index 0000000..42a25de
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/RefReplicationDoneEventEquals.java
@@ -0,0 +1,73 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replication;
+
+import org.easymock.EasyMock;
+import org.easymock.IArgumentMatcher;
+
+public class RefReplicationDoneEventEquals implements IArgumentMatcher {
+
+  private RefReplicationDoneEvent expected;
+
+  public RefReplicationDoneEventEquals(RefReplicationDoneEvent expected) {
+    this.expected = expected;
+  }
+
+  public static final RefReplicationDoneEvent eqEvent(RefReplicationDoneEvent refReplicatedEvent) {
+    EasyMock.reportMatcher(new RefReplicationDoneEventEquals(refReplicatedEvent));
+    return null;
+  }
+
+  @Override
+  public boolean matches(Object actual) {
+    if (!(actual instanceof RefReplicationDoneEvent)) {
+      return false;
+    }
+    RefReplicationDoneEvent actualRefReplicatedDoneEvent = (RefReplicationDoneEvent)actual;
+    if (!equals(expected.project, actualRefReplicatedDoneEvent.project)) {
+      return false;
+    }
+    if (!equals(expected.ref, actualRefReplicatedDoneEvent.ref)) {
+      return false;
+    }
+    if (expected.nodesCount != actualRefReplicatedDoneEvent.nodesCount) {
+      return false;
+    }
+    return true;
+  }
+
+  private static boolean equals(Object object1, Object object2) {
+    if (object1 == object2) {
+      return true;
+    }
+    if (object1 != null && !object1.equals(object2)){
+      return false;
+    }
+    return true;
+  }
+
+  @Override
+  public void appendTo(StringBuffer buffer) {
+    buffer.append("eqEvent(");
+    buffer.append(expected.getClass().getName());
+    buffer.append(" with project \"");
+    buffer.append(expected.project);
+    buffer.append(" and ref \"");
+    buffer.append(expected.ref);
+    buffer.append(" and nodesCount \"");
+    buffer.append(expected.nodesCount);
+    buffer.append("\")");
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStateTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStateTest.java
new file mode 100644
index 0000000..a0bf576
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStateTest.java
@@ -0,0 +1,219 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replication;
+
+import static org.easymock.EasyMock.createNiceMock;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.resetToDefault;
+import static org.easymock.EasyMock.verify;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.googlesource.gerrit.plugins.replication.ReplicationState.RefPushResult;
+
+import org.eclipse.jgit.transport.URIish;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.net.URISyntaxException;
+
+public class ReplicationStateTest {
+
+  private ReplicationState replicationState;
+  private PushResultProcessing pushResultProcessingMock;
+
+  @Before
+  public void setUp() throws Exception {
+    pushResultProcessingMock = createNiceMock(PushResultProcessing.class);
+    replay(pushResultProcessingMock);
+    replicationState = new ReplicationState(pushResultProcessingMock);
+  }
+
+  @Test
+  public void shouldNotHavePushTask() {
+    assertFalse(replicationState.hasPushTask());
+  }
+
+  @Test
+  public void shouldHavePushTask() {
+    replicationState.increasePushTaskCount("someProject", "someRef");
+    assertTrue(replicationState.hasPushTask());
+  }
+
+  @Test
+  public void shouldFireOneReplicationEventWhenNothingToReplicate() {
+    resetToDefault(pushResultProcessingMock);
+
+    //expected event
+    pushResultProcessingMock.onAllRefsReplicatedToAllNodes(0);
+    replay(pushResultProcessingMock);
+
+    //actual test
+    replicationState.markAllPushTasksScheduled();
+    verify(pushResultProcessingMock);
+  }
+
+  @Test
+  public void shouldFireEventsForReplicationOfOneRefToOneNode()
+      throws URISyntaxException {
+    resetToDefault(pushResultProcessingMock);
+    URIish uri = new URIish("git://someHost/someRepo.git");
+
+    //expected events
+    pushResultProcessingMock.onRefReplicatedToOneNode("someProject", "someRef",
+        uri, RefPushResult.SUCCEEDED);
+    pushResultProcessingMock.onRefReplicatedToAllNodes("someProject",
+        "someRef", 1);
+    pushResultProcessingMock.onAllRefsReplicatedToAllNodes(1);
+    replay(pushResultProcessingMock);
+
+    //actual test
+    replicationState.increasePushTaskCount("someProject", "someRef");
+    replicationState.markAllPushTasksScheduled();
+    replicationState.notifyRefReplicated("someProject", "someRef", uri,
+        RefPushResult.SUCCEEDED);
+    verify(pushResultProcessingMock);
+  }
+
+  @Test
+  public void shouldFireEventsForReplicationOfOneRefToMultipleNodes()
+      throws URISyntaxException {
+    resetToDefault(pushResultProcessingMock);
+    URIish uri1 = new URIish("git://someHost1/someRepo.git");
+    URIish uri2 = new URIish("git://someHost2/someRepo.git");
+
+    //expected events
+    pushResultProcessingMock.onRefReplicatedToOneNode("someProject", "someRef",
+        uri1, RefPushResult.SUCCEEDED);
+    pushResultProcessingMock.onRefReplicatedToOneNode("someProject", "someRef",
+        uri2, RefPushResult.FAILED);
+    pushResultProcessingMock.onRefReplicatedToAllNodes("someProject",
+        "someRef", 2);
+    pushResultProcessingMock.onAllRefsReplicatedToAllNodes(2);
+    replay(pushResultProcessingMock);
+
+    //actual test
+    replicationState.increasePushTaskCount("someProject", "someRef");
+    replicationState.increasePushTaskCount("someProject", "someRef");
+    replicationState.markAllPushTasksScheduled();
+    replicationState.notifyRefReplicated("someProject", "someRef", uri1,
+        RefPushResult.SUCCEEDED);
+    replicationState.notifyRefReplicated("someProject", "someRef", uri2,
+        RefPushResult.FAILED);
+    verify(pushResultProcessingMock);
+  }
+
+  @Test
+  public void shouldFireEventsForReplicationOfMultipleRefsToMultipleNodes()
+      throws URISyntaxException {
+    resetToDefault(pushResultProcessingMock);
+    URIish uri1 = new URIish("git://host1/someRepo.git");
+    URIish uri2 = new URIish("git://host2/someRepo.git");
+    URIish uri3 = new URIish("git://host3/someRepo.git");
+
+    //expected events
+    pushResultProcessingMock.onRefReplicatedToOneNode("someProject", "ref1",
+        uri1, RefPushResult.SUCCEEDED);
+    pushResultProcessingMock.onRefReplicatedToOneNode("someProject", "ref1",
+        uri2, RefPushResult.SUCCEEDED);
+    pushResultProcessingMock.onRefReplicatedToOneNode("someProject", "ref1",
+        uri3, RefPushResult.SUCCEEDED);
+    pushResultProcessingMock.onRefReplicatedToOneNode("someProject", "ref2",
+        uri1, RefPushResult.SUCCEEDED);
+    pushResultProcessingMock.onRefReplicatedToOneNode("someProject", "ref2",
+        uri2, RefPushResult.SUCCEEDED);
+    pushResultProcessingMock
+        .onRefReplicatedToAllNodes("someProject", "ref1", 3);
+    pushResultProcessingMock
+        .onRefReplicatedToAllNodes("someProject", "ref2", 2);
+    pushResultProcessingMock.onAllRefsReplicatedToAllNodes(5);
+    replay(pushResultProcessingMock);
+
+    //actual test
+    replicationState.increasePushTaskCount("someProject", "ref1");
+    replicationState.increasePushTaskCount("someProject", "ref1");
+    replicationState.increasePushTaskCount("someProject", "ref1");
+    replicationState.increasePushTaskCount("someProject", "ref2");
+    replicationState.increasePushTaskCount("someProject", "ref2");
+    replicationState.markAllPushTasksScheduled();
+    replicationState.notifyRefReplicated("someProject", "ref1", uri1,
+        RefPushResult.SUCCEEDED);
+    replicationState.notifyRefReplicated("someProject", "ref1", uri2,
+        RefPushResult.SUCCEEDED);
+    replicationState.notifyRefReplicated("someProject", "ref1", uri3,
+        RefPushResult.SUCCEEDED);
+    replicationState.notifyRefReplicated("someProject", "ref2", uri1,
+        RefPushResult.SUCCEEDED);
+    replicationState.notifyRefReplicated("someProject", "ref2", uri2,
+        RefPushResult.SUCCEEDED);
+    verify(pushResultProcessingMock);
+  }
+
+  @Test
+  public void shouldFireEventsForReplicationSameRefDifferentProjects()
+      throws URISyntaxException {
+    resetToDefault(pushResultProcessingMock);
+    URIish uri = new URIish("git://host1/someRepo.git");
+
+    //expected events
+    pushResultProcessingMock.onRefReplicatedToOneNode("project1", "ref1", uri,
+        RefPushResult.SUCCEEDED);
+    pushResultProcessingMock.onRefReplicatedToOneNode("project2", "ref2", uri,
+        RefPushResult.SUCCEEDED);
+    pushResultProcessingMock.onRefReplicatedToAllNodes("project1", "ref1", 1);
+    pushResultProcessingMock.onRefReplicatedToAllNodes("project2", "ref2", 1);
+    pushResultProcessingMock.onAllRefsReplicatedToAllNodes(2);
+    replay(pushResultProcessingMock);
+
+    //actual test
+    replicationState.increasePushTaskCount("project1", "ref1");
+    replicationState.increasePushTaskCount("project2", "ref2");
+    replicationState.markAllPushTasksScheduled();
+    replicationState.notifyRefReplicated("project1", "ref1", uri,
+        RefPushResult.SUCCEEDED);
+    replicationState.notifyRefReplicated("project2", "ref2", uri,
+        RefPushResult.SUCCEEDED);
+    verify(pushResultProcessingMock);
+  }
+
+  @Test
+  public void shouldFireEventsWhenSomeReplicationCompleteBeforeAllTasksAreScheduled()
+      throws URISyntaxException {
+    resetToDefault(pushResultProcessingMock);
+    URIish uri1 = new URIish("git://host1/someRepo.git");
+
+   //expected events
+    pushResultProcessingMock.onRefReplicatedToOneNode("someProject", "ref1",
+        uri1, RefPushResult.SUCCEEDED);
+    pushResultProcessingMock.onRefReplicatedToOneNode("someProject", "ref2",
+        uri1, RefPushResult.SUCCEEDED);
+    pushResultProcessingMock
+        .onRefReplicatedToAllNodes("someProject", "ref1", 1);
+    pushResultProcessingMock
+        .onRefReplicatedToAllNodes("someProject", "ref2", 1);
+    pushResultProcessingMock.onAllRefsReplicatedToAllNodes(2);
+    replay(pushResultProcessingMock);
+
+    //actual test
+    replicationState.increasePushTaskCount("someProject", "ref1");
+    replicationState.increasePushTaskCount("someProject", "ref2");
+    replicationState.notifyRefReplicated("someProject", "ref1", uri1,
+        RefPushResult.SUCCEEDED);
+    replicationState.notifyRefReplicated("someProject", "ref2", uri1,
+        RefPushResult.SUCCEEDED);
+    replicationState.markAllPushTasksScheduled();
+    verify(pushResultProcessingMock);
+  }
+}