Merge changes from topic 'patch-set-groups-2'

* changes:
  Convert GetRelated to use patch set groups where present
  Add patch set group field to secondary index
  Add a "groups" field to PatchSet
  Add a helper for assigning commits to groups heuristically
  Add a helper to sort ChangeDatas in RevWalk order
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index 11acf00..0dcba72 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -31,9 +31,11 @@
 import com.google.gerrit.server.edit.ChangeEditModifier;
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
@@ -42,6 +44,20 @@
 import java.util.List;
 
 public class GetRelatedIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config byGroup() {
+    Config cfg = new Config();
+    cfg.setBoolean("change", null, "getRelatedByAncestors", false);
+    return cfg;
+  }
+
+  @ConfigSuite.Config
+  public static Config byAncestors() {
+    Config cfg = new Config();
+    cfg.setBoolean("change", null, "getRelatedByAncestors", true);
+    return cfg;
+  }
+
   @Inject
   private ChangeEditUtil editUtil;
 
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java
index 48623bd..4ec957e 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java
@@ -20,6 +20,8 @@
 import com.google.gwtorm.client.IntKey;
 
 import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.List;
 
 /** A single revision of a {@link Change}. */
 public final class PatchSet {
@@ -28,6 +30,41 @@
     return Id.fromRef(name) != null;
   }
 
+  public static String joinGroups(Iterable<String> groups) {
+    if (groups == null) {
+      return null;
+    }
+    StringBuilder sb = new StringBuilder();
+    boolean first = true;
+    for (String g : groups) {
+      if (!first) {
+        sb.append(',');
+      } else {
+        first = false;
+      }
+      sb.append(g);
+    }
+    return sb.toString();
+  }
+
+  public static List<String> splitGroups(String joinedGroups) {
+    if (joinedGroups == null) {
+      return null;
+    }
+    List<String> groups = new ArrayList<>();
+    int i = 0;
+    while (true) {
+      int idx = joinedGroups.indexOf(',', i);
+      if (idx < 0) {
+        groups.add(joinedGroups.substring(i, joinedGroups.length()));
+        break;
+      }
+      groups.add(joinedGroups.substring(i, idx));
+      i = idx + 1;
+    }
+    return groups;
+  }
+
   public static class Id extends IntKey<Change.Id> {
     private static final long serialVersionUID = 1L;
 
@@ -140,6 +177,18 @@
   @Column(id = 5)
   protected boolean draft;
 
+  /**
+   * Opaque group identifier, usually assigned during creation.
+   * <p>
+   * This field is actually a comma-separated list of values, as in rare cases
+   * involving merge commits a patch set may belong to multiple groups.
+   * <p>
+   * Changes on the same branch having patch sets with intersecting groups are
+   * considered related, as in the "Related Changes" tab.
+   */
+  @Column(id = 6, notNull = false)
+  protected String groups;
+
   protected PatchSet() {
   }
 
@@ -187,6 +236,14 @@
     draft = draftStatus;
   }
 
+  public List<String> getGroups() {
+    return splitGroups(groups);
+  }
+
+  public void setGroups(Iterable<String> groups) {
+    this.groups = joinGroups(groups);
+  }
+
   public String getRefName() {
     return id.toRefName();
   }
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetTest.java
index 4bf1d81..87e5b88 100644
--- a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetTest.java
+++ b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetTest.java
@@ -15,6 +15,10 @@
 package com.google.gerrit.reviewdb.client;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.reviewdb.client.PatchSet.joinGroups;
+import static com.google.gerrit.reviewdb.client.PatchSet.splitGroups;
+
+import com.google.common.collect.ImmutableList;
 
 import org.junit.Test;
 
@@ -59,6 +63,26 @@
     assertNotRef("refs/changes/34/1234foo");
   }
 
+  @Test
+  public void testSplitGroups() {
+    assertThat(splitGroups(null)).isNull();
+    assertThat(splitGroups("")).containsExactly("");
+    assertThat(splitGroups("abcd")).containsExactly("abcd");
+    assertThat(splitGroups("ab,cd")).containsExactly("ab", "cd").inOrder();
+    assertThat(splitGroups("ab,")).containsExactly("ab", "").inOrder();
+    assertThat(splitGroups(",cd")).containsExactly("", "cd").inOrder();
+  }
+
+  @Test
+  public void testJoinGroups() {
+    assertThat(joinGroups(null)).isNull();
+    assertThat(joinGroups(ImmutableList.of(""))).isEqualTo("");
+    assertThat(joinGroups(ImmutableList.of("abcd"))).isEqualTo("abcd");
+    assertThat(joinGroups(ImmutableList.of("ab", "cd"))).isEqualTo("ab,cd");
+    assertThat(joinGroups(ImmutableList.of("ab", ""))).isEqualTo("ab,");
+    assertThat(joinGroups(ImmutableList.of("", "cd"))).isEqualTo(",cd");
+  }
+
   private static void assertRef(int changeId, int psId, String refName) {
     assertThat(PatchSet.isRef(refName)).isTrue();
     assertThat(PatchSet.Id.fromRef(refName))
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
index 9bd6697..a9153d6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.ChangeIndexer;
 import com.google.gerrit.server.mail.CreateChangeSender;
@@ -163,6 +164,11 @@
     return this;
   }
 
+  public ChangeInserter setGroups(Iterable<String> groups) {
+    patchSet.setGroups(groups);
+    return this;
+  }
+
   public ChangeInserter setHashtags(Set<String> hashtags) {
     this.hashtags = hashtags;
     return this;
@@ -205,6 +211,9 @@
     db.changes().beginTransaction(change.getId());
     try {
       ChangeUtil.insertAncestors(db, patchSet.getId(), commit);
+      if (patchSet.getGroups() == null) {
+        patchSet.setGroups(GroupCollector.getDefaultGroups(patchSet));
+      }
       db.patchSets().insert(Collections.singleton(patchSet));
       db.changes().insert(Collections.singleton(change));
       LabelTypes labelTypes = projectControl.getLabelTypes();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
index 7320c7a..ac5d3c6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
@@ -157,6 +157,7 @@
     try (Repository git = gitManager.openRepository(project);
         RevWalk rw = new RevWalk(git)) {
       ObjectId parentCommit;
+      List<String> groups;
       if (input.baseChange != null) {
         List<Change> changes = changeUtil.findChanges(input.baseChange);
         if (changes.size() != 1) {
@@ -172,6 +173,7 @@
             new PatchSet.Id(change.getId(),
             change.currentPatchSetId().get()));
         parentCommit = ObjectId.fromString(ps.getRevision().get());
+        groups = ps.getGroups();
       } else {
         Ref destRef = git.getRef(refName);
         if (destRef == null) {
@@ -179,6 +181,7 @@
               "Branch %s does not exist.", refName));
         }
         parentCommit = destRef.getObjectId();
+        groups = null;
       }
       RevCommit mergeTip = rw.parseCommit(parentCommit);
 
@@ -208,6 +211,7 @@
 
       change.setTopic(input.topic);
       ins.setDraft(input.status != null && input.status == ChangeStatus.DRAFT);
+      ins.setGroups(groups);
       ins.insert();
 
       return Response.created(json.format(change.getId()));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java
index 7f49192..df93278e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java
@@ -14,258 +14,166 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.collect.ArrayListMultimap;
+import com.google.common.base.MoreObjects;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.common.collect.Multimap;
-import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetAncestor;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommonConverters;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.change.WalkSorter.PatchSetData;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GroupCollector;
+import com.google.gerrit.server.index.ChangeField;
+import com.google.gerrit.server.index.IndexCollection;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevFlag;
-import org.eclipse.jgit.revwalk.RevSort;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.LinkedList;
+import java.util.HashSet;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 
 @Singleton
 public class GetRelated implements RestReadView<RevisionResource> {
-  private static final Logger log = LoggerFactory.getLogger(GetRelated.class);
-
-  private final GitRepositoryManager gitMgr;
-  private final Provider<ReviewDb> dbProvider;
+  private final Provider<ReviewDb> db;
+  private final GetRelatedByAncestors byAncestors;
   private final Provider<InternalChangeQuery> queryProvider;
+  private final Provider<WalkSorter> sorter;
+  private final IndexCollection indexes;
+  private final boolean byAncestorsOnly;
 
   @Inject
-  GetRelated(GitRepositoryManager gitMgr,
-      Provider<ReviewDb> db,
-      Provider<InternalChangeQuery> queryProvider) {
-    this.gitMgr = gitMgr;
-    this.dbProvider = db;
+  GetRelated(Provider<ReviewDb> db,
+      @GerritServerConfig Config cfg,
+      GetRelatedByAncestors byAncestors,
+      Provider<InternalChangeQuery> queryProvider,
+      Provider<WalkSorter> sorter,
+      IndexCollection indexes) {
+    this.db = db;
+    this.byAncestors = byAncestors;
     this.queryProvider = queryProvider;
+    this.sorter = sorter;
+    this.indexes = indexes;
+    byAncestorsOnly =
+        cfg.getBoolean("change", null, "getRelatedByAncestors", false);
   }
 
   @Override
   public RelatedInfo apply(RevisionResource rsrc)
       throws RepositoryNotFoundException, IOException, OrmException {
-    try (Repository git = gitMgr.openRepository(rsrc.getChange().getProject());
-        RevWalk rw = new RevWalk(git)) {
-      Ref ref = git.getRef(rsrc.getChange().getDest().get());
-      RelatedInfo info = new RelatedInfo();
-      info.changes = walk(rsrc, rw, ref);
-      return info;
+    List<String> thisPatchSetGroups = GroupCollector.getGroups(rsrc);
+    if (byAncestorsOnly
+        || thisPatchSetGroups == null
+        || !indexes.getSearchIndex().getSchema().hasField(ChangeField.GROUP)) {
+      return byAncestors.getRelated(rsrc);
     }
+    RelatedInfo relatedInfo = new RelatedInfo();
+    relatedInfo.changes = getRelated(rsrc, thisPatchSetGroups);
+    return relatedInfo;
   }
 
-  private List<ChangeAndCommit> walk(RevisionResource rsrc, RevWalk rw, Ref ref)
-      throws OrmException, IOException {
-    Map<Change.Id, ChangeData> changes = allOpenChanges(rsrc);
-    Map<PatchSet.Id, PatchSet> patchSets = allPatchSets(rsrc, changes.values());
-
-    Map<String, PatchSet> commits = Maps.newHashMap();
-    for (PatchSet p : patchSets.values()) {
-      commits.put(p.getRevision().get(), p);
+  private List<ChangeAndCommit> getRelated(RevisionResource rsrc,
+      List<String> thisPatchSetGroups) throws OrmException, IOException {
+    if (thisPatchSetGroups.isEmpty()) {
+      return Collections.emptyList();
     }
 
-    RevCommit rev = rw.parseCommit(ObjectId.fromString(
-        rsrc.getPatchSet().getRevision().get()));
-    rw.sort(RevSort.TOPO);
-    rw.markStart(rev);
+    List<ChangeData> cds = queryProvider.get()
+        .enforceVisibility(true)
+        .byProjectGroups(
+            rsrc.getChange().getProject(),
+            getAllGroups(rsrc.getChange().getId()));
+    List<ChangeAndCommit> result = new ArrayList<>(cds.size());
 
-    if (ref != null && ref.getObjectId() != null) {
-      try {
-        rw.markUninteresting(rw.parseCommit(ref.getObjectId()));
-      } catch (IncorrectObjectTypeException notCommit) {
-        // Ignore and treat as new branch.
+    PatchSet.Id editBaseId = rsrc.getEdit().isPresent()
+        ? rsrc.getEdit().get().getBasePatchSet().getId()
+        : null;
+    for (PatchSetData d : sorter.get()
+        .includePatchSets(choosePatchSets(thisPatchSetGroups, cds))
+        .setRetainBody(true)
+        .sort(cds)) {
+      PatchSet ps = d.patchSet();
+      RevCommit commit;
+      if (ps.getId().equals(editBaseId)) {
+        // Replace base of an edit with the edit itself.
+        ps = rsrc.getPatchSet();
+        commit = rsrc.getEdit().get().getEditCommit();
+      } else {
+        commit = d.commit();
       }
+      result.add(new ChangeAndCommit(d.data().change(), ps, commit));
     }
 
-    Set<Change.Id> added = Sets.newHashSet();
-    List<ChangeAndCommit> parents = Lists.newArrayList();
-    for (RevCommit c; (c = rw.next()) != null;) {
-      PatchSet p = commits.get(c.name());
-      Change g = null;
-      if (p != null) {
-        g = changes.get(p.getId().getParentKey()).change();
-        added.add(p.getId().getParentKey());
-      }
-      parents.add(new ChangeAndCommit(g, p, c));
-    }
-    List<ChangeAndCommit> list = children(rsrc, rw, changes, patchSets, added);
-    list.addAll(parents);
-
-    if (list.size() == 1) {
-      ChangeAndCommit r = list.get(0);
-      if (r.commit != null && r.commit.commit.equals(rsrc.getPatchSet().getRevision().get())) {
+    if (result.size() == 1) {
+      ChangeAndCommit r = result.get(0);
+      if (r.commit != null
+          && r.commit.commit.equals(rsrc.getPatchSet().getRevision().get())) {
         return Collections.emptyList();
       }
     }
-    return list;
+    return result;
   }
 
-  private Map<Change.Id, ChangeData> allOpenChanges(RevisionResource rsrc)
-      throws OrmException {
-    return ChangeData.asMap(
-        queryProvider.get().byBranchOpen(rsrc.getChange().getDest()));
+  private Set<String> getAllGroups(Change.Id changeId) throws OrmException {
+    Set<String> result = new HashSet<>();
+    for (PatchSet ps : db.get().patchSets().byChange(changeId)) {
+      List<String> groups = ps.getGroups();
+      if (groups != null) {
+        result.addAll(groups);
+      }
+    }
+    return result;
   }
 
-  private Map<PatchSet.Id, PatchSet> allPatchSets(RevisionResource rsrc,
-      Collection<ChangeData> cds) throws OrmException {
-    Map<PatchSet.Id, PatchSet> r =
-        Maps.newHashMapWithExpectedSize(cds.size() * 2);
+  private static Set<PatchSet.Id> choosePatchSets(List<String> groups,
+      List<ChangeData> cds) throws OrmException {
+    // Prefer the latest patch set matching at least one group from this
+    // revision; otherwise, just use the latest patch set overall.
+    Set<PatchSet.Id> result = new HashSet<>();
     for (ChangeData cd : cds) {
-      for (PatchSet p : cd.patchSets()) {
-        r.put(p.getId(), p);
+      Collection<PatchSet> patchSets = cd.patchSets();
+      List<PatchSet> sameGroup = new ArrayList<>(patchSets.size());
+      for (PatchSet ps : patchSets) {
+        if (hasAnyGroup(ps, groups)) {
+          sameGroup.add(ps);
+        }
       }
+      result.add(ChangeUtil.PS_ID_ORDER.max(
+          !sameGroup.isEmpty() ? sameGroup : patchSets).getId());
     }
-
-    if (rsrc.getEdit().isPresent()) {
-      r.put(rsrc.getPatchSet().getId(), rsrc.getPatchSet());
-    }
-    return r;
+    return result;
   }
 
-  private List<ChangeAndCommit> children(RevisionResource rsrc, RevWalk rw,
-      Map<Change.Id, ChangeData> changes, Map<PatchSet.Id, PatchSet> patchSets,
-      Set<Change.Id> added)
-      throws OrmException, IOException {
-    // children is a map of parent commit name to PatchSet built on it.
-    Multimap<String, PatchSet.Id> children = allChildren(changes.keySet());
-
-    RevFlag seenCommit = rw.newFlag("seenCommit");
-    LinkedList<String> q = Lists.newLinkedList();
-    seedQueue(rsrc, rw, seenCommit, patchSets, q);
-
-    ProjectControl projectCtl = rsrc.getControl().getProjectControl();
-    Set<Change.Id> seenChange = Sets.newHashSet();
-    List<ChangeAndCommit> graph = Lists.newArrayList();
-    while (!q.isEmpty()) {
-      String id = q.remove();
-
-      // For every matching change find the most recent patch set.
-      Map<Change.Id, PatchSet.Id> matches = Maps.newHashMap();
-      for (PatchSet.Id psId : children.get(id)) {
-        PatchSet.Id e = matches.get(psId.getParentKey());
-        if ((e == null || e.get() < psId.get())
-            && isVisible(projectCtl, changes, patchSets, psId))  {
-          matches.put(psId.getParentKey(), psId);
-        }
-      }
-
-      for (Map.Entry<Change.Id, PatchSet.Id> e : matches.entrySet()) {
-        ChangeData cd = changes.get(e.getKey());
-        PatchSet ps = patchSets.get(e.getValue());
-        if (cd == null || ps == null || !seenChange.add(e.getKey())) {
-          continue;
-        }
-
-        RevCommit c = rw.parseCommit(ObjectId.fromString(
-            ps.getRevision().get()));
-        if (!c.has(seenCommit)) {
-          c.add(seenCommit);
-          q.addFirst(ps.getRevision().get());
-          if (added.add(ps.getId().getParentKey())) {
-            rw.parseBody(c);
-            graph.add(new ChangeAndCommit(cd.change(), ps, c));
-          }
-        }
-      }
+  private static boolean hasAnyGroup(PatchSet ps, List<String> groups) {
+    if (ps.getGroups() == null) {
+      return false;
     }
-    Collections.reverse(graph);
-    return graph;
-  }
-
-  private boolean isVisible(ProjectControl projectCtl,
-      Map<Change.Id, ChangeData> changes,
-      Map<PatchSet.Id, PatchSet> patchSets,
-      PatchSet.Id psId) throws OrmException {
-    ChangeData cd = changes.get(psId.getParentKey());
-    PatchSet ps = patchSets.get(psId);
-    if (cd != null && ps != null) {
-      // Related changes are in the same project, so reuse the existing
-      // ProjectControl.
-      ChangeControl ctl = projectCtl.controlFor(cd.change());
-      return ctl.isVisible(dbProvider.get())
-          && ctl.isPatchVisible(ps, dbProvider.get());
+    // Expected size of each list is 1, so nested linear search is fine.
+    for (String g1 : ps.getGroups()) {
+      for (String g2 : groups) {
+        if (g1.equals(g2)) {
+          return true;
+        }
+      }
     }
     return false;
   }
 
-  private void seedQueue(RevisionResource rsrc, RevWalk rw,
-      RevFlag seenCommit, Map<PatchSet.Id, PatchSet> patchSets,
-      LinkedList<String> q) throws IOException {
-    RevCommit tip = rw.parseCommit(ObjectId.fromString(
-        rsrc.getPatchSet().getRevision().get()));
-    tip.add(seenCommit);
-    q.add(tip.name());
-
-    Change.Id cId = rsrc.getChange().getId();
-    for (PatchSet p : patchSets.values()) {
-      if (cId.equals(p.getId().getParentKey())) {
-        try {
-          RevCommit c = rw.parseCommit(ObjectId.fromString(
-              p.getRevision().get()));
-          if (!c.has(seenCommit)) {
-            c.add(seenCommit);
-            q.add(c.name());
-          }
-        } catch (IOException e) {
-          log.warn(String.format(
-              "Cannot read patch set %d of %d",
-              p.getPatchSetId(), cId.get()), e);
-        }
-      }
-    }
-  }
-
-  private Multimap<String, PatchSet.Id> allChildren(Collection<Change.Id> ids)
-      throws OrmException {
-    ReviewDb db = dbProvider.get();
-    List<ResultSet<PatchSetAncestor>> t =
-        Lists.newArrayListWithCapacity(ids.size());
-    for (Change.Id id : ids) {
-      t.add(db.patchSetAncestors().byChange(id));
-    }
-
-    Multimap<String, PatchSet.Id> r = ArrayListMultimap.create();
-    for (ResultSet<PatchSetAncestor> rs : t) {
-      for (PatchSetAncestor a : rs) {
-        r.put(a.getAncestorRevision().get(), a.getPatchSet());
-      }
-    }
-    return r;
-  }
-
   public static class RelatedInfo {
     public List<ChangeAndCommit> changes;
   }
@@ -300,5 +208,28 @@
       commit.author = CommonConverters.toGitPerson(c.getAuthorIdent());
       commit.subject = c.getShortMessage();
     }
+
+    @Override
+    public String toString() {
+      return MoreObjects.toStringHelper(this)
+          .add("changeId", changeId)
+          .add("commit", toString(commit))
+          .add("_changeNumber", _changeNumber)
+          .add("_revisionNumber", _revisionNumber)
+          .add("_currentRevisionNumber", _currentRevisionNumber)
+          .toString();
+    }
+
+    private static String toString(CommitInfo commit) {
+      return MoreObjects.toStringHelper(commit)
+        .add("commit", commit.commit)
+        .add("parent", commit.parents)
+        .add("author", commit.author)
+        .add("committer", commit.committer)
+        .add("subject", commit.subject)
+        .add("message", commit.message)
+        .add("webLinks", commit.webLinks)
+        .toString();
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelatedByAncestors.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelatedByAncestors.java
new file mode 100644
index 0000000..119de7e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelatedByAncestors.java
@@ -0,0 +1,266 @@
+// Copyright (C) 2015 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.google.gerrit.server.change;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Sets;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetAncestor;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.change.GetRelated.ChangeAndCommit;
+import com.google.gerrit.server.change.GetRelated.RelatedInfo;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevFlag;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/** Implementation of {@link GetRelated} using {@link PatchSetAncestor}s. */
+class GetRelatedByAncestors {
+  private static final Logger log = LoggerFactory.getLogger(GetRelated.class);
+
+  private final GitRepositoryManager gitMgr;
+  private final Provider<ReviewDb> dbProvider;
+  private final Provider<InternalChangeQuery> queryProvider;
+
+  @Inject
+  GetRelatedByAncestors(GitRepositoryManager gitMgr,
+      Provider<ReviewDb> db,
+      Provider<InternalChangeQuery> queryProvider) {
+    this.gitMgr = gitMgr;
+    this.dbProvider = db;
+    this.queryProvider = queryProvider;
+  }
+
+  public RelatedInfo getRelated(RevisionResource rsrc)
+      throws RepositoryNotFoundException, IOException, OrmException {
+    try (Repository git = gitMgr.openRepository(rsrc.getChange().getProject());
+        RevWalk rw = new RevWalk(git)) {
+      Ref ref = git.getRef(rsrc.getChange().getDest().get());
+      RelatedInfo info = new RelatedInfo();
+      info.changes = walk(rsrc, rw, ref);
+      return info;
+    }
+  }
+
+  private List<ChangeAndCommit> walk(RevisionResource rsrc, RevWalk rw, Ref ref)
+      throws OrmException, IOException {
+    Map<Change.Id, ChangeData> changes = allOpenChanges(rsrc);
+    Map<PatchSet.Id, PatchSet> patchSets = allPatchSets(rsrc, changes.values());
+
+    Map<String, PatchSet> commits = Maps.newHashMap();
+    for (PatchSet p : patchSets.values()) {
+      commits.put(p.getRevision().get(), p);
+    }
+
+    RevCommit rev = rw.parseCommit(ObjectId.fromString(
+        rsrc.getPatchSet().getRevision().get()));
+    rw.sort(RevSort.TOPO);
+    rw.markStart(rev);
+
+    if (ref != null && ref.getObjectId() != null) {
+      try {
+        rw.markUninteresting(rw.parseCommit(ref.getObjectId()));
+      } catch (IncorrectObjectTypeException notCommit) {
+        // Ignore and treat as new branch.
+      }
+    }
+
+    Set<Change.Id> added = Sets.newHashSet();
+    List<ChangeAndCommit> parents = Lists.newArrayList();
+    for (RevCommit c; (c = rw.next()) != null;) {
+      PatchSet p = commits.get(c.name());
+      Change g = null;
+      if (p != null) {
+        g = changes.get(p.getId().getParentKey()).change();
+        added.add(p.getId().getParentKey());
+      }
+      parents.add(new ChangeAndCommit(g, p, c));
+    }
+    List<ChangeAndCommit> list = children(rsrc, rw, changes, patchSets, added);
+    list.addAll(parents);
+
+    if (list.size() == 1) {
+      ChangeAndCommit r = list.get(0);
+      if (r.commit != null && r.commit.commit.equals(rsrc.getPatchSet().getRevision().get())) {
+        return Collections.emptyList();
+      }
+    }
+    return list;
+  }
+
+  private Map<Change.Id, ChangeData> allOpenChanges(RevisionResource rsrc)
+      throws OrmException {
+    return ChangeData.asMap(
+        queryProvider.get().byBranchOpen(rsrc.getChange().getDest()));
+  }
+
+  private Map<PatchSet.Id, PatchSet> allPatchSets(RevisionResource rsrc,
+      Collection<ChangeData> cds) throws OrmException {
+    Map<PatchSet.Id, PatchSet> r =
+        Maps.newHashMapWithExpectedSize(cds.size() * 2);
+    for (ChangeData cd : cds) {
+      for (PatchSet p : cd.patchSets()) {
+        r.put(p.getId(), p);
+      }
+    }
+
+    if (rsrc.getEdit().isPresent()) {
+      r.put(rsrc.getPatchSet().getId(), rsrc.getPatchSet());
+    }
+    return r;
+  }
+
+  private List<ChangeAndCommit> children(RevisionResource rsrc, RevWalk rw,
+      Map<Change.Id, ChangeData> changes, Map<PatchSet.Id, PatchSet> patchSets,
+      Set<Change.Id> added)
+      throws OrmException, IOException {
+    // children is a map of parent commit name to PatchSet built on it.
+    Multimap<String, PatchSet.Id> children = allChildren(changes.keySet());
+
+    RevFlag seenCommit = rw.newFlag("seenCommit");
+    LinkedList<String> q = Lists.newLinkedList();
+    seedQueue(rsrc, rw, seenCommit, patchSets, q);
+
+    ProjectControl projectCtl = rsrc.getControl().getProjectControl();
+    Set<Change.Id> seenChange = Sets.newHashSet();
+    List<ChangeAndCommit> graph = Lists.newArrayList();
+    while (!q.isEmpty()) {
+      String id = q.remove();
+
+      // For every matching change find the most recent patch set.
+      Map<Change.Id, PatchSet.Id> matches = Maps.newHashMap();
+      for (PatchSet.Id psId : children.get(id)) {
+        PatchSet.Id e = matches.get(psId.getParentKey());
+        if ((e == null || e.get() < psId.get())
+            && isVisible(projectCtl, changes, patchSets, psId))  {
+          matches.put(psId.getParentKey(), psId);
+        }
+      }
+
+      for (Map.Entry<Change.Id, PatchSet.Id> e : matches.entrySet()) {
+        ChangeData cd = changes.get(e.getKey());
+        PatchSet ps = patchSets.get(e.getValue());
+        if (cd == null || ps == null || !seenChange.add(e.getKey())) {
+          continue;
+        }
+
+        RevCommit c = rw.parseCommit(ObjectId.fromString(
+            ps.getRevision().get()));
+        if (!c.has(seenCommit)) {
+          c.add(seenCommit);
+          q.addFirst(ps.getRevision().get());
+          if (added.add(ps.getId().getParentKey())) {
+            rw.parseBody(c);
+            graph.add(new ChangeAndCommit(cd.change(), ps, c));
+          }
+        }
+      }
+    }
+    Collections.reverse(graph);
+    return graph;
+  }
+
+  private boolean isVisible(ProjectControl projectCtl,
+      Map<Change.Id, ChangeData> changes,
+      Map<PatchSet.Id, PatchSet> patchSets,
+      PatchSet.Id psId) throws OrmException {
+    ChangeData cd = changes.get(psId.getParentKey());
+    PatchSet ps = patchSets.get(psId);
+    if (cd != null && ps != null) {
+      // Related changes are in the same project, so reuse the existing
+      // ProjectControl.
+      ChangeControl ctl = projectCtl.controlFor(cd.change());
+      return ctl.isVisible(dbProvider.get())
+          && ctl.isPatchVisible(ps, dbProvider.get());
+    }
+    return false;
+  }
+
+  private void seedQueue(RevisionResource rsrc, RevWalk rw,
+      RevFlag seenCommit, Map<PatchSet.Id, PatchSet> patchSets,
+      LinkedList<String> q) throws IOException {
+    RevCommit tip = rw.parseCommit(ObjectId.fromString(
+        rsrc.getPatchSet().getRevision().get()));
+    tip.add(seenCommit);
+    q.add(tip.name());
+
+    Change.Id cId = rsrc.getChange().getId();
+    for (PatchSet p : patchSets.values()) {
+      if (cId.equals(p.getId().getParentKey())) {
+        try {
+          RevCommit c = rw.parseCommit(ObjectId.fromString(
+              p.getRevision().get()));
+          if (!c.has(seenCommit)) {
+            c.add(seenCommit);
+            q.add(c.name());
+          }
+        } catch (IOException e) {
+          log.warn(String.format(
+              "Cannot read patch set %d of %d",
+              p.getPatchSetId(), cId.get()), e);
+        }
+      }
+    }
+  }
+
+  private Multimap<String, PatchSet.Id> allChildren(Collection<Change.Id> ids)
+      throws OrmException {
+    ReviewDb db = dbProvider.get();
+    List<ResultSet<PatchSetAncestor>> t =
+        Lists.newArrayListWithCapacity(ids.size());
+    for (Change.Id id : ids) {
+      t.add(db.patchSetAncestors().byChange(id));
+    }
+
+    Multimap<String, PatchSet.Id> r = ArrayListMultimap.create();
+    for (ResultSet<PatchSetAncestor> rs : t) {
+      for (PatchSetAncestor a : rs) {
+        r.put(a.getAncestorRevision().get(), a.getPatchSet());
+      }
+    }
+    return r;
+  }
+
+
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
index 6716162..db9c6d0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.BanCommit;
+import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.index.ChangeIndexer;
@@ -108,6 +109,7 @@
   private SshInfo sshInfo;
   private ValidatePolicy validatePolicy = ValidatePolicy.GERRIT;
   private boolean draft;
+  private Iterable<String> groups;
   private boolean runHooks;
   private boolean sendMail;
   private Account.Id uploader;
@@ -200,6 +202,11 @@
     return this;
   }
 
+  public PatchSetInserter setGroups(Iterable<String> groups) {
+    this.groups = groups;
+    return this;
+  }
+
   public PatchSetInserter setRunHooks(boolean runHooks) {
     this.runHooks = runHooks;
     return this;
@@ -239,12 +246,18 @@
 
     db.changes().beginTransaction(c.getId());
     try {
-      if (!db.changes().get(c.getId()).getStatus().isOpen()) {
+      updatedChange = db.changes().get(c.getId());
+      if (!updatedChange.getStatus().isOpen()) {
         throw new InvalidChangeOperationException(String.format(
             "Change %s is closed", c.getId()));
       }
 
       ChangeUtil.insertAncestors(db, patchSet.getId(), commit);
+      if (groups != null) {
+        patchSet.setGroups(groups);
+      } else {
+        patchSet.setGroups(GroupCollector.getCurrentGroups(db, c));
+      }
       db.patchSets().insert(Collections.singleton(patchSet));
 
       SetMultimap<ReviewerState, Account.Id> oldReviewers = sendMail
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/WalkSorter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/WalkSorter.java
new file mode 100644
index 0000000..56d6677
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/WalkSorter.java
@@ -0,0 +1,203 @@
+// Copyright (C) 2015 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.google.gerrit.server.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Function;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Ordering;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Helper to sort {@link ChangeData}s based on {@link RevWalk} ordering.
+ * <p>
+ * Split changes by project, and map each change to a single commit based on the
+ * latest patch set. The set of patch sets considered may be limited by calling
+ * {@link #includePatchSets(Set)}. Perform a standard {@link RevWalk} on each
+ * project repository, and record the order in which each change's commit is
+ * seen.
+ * <p>
+ * Once an order within each project is determined, groups of changes are sorted
+ * based on the project name. This is slightly more stable than sorting on
+ * something like the commit or change timestamp, as it will not unexpectedly
+ * reorder large groups of changes on subsequent calls if one of the changes was
+ * updated.
+ */
+class WalkSorter {
+  private static final Logger log =
+      LoggerFactory.getLogger(WalkSorter.class);
+
+  private static final Ordering<List<PatchSetData>> PROJECT_LIST_SORTER =
+      Ordering.natural().nullsFirst()
+          .onResultOf(
+            new Function<List<PatchSetData>, Project.NameKey>() {
+              @Override
+              public Project.NameKey apply(List<PatchSetData> in) {
+                if (in == null || in.isEmpty()) {
+                  return null;
+                }
+                try {
+                  return in.get(0).data().change().getProject();
+                } catch (OrmException e) {
+                  throw new IllegalStateException(e);
+                }
+              }
+            });
+
+  private final GitRepositoryManager repoManager;
+  private final Set<PatchSet.Id> includePatchSets;
+  private boolean retainBody;
+
+  @Inject
+  WalkSorter(GitRepositoryManager repoManager) {
+    this.repoManager = repoManager;
+    includePatchSets = new HashSet<>();
+  }
+
+  public WalkSorter includePatchSets(Iterable<PatchSet.Id> patchSets) {
+    Iterables.addAll(includePatchSets, patchSets);
+    return this;
+  }
+
+  public WalkSorter setRetainBody(boolean retainBody) {
+    this.retainBody = retainBody;
+    return this;
+  }
+
+  public Iterable<PatchSetData> sort(Iterable<ChangeData> in)
+      throws OrmException, IOException {
+    Multimap<Project.NameKey, ChangeData> byProject =
+        ArrayListMultimap.create();
+    for (ChangeData cd : in) {
+      byProject.put(cd.change().getProject(), cd);
+    }
+
+    List<List<PatchSetData>> sortedByProject =
+        new ArrayList<>(byProject.keySet().size());
+    for (Map.Entry<Project.NameKey, Collection<ChangeData>> e
+        : byProject.asMap().entrySet()) {
+      sortedByProject.add(sortProject(e.getKey(), e.getValue()));
+    }
+    Collections.sort(sortedByProject, PROJECT_LIST_SORTER);
+    return Iterables.concat(sortedByProject);
+  }
+
+  private List<PatchSetData> sortProject(Project.NameKey project,
+      Collection<ChangeData> in) throws OrmException, IOException {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      rw.setRetainBody(retainBody);
+      rw.sort(RevSort.TOPO);
+      Multimap<RevCommit, PatchSetData> byCommit = byCommit(rw, in);
+      if (byCommit.isEmpty()) {
+        return ImmutableList.of();
+      }
+
+      // Walk from all patch set SHA-1s, and terminate as soon as we've found
+      // everything we're looking for. This is equivalent to just sorting the
+      // list of commits by the RevWalk's configured order.
+      for (RevCommit c : byCommit.keySet()) {
+        rw.markStart(c);
+      }
+
+      int expected = byCommit.keySet().size();
+      int found = 0;
+      RevCommit c;
+      List<PatchSetData> result = new ArrayList<>(expected);
+      while (found < expected && (c = rw.next()) != null) {
+        Collection<PatchSetData> psds = byCommit.get(c);
+        if (!psds.isEmpty()) {
+          found++;
+          for (PatchSetData psd : psds) {
+            result.add(psd);
+          }
+        }
+      }
+      return result;
+    }
+  }
+
+  private Multimap<RevCommit, PatchSetData> byCommit(RevWalk rw,
+      Collection<ChangeData> in) throws OrmException, IOException {
+    Multimap<RevCommit, PatchSetData> byCommit =
+        ArrayListMultimap.create(in.size(), 1);
+    for (ChangeData cd : in) {
+      PatchSet maxPs = null;
+      for (PatchSet ps : cd.patchSets()) {
+        if (shouldInclude(ps)
+            && (maxPs == null || ps.getId().get() > maxPs.getId().get())) {
+          maxPs = ps;
+        }
+      }
+      if (maxPs == null) {
+       continue; // No patch sets matched.
+      }
+      ObjectId id = ObjectId.fromString(maxPs.getRevision().get());
+      try {
+        RevCommit c = rw.parseCommit(id);
+        byCommit.put(c, PatchSetData.create(cd, maxPs, c));
+      } catch (MissingObjectException | IncorrectObjectTypeException e) {
+        log.warn(
+            "missing commit " + id.name() + " for patch set " + maxPs.getId(),
+            e);
+      }
+    }
+    return byCommit;
+  }
+
+  private boolean shouldInclude(PatchSet ps) {
+    return includePatchSets.isEmpty() || includePatchSets.contains(ps.getId());
+  }
+
+  @AutoValue
+  static abstract class PatchSetData {
+    @VisibleForTesting
+    static PatchSetData create(ChangeData cd, PatchSet ps, RevCommit commit) {
+      return new AutoValue_WalkSorter_PatchSetData(cd, ps, commit);
+    }
+
+    abstract ChangeData data();
+    abstract PatchSet patchSet();
+    abstract RevCommit commit();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupCollector.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupCollector.java
new file mode 100644
index 0000000..51125b4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupCollector.java
@@ -0,0 +1,303 @@
+// Copyright (C) 2015 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.google.gerrit.server.git;
+
+import static com.google.common.base.Preconditions.checkState;
+import static org.eclipse.jgit.revwalk.RevFlag.UNINTERESTING;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Function;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Multimaps;
+import com.google.common.collect.SetMultimap;
+import com.google.common.collect.Sets;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gwtorm.server.OrmException;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayDeque;
+import java.util.Collection;
+import java.util.Deque;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * Helper for assigning groups to commits during {@link ReceiveCommits}.
+ * <p>
+ * For each commit encountered along a walk between the branch tip and the tip
+ * of the push, the group of a commit is defined as follows:
+ * <ul>
+ *   <li>If the commit is an existing patch set of a change, the group is read
+ *   from the group field in the corresponding {@link PatchSet} record.</li>
+ *   <li>If all of a commit's parents are merged into the branch, then its group
+ *   is its own SHA-1.</li>
+ *   <li>If the commit has a single parent that is not yet merged into the
+ *   branch, then its group is the same as the parent's group.<li>
+ *   <li>For a merge commit, choose a parent and use that parent's group. If one
+ *   of the parents has a group from a patch set, use that group, otherwise, use
+ *   the group from the first parent. In addition to setting this merge commit's
+ *   group, use the chosen group for all commits that would otherwise use a
+ *   group from the parents that were not chosen.</li>
+ *   <li>If a merge commit has multiple parents whose group comes from separate
+ *   patch sets, concatenate the groups from those parents together. This
+ *   indicates two side branches were pushed separately, followed by the merge.
+ *   <li>
+ * </ul>
+ * <p>
+ * Callers must call {@link #visit(RevCommit)} on all commits between the
+ * current branch tip and the tip of a push, in reverse topo order (parents
+ * before children). Once all commits have been visited, call {@link
+ * #getGroups()} for the result.
+ */
+public class GroupCollector {
+  private static final Logger log =
+      LoggerFactory.getLogger(GroupCollector.class);
+
+  public static List<String> getCurrentGroups(ReviewDb db, Change c)
+      throws OrmException {
+    PatchSet ps = db.patchSets().get(c.currentPatchSetId());
+    return ps != null ? ps.getGroups() : null;
+  }
+
+  public static List<String> getDefaultGroups(PatchSet ps) {
+    return ImmutableList.of(ps.getRevision().get());
+  }
+
+  public static List<String> getGroups(RevisionResource rsrc) {
+    if (rsrc.getEdit().isPresent()) {
+      // Groups for an edit are just the base revision's groups, since they have
+      // the same parent.
+      return rsrc.getEdit().get().getBasePatchSet().getGroups();
+    }
+    return rsrc.getPatchSet().getGroups();
+  }
+
+  private static interface Lookup {
+    List<String> lookup(PatchSet.Id psId) throws OrmException;
+  }
+
+  private final Multimap<ObjectId, PatchSet.Id> patchSetsBySha;
+  private final Multimap<ObjectId, String> groups;
+  private final SetMultimap<String, String> groupAliases;
+  private final Lookup groupLookup;
+
+  private boolean done;
+
+  private GroupCollector(
+      Multimap<ObjectId, PatchSet.Id> patchSetsBySha,
+      Lookup groupLookup) {
+    this.patchSetsBySha = patchSetsBySha;
+    this.groupLookup = groupLookup;
+    groups = ArrayListMultimap.create();
+    groupAliases = HashMultimap.create();
+  }
+
+  public GroupCollector(
+      Multimap<ObjectId, Ref> changeRefsById,
+      final ReviewDb db) {
+    this(
+        Multimaps.transformValues(
+            changeRefsById,
+            new Function<Ref, PatchSet.Id>() {
+              @Override
+              public PatchSet.Id apply(Ref in) {
+                return PatchSet.Id.fromRef(in.getName());
+              }
+            }),
+        new Lookup() {
+          @Override
+          public List<String> lookup(PatchSet.Id psId) throws OrmException {
+            PatchSet ps = db.patchSets().get(psId);
+            return ps != null ? ps.getGroups() : null;
+          }
+        });
+  }
+
+  @VisibleForTesting
+  GroupCollector(
+      Multimap<ObjectId, PatchSet.Id> patchSetsBySha,
+      final ListMultimap<PatchSet.Id, String> groupLookup) {
+    this(
+        patchSetsBySha,
+        new Lookup() {
+          @Override
+          public List<String> lookup(PatchSet.Id psId) {
+            List<String> groups = groupLookup.get(psId);
+            return !groups.isEmpty() ? groups : null;
+          }
+        });
+  }
+
+  public void visit(RevCommit c) {
+    checkState(!done, "visit() called after getGroups()");
+    Set<RevCommit> interestingParents = getInterestingParents(c);
+
+    if (interestingParents.size() == 0) {
+      // All parents are uninteresting: treat this commit as the root of a new
+      // group of related changes.
+      groups.put(c, c.name());
+      return;
+    } else if (interestingParents.size() == 1) {
+      // Only one parent is new in this push. If it is the only parent, just use
+      // that parent's group. If there are multiple parents, perhaps this commit
+      // is a merge of a side branch. This commit belongs in that parent's group
+      // in that case.
+      groups.putAll(c, groups.get(interestingParents.iterator().next()));
+      return;
+    }
+
+    // Multiple parents, merging at least two branches containing new commits in
+    // this push.
+    Set<String> thisCommitGroups = new TreeSet<>();
+    Set<String> parentGroupsNewInThisPush =
+        Sets.newLinkedHashSetWithExpectedSize(interestingParents.size());
+    for (RevCommit p : interestingParents) {
+      Collection<String> parentGroups = groups.get(p);
+      if (parentGroups.isEmpty()) {
+        throw new IllegalStateException(String.format(
+            "no group assigned to parent %s of commit %s", p.name(), c.name()));
+      }
+
+      for (String parentGroup : parentGroups) {
+        if (isGroupFromExistingPatchSet(p, parentGroup)) {
+          // This parent's group is from an existing patch set, i.e. the parent
+          // not new in this push. Use this group for the commit.
+          thisCommitGroups.add(parentGroup);
+        } else {
+          // This parent's group is new in this push.
+          parentGroupsNewInThisPush.add(parentGroup);
+        }
+      }
+    }
+
+    Iterable<String> toAlias;
+    if (thisCommitGroups.isEmpty()) {
+      // All parent groups were new in this push. Pick the first one and alias
+      // other parents' groups to this first parent.
+      String firstParentGroup = parentGroupsNewInThisPush.iterator().next();
+      thisCommitGroups = ImmutableSet.of(firstParentGroup);
+      toAlias = Iterables.skip(parentGroupsNewInThisPush, 1);
+    } else {
+      // For each parent group that was new in this push, alias it to the actual
+      // computed group(s) for this commit.
+      toAlias = parentGroupsNewInThisPush;
+    }
+    groups.putAll(c, thisCommitGroups);
+    for (String pg : toAlias) {
+      groupAliases.putAll(pg, thisCommitGroups);
+    }
+  }
+
+  public SetMultimap<ObjectId, String> getGroups() throws OrmException {
+    done = true;
+    SetMultimap<ObjectId, String> result = MultimapBuilder
+        .hashKeys(groups.keySet().size())
+        .treeSetValues()
+        .build();
+    for (Map.Entry<ObjectId, Collection<String>> e
+        : groups.asMap().entrySet()) {
+      ObjectId id = e.getKey();
+      result.putAll(id.copy(), resolveGroups(id, e.getValue()));
+    }
+    return result;
+  }
+
+  private Set<RevCommit> getInterestingParents(RevCommit commit) {
+    Set<RevCommit> result =
+        Sets.newLinkedHashSetWithExpectedSize(commit.getParentCount());
+    for (RevCommit p : commit.getParents()) {
+      if (!p.has(UNINTERESTING)) {
+        result.add(p);
+      }
+    }
+    return result;
+  }
+
+  private boolean isGroupFromExistingPatchSet(RevCommit commit, String group) {
+    ObjectId id = parseGroup(commit, group);
+    return id != null && patchSetsBySha.containsKey(id);
+  }
+
+  private Set<String> resolveGroups(ObjectId forCommit,
+      Collection<String> candidates) throws OrmException {
+    Set<String> actual = Sets.newTreeSet();
+    Set<String> done = Sets.newHashSetWithExpectedSize(candidates.size());
+    Set<String> seen = Sets.newHashSetWithExpectedSize(candidates.size());
+    Deque<String> todo = new ArrayDeque<>(candidates);
+    // BFS through all aliases to find groups that are not aliased to anything
+    // else.
+    while (!todo.isEmpty()) {
+      String g = todo.removeFirst();
+      if (!seen.add(g)) {
+        continue;
+      }
+      Set<String> aliases = groupAliases.get(g);
+      if (aliases.isEmpty()) {
+        if (!done.contains(g)) {
+          Iterables.addAll(actual, resolveGroup(forCommit, g));
+          done.add(g);
+        }
+      } else {
+        todo.addAll(aliases);
+      }
+    }
+    return actual;
+  }
+
+  private ObjectId parseGroup(ObjectId forCommit, String group) {
+    try {
+      return ObjectId.fromString(group);
+    } catch (IllegalArgumentException e) {
+      // Shouldn't happen; some sort of corruption or manual tinkering?
+      log.warn("group for commit {} is not a SHA-1: {}",
+          forCommit.name(), group);
+      return null;
+    }
+  }
+
+  private Iterable<String> resolveGroup(ObjectId forCommit, String group)
+      throws OrmException {
+    ObjectId id = parseGroup(forCommit, group);
+    if (id != null) {
+      PatchSet.Id psId = Iterables.getFirst(patchSetsBySha.get(id), null);
+      if (psId != null) {
+        List<String> groups = groupLookup.lookup(psId);
+        // Group for existing patch set may be missing, e.g. if group has not
+        // been migrated yet.
+        if (groups != null && !groups.isEmpty()) {
+          return groups;
+        }
+      }
+    }
+    return ImmutableList.of(group);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
index 3fb515a..464e267 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
@@ -44,6 +44,7 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
+import com.google.common.collect.Multimap;
 import com.google.common.collect.Ordering;
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
@@ -1467,6 +1468,10 @@
   private void selectNewAndReplacedChangesFromMagicBranch() {
     newChanges = Lists.newArrayList();
     final RevWalk walk = rp.getRevWalk();
+
+    Set<ObjectId> existing = changeRefsById().keySet();
+    GroupCollector groupCollector = new GroupCollector(refsById, db);
+
     walk.reset();
     walk.sort(RevSort.TOPO);
     walk.sort(RevSort.REVERSE, true);
@@ -1486,7 +1491,6 @@
             magicBranch.ctl != null ? magicBranch.ctl.getRefName() : null);
       }
 
-      Set<ObjectId> existing = changeRefsById().keySet();
       List<ChangeLookup> pending = Lists.newArrayList();
       final Set<Change.Key> newChangeIds = new HashSet<>();
       final int maxBatchChanges =
@@ -1496,7 +1500,17 @@
         if (c == null) {
           break;
         }
+        groupCollector.visit(c);
         if (existing.contains(c)) { // Commit is already tracked.
+          // TODO(dborowitz): Corner case where an existing commit might need a
+          // new group:
+          // Let A<-B<-C, then:
+          //   1. Push A to refs/heads/master
+          //   2. Push B to refs/for/master
+          //   3. Force push A~ to refs/heads/master
+          //   4. Push C to refs/for/master.
+          // B will be in existing so we aren't replacing the patch set. It used
+          // to have its own group, but now needs to to be changed to A's group.
           continue;
         }
 
@@ -1605,8 +1619,20 @@
       reject(magicBranch.cmd, "edit is not supported for new changes");
       return;
     }
-    for (CreateRequest create : newChanges) {
-      batch.addCommand(create.cmd);
+
+    try {
+      Multimap<ObjectId, String> groups = groupCollector.getGroups();
+      for (CreateRequest create : newChanges) {
+        batch.addCommand(create.cmd);
+        create.groups = groups.get(create.commit);
+      }
+      for (ReplaceRequest replace : replaceByChange.values()) {
+        replace.groups = groups.get(replace.newCommit);
+      }
+    } catch (OrmException e) {
+      log.error("Error collecting groups for changes", e);
+      reject(magicBranch.cmd, "internal server error");
+      return;
     }
   }
 
@@ -1646,6 +1672,7 @@
     final ReceiveCommand cmd;
     final ChangeInserter ins;
     boolean created;
+    Collection<String> groups;
 
     CreateRequest(RefControl ctl, RevCommit c, Change.Key changeKey)
         throws OrmException {
@@ -1690,7 +1717,7 @@
     }
 
     private void insertChange(ReviewDb db) throws OrmException, IOException {
-      final PatchSet ps = ins.getPatchSet();
+      final PatchSet ps = ins.setGroups(groups).getPatchSet();
       final Account.Id me = currentUser.getAccountId();
       final List<FooterLine> footerLines = commit.getFooterLines();
       final MailRecipients recipients = new MailRecipients();
@@ -1845,6 +1872,7 @@
     String mergedIntoRef;
     boolean skip;
     private PatchSet.Id priorPatchSet;
+    Collection<String> groups;
 
     ReplaceRequest(final Change.Id toChange, final RevCommit newCommit,
         final ReceiveCommand cmd, final boolean checkMergedInto) {
@@ -2020,6 +2048,7 @@
       newPatchSet.setCreatedOn(TimeUtil.nowTs());
       newPatchSet.setUploader(currentUser.getAccountId());
       newPatchSet.setRevision(toRevId(newCommit));
+      newPatchSet.setGroups(groups);
       if (magicBranch != null && magicBranch.draft) {
         newPatchSet.setDraft(true);
       }
@@ -2124,6 +2153,9 @@
         }
 
         ChangeUtil.insertAncestors(db, newPatchSet.getId(), newCommit);
+        if (newPatchSet.getGroups() == null) {
+          newPatchSet.setGroups(GroupCollector.getCurrentGroups(db, change));
+        }
         db.patchSets().insert(Collections.singleton(newPatchSet));
 
         if (checkMergedInto) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
index 60af3003..2d229a9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CommitMergeStatus;
+import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeConflictException;
 import com.google.gerrit.server.git.MergeException;
 import com.google.gerrit.server.git.MergeIdenticalTreeException;
@@ -186,6 +187,7 @@
     args.db.changes().beginTransaction(n.change().getId());
     try {
       insertAncestors(args.db, ps.getId(), newCommit);
+      ps.setGroups(GroupCollector.getCurrentGroups(args.db, n.change()));
       args.db.patchSets().insert(Collections.singleton(ps));
       n.change()
           .setCurrentPatchSet(patchSetInfoFactory.get(newCommit, ps.getId()));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeField.java
index 9054ba4..ebbc951 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeField.java
@@ -531,6 +531,24 @@
         }
       };
 
+  /** Opaque group identifiers for this change's patch sets. */
+  public static final FieldDef<ChangeData, Iterable<String>> GROUP =
+      new FieldDef.Repeatable<ChangeData, String>(
+          "group", FieldType.EXACT, false) {
+        @Override
+        public Iterable<String> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          Set<String> r = Sets.newHashSetWithExpectedSize(1);
+          for (PatchSet ps : input.patchSets()) {
+            List<String> groups = ps.getGroups();
+            if (groups != null) {
+              r.addAll(groups);
+            }
+          }
+          return r;
+        }
+      };
+
   public static class PatchSetProtoField
       extends FieldDef.Repeatable<ChangeData, byte[]> {
     public static final ProtobufCodec<PatchSet> CODEC =
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeSchemas.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeSchemas.java
index cff654a..b6b702c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeSchemas.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/ChangeSchemas.java
@@ -202,6 +202,36 @@
       ChangeField.COMMENTBY,
       ChangeField.PATCH_SET);
 
+  static final Schema<ChangeData> V18 = schema(
+      ChangeField.LEGACY_ID,
+      ChangeField.ID,
+      ChangeField.STATUS,
+      ChangeField.PROJECT,
+      ChangeField.PROJECTS,
+      ChangeField.REF,
+      ChangeField.TOPIC,
+      ChangeField.UPDATED,
+      ChangeField.FILE_PART,
+      ChangeField.PATH,
+      ChangeField.OWNER,
+      ChangeField.REVIEWER,
+      ChangeField.COMMIT,
+      ChangeField.TR,
+      ChangeField.LABEL,
+      ChangeField.REVIEWED,
+      ChangeField.COMMIT_MESSAGE,
+      ChangeField.COMMENT,
+      ChangeField.CHANGE,
+      ChangeField.APPROVAL,
+      ChangeField.MERGEABLE,
+      ChangeField.ADDED,
+      ChangeField.DELETED,
+      ChangeField.DELTA,
+      ChangeField.HASHTAG,
+      ChangeField.COMMENTBY,
+      ChangeField.PATCH_SET,
+      ChangeField.GROUP);
+
   private static Schema<ChangeData> schema(Collection<FieldDef<ChangeData, ?>> fields) {
     return new Schema<>(ImmutableList.copyOf(fields));
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
index d94921e..5c023e2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -172,12 +172,13 @@
   /**
    * Create an instance for testing only.
    * <p>
-   * Attempting to lazy load data will fail with NPEs.
+   * Attempting to lazy load data will fail with NPEs. Callers may consider
+   * manually setting fields that can be set.
    *
    * @param id change ID
    * @return instance for testing.
    */
-  static ChangeData createForTest(Change.Id id, int currentPatchSetId) {
+  public static ChangeData createForTest(Change.Id id, int currentPatchSetId) {
     ChangeData cd = new ChangeData(null, null, null, null, null, null, null,
         null, null, null, null, null, null, id);
     cd.currentPatchSet = new PatchSet(new PatchSet.Id(id, currentPatchSetId));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java
new file mode 100644
index 0000000..235d64e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2015 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.google.gerrit.server.query.change;
+
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.index.ChangeField;
+import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gwtorm.server.OrmException;
+
+import java.util.List;
+
+class GroupPredicate extends IndexPredicate<ChangeData> {
+  GroupPredicate(String group) {
+    super(ChangeField.GROUP, group);
+  }
+
+  @Override
+  public boolean match(ChangeData cd) throws OrmException {
+    for (PatchSet ps : cd.patchSets()) {
+      List<String> groups = ps.getGroups();
+      if (groups != null && groups.contains(getValue())) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 573bc77..7212f9c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.query.change;
 
 import static com.google.gerrit.server.query.Predicate.and;
+import static com.google.gerrit.server.query.Predicate.or;
 import static com.google.gerrit.server.query.change.ChangeStatusPredicate.open;
 
 import com.google.gerrit.common.Nullable;
@@ -32,6 +33,8 @@
 import org.eclipse.jgit.lib.AbbreviatedObjectId;
 import org.eclipse.jgit.lib.ObjectId;
 
+import java.util.ArrayList;
+import java.util.Collection;
 import java.util.List;
 
 /**
@@ -145,6 +148,15 @@
     return query(commit(AbbreviatedObjectId.fromObjectId(id)));
   }
 
+  public List<ChangeData> byProjectGroups(Project.NameKey project,
+      Collection<String> groups) throws OrmException {
+    List<GroupPredicate> groupPredicates = new ArrayList<>(groups.size());
+    for (String g : groups) {
+      groupPredicates.add(new GroupPredicate(g));
+    }
+    return query(and(project(project), or(groupPredicates)));
+  }
+
   private List<ChangeData> query(Predicate<ChangeData> p) throws OrmException {
     try {
       return qp.queryChanges(p).changes();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
index e7359fd..c1be4f8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
@@ -32,7 +32,7 @@
 /** A version of the database schema. */
 public abstract class SchemaVersion {
   /** The current schema version. */
-  public static final Class<Schema_107> C = Schema_107.class;
+  public static final Class<Schema_108> C = Schema_108.class;
 
   public static int getBinaryVersion() {
     return guessVersion(C);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_108.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_108.java
new file mode 100644
index 0000000..8cbf119
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_108.java
@@ -0,0 +1,161 @@
+// Copyright (C) 2015 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.google.gerrit.server.schema;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.SetMultimap;
+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.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.GroupCollector;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+
+public class Schema_108 extends SchemaVersion {
+  private final GitRepositoryManager repoManager;
+
+  @Inject
+  Schema_108(Provider<Schema_107> prior,
+      GitRepositoryManager repoManager) {
+    super(prior);
+    this.repoManager = repoManager;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
+    ui.message("Listing all changes ...");
+    SetMultimap<Project.NameKey, Change.Id> openByProject =
+        getOpenChangesByProject(db);
+    ui.message("done");
+
+    ui.message("Updating groups for open changes ...");
+    int i = 0;
+    for (Map.Entry<Project.NameKey, Collection<Change.Id>> e
+        : openByProject.asMap().entrySet()) {
+      try (Repository repo = repoManager.openRepository(e.getKey());
+          RevWalk rw = new RevWalk(repo)) {
+        updateProjectGroups(db, repo, rw, (Set<Change.Id>) e.getValue());
+      } catch (IOException err) {
+        throw new OrmException(err);
+      }
+      if (++i % 100 == 0) {
+        ui.message("  done " + i + " projects ...");
+      }
+    }
+    ui.message("done");
+  }
+
+  private static void updateProjectGroups(ReviewDb db, Repository repo,
+      RevWalk rw, Set<Change.Id> changes) throws OrmException, IOException {
+    // Match sorting in ReceiveCommits.
+    rw.reset();
+    rw.sort(RevSort.TOPO);
+    rw.sort(RevSort.REVERSE, true);
+
+    RefDatabase refdb = repo.getRefDatabase();
+    for (Ref ref : refdb.getRefs(Constants.R_HEADS).values()) {
+      RevCommit c = maybeParseCommit(rw, ref.getObjectId());
+      if (c != null) {
+        rw.markUninteresting(c);
+      }
+    }
+
+    Multimap<ObjectId, Ref> changeRefsBySha = ArrayListMultimap.create();
+    Multimap<ObjectId, PatchSet.Id> patchSetsBySha = ArrayListMultimap.create();
+    for (Ref ref : refdb.getRefs(RefNames.REFS_CHANGES).values()) {
+      ObjectId id = ref.getObjectId();
+      if (ref.getObjectId() == null) {
+        continue;
+      }
+      id = id.copy();
+      changeRefsBySha.put(id, ref);
+      PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+      if (psId != null && changes.contains(psId.getParentKey())) {
+        patchSetsBySha.put(id, psId);
+        RevCommit c = maybeParseCommit(rw, id);
+        if (c != null) {
+          rw.markStart(c);
+        }
+      }
+    }
+
+    GroupCollector collector = new GroupCollector(changeRefsBySha, db);
+    RevCommit c;
+    while ((c = rw.next()) != null) {
+      collector.visit(c);
+    }
+
+    updateGroups(db, collector, patchSetsBySha);
+  }
+
+  private static void updateGroups(ReviewDb db, GroupCollector collector,
+      Multimap<ObjectId, PatchSet.Id> patchSetsBySha) throws OrmException {
+    Map<PatchSet.Id, PatchSet> patchSets =
+        db.patchSets().toMap(db.patchSets().get(patchSetsBySha.values()));
+    for (Map.Entry<ObjectId, Collection<String>> e
+        : collector.getGroups().asMap().entrySet()) {
+      for (PatchSet.Id psId : patchSetsBySha.get(e.getKey())) {
+        PatchSet ps = patchSets.get(psId);
+        if (ps != null) {
+          ps.setGroups(e.getValue());
+        }
+      }
+    }
+
+    db.patchSets().update(patchSets.values());
+  }
+
+  private SetMultimap<Project.NameKey, Change.Id> getOpenChangesByProject(
+      ReviewDb db) throws OrmException {
+    SetMultimap<Project.NameKey, Change.Id> openByProject =
+        HashMultimap.create();
+    for (Change c : db.changes().all()) {
+      if (c.getStatus().isOpen()) {
+        openByProject.put(c.getProject(), c.getId());
+      }
+    }
+    return openByProject;
+  }
+
+  private static RevCommit maybeParseCommit(RevWalk rw, ObjectId id)
+      throws IOException {
+    if (id == null) {
+      return null;
+    }
+    RevObject obj = rw.parseAny(id);
+    return (obj instanceof RevCommit) ? (RevCommit) obj : null;
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/change/WalkSorterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/change/WalkSorterTest.java
new file mode 100644
index 0000000..7b7983c
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/change/WalkSorterTest.java
@@ -0,0 +1,267 @@
+// Copyright (C) 2015 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.google.gerrit.server.change;
+
+import static com.google.common.collect.Collections2.permutations;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.reviewdb.client.Account;
+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.client.RevId;
+import com.google.gerrit.server.change.WalkSorter.PatchSetData;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.testutil.InMemoryRepositoryManager;
+import com.google.gerrit.testutil.InMemoryRepositoryManager.Repo;
+import com.google.gerrit.testutil.TestChanges;
+import com.google.gwtorm.client.KeyUtil;
+import com.google.gwtorm.server.StandardKeyEncoder;
+
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class WalkSorterTest {
+  static {
+    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
+  }
+
+  private Account.Id userId;
+  private InMemoryRepositoryManager repoManager;
+
+  @Before
+  public void setUp() {
+    userId = new Account.Id(1);
+    repoManager = new InMemoryRepositoryManager();
+  }
+
+  @Test
+  public void seriesOfChanges() throws Exception {
+    TestRepository<Repo> p = newRepo("p");
+    RevCommit c1_1 = p.commit().create();
+    RevCommit c2_1 = p.commit().parent(c1_1).create();
+    RevCommit c3_1 = p.commit().parent(c2_1).create();
+
+    ChangeData cd1 = newChange(p, c1_1);
+    ChangeData cd2 = newChange(p, c2_1);
+    ChangeData cd3 = newChange(p, c3_1);
+
+    List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3);
+    WalkSorter sorter = new WalkSorter(repoManager);
+
+    assertSorted(sorter, changes, ImmutableList.of(
+        patchSetData(cd3, c3_1),
+        patchSetData(cd2, c2_1),
+        patchSetData(cd1, c1_1)));
+
+    // Add new patch sets whose commits are in reverse order, so output is in
+    // reverse order.
+    RevCommit c3_2 = p.commit().create();
+    RevCommit c2_2 = p.commit().parent(c3_2).create();
+    RevCommit c1_2 = p.commit().parent(c2_2).create();
+
+    addPatchSet(cd1, c1_2);
+    addPatchSet(cd2, c2_2);
+    addPatchSet(cd3, c3_2);
+
+    assertSorted(sorter, changes, ImmutableList.of(
+        patchSetData(cd1, c1_2),
+        patchSetData(cd2, c2_2),
+        patchSetData(cd3, c3_2)));
+  }
+
+  @Test
+  public void seriesOfChangesAtSameTimestamp() throws Exception {
+    TestRepository<Repo> p = newRepo("p");
+    RevCommit c1 = p.commit().tick(0).create();
+    RevCommit c2 = p.commit().tick(0).parent(c1).create();
+    RevCommit c3 = p.commit().tick(0).parent(c2).create();
+    RevCommit c4 = p.commit().tick(0).parent(c3).create();
+
+    RevWalk rw = p.getRevWalk();
+    rw.parseCommit(c1);
+    assertThat(rw.parseCommit(c2).getCommitTime())
+        .isEqualTo(c1.getCommitTime());
+    assertThat(rw.parseCommit(c3).getCommitTime())
+        .isEqualTo(c1.getCommitTime());
+    assertThat(rw.parseCommit(c4).getCommitTime())
+        .isEqualTo(c1.getCommitTime());
+
+    ChangeData cd1 = newChange(p, c1);
+    ChangeData cd2 = newChange(p, c2);
+    ChangeData cd3 = newChange(p, c3);
+    ChangeData cd4 = newChange(p, c4);
+
+    List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3, cd4);
+    WalkSorter sorter = new WalkSorter(repoManager);
+
+    assertSorted(sorter, changes, ImmutableList.of(
+        patchSetData(cd4, c4),
+        patchSetData(cd3, c3),
+        patchSetData(cd2, c2),
+        patchSetData(cd1, c1)));
+  }
+
+  @Test
+  public void projectsSortedByName() throws Exception {
+    TestRepository<Repo> pa = newRepo("a");
+    TestRepository<Repo> pb = newRepo("b");
+    RevCommit c1 = pa.commit().create();
+    RevCommit c2 = pb.commit().create();
+    RevCommit c3 = pa.commit().parent(c1).create();
+    RevCommit c4 = pb.commit().parent(c2).create();
+
+    ChangeData cd1 = newChange(pa, c1);
+    ChangeData cd2 = newChange(pb, c2);
+    ChangeData cd3 = newChange(pa, c3);
+    ChangeData cd4 = newChange(pb, c4);
+
+    assertSorted(
+        new WalkSorter(repoManager),
+        ImmutableList.of(cd1, cd2, cd3, cd4),
+        ImmutableList.of(
+            patchSetData(cd3, c3),
+            patchSetData(cd1, c1),
+            patchSetData(cd4, c4),
+            patchSetData(cd2, c2)));
+  }
+
+  @Test
+  public void restrictToPatchSets() throws Exception {
+    TestRepository<Repo> p = newRepo("p");
+    RevCommit c1_1 = p.commit().create();
+    RevCommit c2_1 = p.commit().parent(c1_1).create();
+
+    ChangeData cd1 = newChange(p, c1_1);
+    ChangeData cd2 = newChange(p, c2_1);
+
+    // Add new patch sets whose commits are in reverse order.
+    RevCommit c2_2 = p.commit().create();
+    RevCommit c1_2 = p.commit().parent(c2_2).create();
+
+    addPatchSet(cd1, c1_2);
+    addPatchSet(cd2, c2_2);
+
+    List<ChangeData> changes = ImmutableList.of(cd1, cd2);
+    WalkSorter sorter = new WalkSorter(repoManager);
+
+    assertSorted(sorter, changes, ImmutableList.of(
+        patchSetData(cd1, c1_2),
+        patchSetData(cd2, c2_2)));
+
+    // If we restrict to PS1 of each change, the sorter uses that commit.
+    sorter.includePatchSets(ImmutableSet.of(
+        new PatchSet.Id(cd1.getId(), 1),
+        new PatchSet.Id(cd2.getId(), 1)));
+    assertSorted(sorter, changes, ImmutableList.of(
+        patchSetData(cd2, 1, c2_1),
+        patchSetData(cd1, 1, c1_1)));
+  }
+
+  @Test
+  public void restrictToPatchSetsOmittingWholeProject() throws Exception {
+    TestRepository<Repo> pa = newRepo("a");
+    TestRepository<Repo> pb = newRepo("b");
+    RevCommit c1 = pa.commit().create();
+    RevCommit c2 = pa.commit().create();
+
+    ChangeData cd1 = newChange(pa, c1);
+    ChangeData cd2 = newChange(pb, c2);
+
+    List<ChangeData> changes = ImmutableList.of(cd1, cd2);
+    WalkSorter sorter = new WalkSorter(repoManager)
+        .includePatchSets(ImmutableSet.of(cd1.currentPatchSet().getId()));
+
+    assertSorted(sorter, changes, ImmutableList.of(patchSetData(cd1, c1)));
+  }
+
+  @Test
+  public void retainBody() throws Exception {
+    TestRepository<Repo> p = newRepo("p");
+    RevCommit c = p.commit().message("message").create();
+    ChangeData cd = newChange(p, c);
+
+    List<ChangeData> changes = ImmutableList.of(cd);
+    RevCommit actual = new WalkSorter(repoManager)
+        .setRetainBody(true)
+        .sort(changes)
+        .iterator().next()
+        .commit();
+    assertThat(actual.getRawBuffer()).isNotNull();
+    assertThat(actual.getShortMessage()).isEqualTo("message");
+
+    actual = new WalkSorter(repoManager)
+        .setRetainBody(false)
+        .sort(changes)
+        .iterator().next()
+        .commit();
+    assertThat(actual.getRawBuffer()).isNull();
+  }
+
+  private ChangeData newChange(TestRepository<Repo> tr, ObjectId id)
+      throws Exception {
+    Project.NameKey project = tr.getRepository().getDescription().getProject();
+    Change c = TestChanges.newChange(project, userId);
+    ChangeData cd = ChangeData.createForTest(c.getId(), 1);
+    cd.setChange(c);
+    cd.currentPatchSet().setRevision(new RevId(id.name()));
+    cd.setPatchSets(ImmutableList.of(cd.currentPatchSet()));
+    return cd;
+  }
+
+  private PatchSet addPatchSet(ChangeData cd, ObjectId id) throws Exception {
+    TestChanges.incrementPatchSet(cd.change());
+    PatchSet ps = new PatchSet(cd.change().currentPatchSetId());
+    ps.setRevision(new RevId(id.name()));
+    List<PatchSet> patchSets = new ArrayList<>(cd.patchSets());
+    patchSets.add(ps);
+    cd.setPatchSets(patchSets);
+    return ps;
+  }
+
+  private TestRepository<Repo> newRepo(String name)
+      throws Exception {
+    return new TestRepository<>(
+        repoManager.createRepository(new Project.NameKey(name)));
+  }
+
+  private static PatchSetData patchSetData(ChangeData cd, RevCommit commit)
+      throws Exception {
+    return PatchSetData.create(cd, cd.currentPatchSet(), commit);
+  }
+
+  private static PatchSetData patchSetData(ChangeData cd, int psId,
+      RevCommit commit) throws Exception {
+    return PatchSetData.create(
+        cd, cd.patchSet(new PatchSet.Id(cd.getId(), psId)), commit);
+  }
+
+  private static void assertSorted(WalkSorter sorter, List<ChangeData> changes,
+      List<PatchSetData> expected) throws Exception {
+    for (List<ChangeData> list : permutations(changes)) {
+      assertThat(sorter.sort(list))
+          .containsExactlyElementsIn(expected).inOrder();
+    }
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/GroupCollectorTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/GroupCollectorTest.java
new file mode 100644
index 0000000..ba0599d
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/GroupCollectorTest.java
@@ -0,0 +1,365 @@
+// Copyright (C) 2015 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.google.gerrit.server.git;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Multimap;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+public class GroupCollectorTest {
+  private TestRepository<?> tr;
+
+  @Before
+  public void setUp() throws Exception {
+    tr = new TestRepository<>(
+        new InMemoryRepository(new DfsRepositoryDescription("repo")));
+  }
+
+  @Test
+  public void commitWhoseParentIsUninterestingGetsNewGroup() throws Exception {
+    RevCommit branchTip = tr.commit().create();
+    RevCommit a = tr.commit().parent(branchTip).create();
+
+    Multimap<ObjectId, String> groups = collectGroups(
+        newWalk(a, branchTip),
+        patchSets(),
+        groups());
+
+    assertThat(groups).containsEntry(a, a.name());
+  }
+
+  @Test
+  public void commitWhoseParentIsNewPatchSetGetsParentsGroup()
+      throws Exception {
+    RevCommit branchTip = tr.commit().create();
+    RevCommit a = tr.commit().parent(branchTip).create();
+    RevCommit b = tr.commit().parent(a).create();
+
+    Multimap<ObjectId, String> groups = collectGroups(
+        newWalk(b, branchTip),
+        patchSets(),
+        groups());
+
+    assertThat(groups).containsEntry(a, a.name());
+    assertThat(groups).containsEntry(b, a.name());
+  }
+
+  @Test
+  public void commitWhoseParentIsExistingPatchSetGetsParentsGroup()
+      throws Exception {
+    RevCommit branchTip = tr.commit().create();
+    RevCommit a = tr.commit().parent(branchTip).create();
+    RevCommit b = tr.commit().parent(a).create();
+
+    String group = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    Multimap<ObjectId, String> groups = collectGroups(
+        newWalk(b, branchTip),
+        patchSets().put(a, psId(1, 1)),
+        groups().put(psId(1, 1), group));
+
+    assertThat(groups).containsEntry(a, group);
+    assertThat(groups).containsEntry(b, group);
+  }
+
+  @Test
+  public void commitWhoseParentIsExistingPatchSetWithNoGroup()
+      throws Exception {
+    RevCommit branchTip = tr.commit().create();
+    RevCommit a = tr.commit().parent(branchTip).create();
+    RevCommit b = tr.commit().parent(a).create();
+
+    Multimap<ObjectId, String> groups = collectGroups(
+        newWalk(b, branchTip),
+        patchSets().put(a, psId(1, 1)),
+        groups());
+
+    assertThat(groups).containsEntry(a, a.name());
+    assertThat(groups).containsEntry(b, a.name());
+  }
+
+  @Test
+  public void mergeCommitAndNewParentsAllGetSameGroup() throws Exception {
+    RevCommit branchTip = tr.commit().create();
+    RevCommit a = tr.commit().parent(branchTip).create();
+    RevCommit b = tr.commit().parent(branchTip).create();
+    RevCommit m = tr.commit().parent(a).parent(b).create();
+
+    Multimap<ObjectId, String> groups = collectGroups(
+        newWalk(m, branchTip),
+        patchSets(),
+        groups());
+
+    assertThat(groups).containsEntry(a, a.name());
+    assertThat(groups).containsEntry(b, a.name());
+    assertThat(groups).containsEntry(m, a.name());
+  }
+
+  @Test
+  public void mergeCommitWhereOneParentHasExistingGroup() throws Exception {
+    RevCommit branchTip = tr.commit().create();
+    RevCommit a = tr.commit().parent(branchTip).create();
+    RevCommit b = tr.commit().parent(branchTip).create();
+    RevCommit m = tr.commit().parent(a).parent(b).create();
+
+    String group = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    Multimap<ObjectId, String> groups = collectGroups(
+        newWalk(m, branchTip),
+        patchSets().put(b, psId(1, 1)),
+        groups().put(psId(1, 1), group));
+
+    // Merge commit and other parent get the existing group.
+    assertThat(groups).containsEntry(a, group);
+    assertThat(groups).containsEntry(b, group);
+    assertThat(groups).containsEntry(m, group);
+  }
+
+  @Test
+  public void mergeCommitWhereBothParentsHaveDifferentGroups()
+      throws Exception {
+    RevCommit branchTip = tr.commit().create();
+    RevCommit a = tr.commit().parent(branchTip).create();
+    RevCommit b = tr.commit().parent(branchTip).create();
+    RevCommit m = tr.commit().parent(a).parent(b).create();
+
+    String group1 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    String group2 = "1234567812345678123456781234567812345678";
+    Multimap<ObjectId, String> groups = collectGroups(
+        newWalk(m, branchTip),
+        patchSets()
+            .put(a, psId(1, 1))
+            .put(b, psId(2, 1)),
+        groups()
+            .put(psId(1, 1), group1)
+            .put(psId(2, 1), group2));
+
+    assertThat(groups).containsEntry(a, group1);
+    assertThat(groups).containsEntry(b, group2);
+    // Merge commit gets joined group of parents.
+    assertThat(groups.asMap())
+        .containsEntry(m, ImmutableSet.of(group1, group2));
+  }
+
+  @Test
+  public void mergeCommitMergesGroupsFromParent() throws Exception {
+    RevCommit branchTip = tr.commit().create();
+    RevCommit a = tr.commit().parent(branchTip).create();
+    RevCommit b = tr.commit().parent(branchTip).create();
+    RevCommit m = tr.commit().parent(a).parent(b).create();
+
+    String group1 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    String group2a = "1234567812345678123456781234567812345678";
+    String group2b = "ef123456ef123456ef123456ef123456ef123456";
+    Multimap<ObjectId, String> groups = collectGroups(
+        newWalk(m, branchTip),
+        patchSets()
+            .put(a, psId(1, 1))
+            .put(b, psId(2, 1)),
+        groups()
+            .put(psId(1, 1), group1)
+            .put(psId(2, 1), group2a)
+            .put(psId(2, 1), group2b));
+
+    assertThat(groups).containsEntry(a, group1);
+    assertThat(groups.asMap())
+        .containsEntry(b, ImmutableSet.of(group2a, group2b));
+    // Joined parent groups are split and resorted.
+    assertThat(groups.asMap())
+        .containsEntry(m, ImmutableSet.of(group1, group2a, group2b));
+  }
+
+  @Test
+  public void mergeCommitWithOneUninterestingParentAndOtherParentIsExisting()
+      throws Exception {
+    RevCommit branchTip = tr.commit().create();
+    RevCommit a = tr.commit().parent(branchTip).create();
+    RevCommit m = tr.commit().parent(branchTip).parent(a).create();
+
+    String group = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    Multimap<ObjectId, String> groups = collectGroups(
+        newWalk(m, branchTip),
+        patchSets().put(a, psId(1, 1)),
+        groups().put(psId(1, 1), group));
+
+    assertThat(groups).containsEntry(a, group);
+    assertThat(groups).containsEntry(m, group);
+  }
+
+  @Test
+  public void mergeCommitWithOneUninterestingParentAndOtherParentIsNew()
+      throws Exception {
+    RevCommit branchTip = tr.commit().create();
+    RevCommit a = tr.commit().parent(branchTip).create();
+    RevCommit m = tr.commit().parent(branchTip).parent(a).create();
+
+    Multimap<ObjectId, String> groups = collectGroups(
+        newWalk(m, branchTip),
+        patchSets(),
+        groups());
+
+    assertThat(groups).containsEntry(a, a.name());
+    assertThat(groups).containsEntry(m, a.name());
+  }
+
+  @Test
+  public void multipleMergeCommitsInHistoryAllResolveToSameGroup()
+      throws Exception {
+    RevCommit branchTip = tr.commit().create();
+    RevCommit a = tr.commit().parent(branchTip).create();
+    RevCommit b = tr.commit().parent(branchTip).create();
+    RevCommit c = tr.commit().parent(branchTip).create();
+    RevCommit m1 = tr.commit().parent(b).parent(c).create();
+    RevCommit m2 = tr.commit().parent(a).parent(m1).create();
+
+    Multimap<ObjectId, String> groups = collectGroups(
+        newWalk(m2, branchTip),
+        patchSets(),
+        groups());
+
+    assertThat(groups).containsEntry(a, a.name());
+    assertThat(groups).containsEntry(b, a.name());
+    assertThat(groups).containsEntry(c, a.name());
+    assertThat(groups).containsEntry(m1, a.name());
+    assertThat(groups).containsEntry(m2, a.name());
+  }
+
+  @Test
+  public void mergeCommitWithDuplicatedParentGetsParentsGroup()
+      throws Exception {
+    RevCommit branchTip = tr.commit().create();
+    RevCommit a = tr.commit().parent(branchTip).create();
+    RevCommit m = tr.commit().parent(a).parent(a).create();
+    tr.getRevWalk().parseBody(m);
+    assertThat(m.getParentCount()).isEqualTo(2);
+    assertThat(m.getParent(0)).isEqualTo(m.getParent(1));
+
+    Multimap<ObjectId, String> groups = collectGroups(
+        newWalk(m, branchTip),
+        patchSets(),
+        groups());
+
+    assertThat(groups).containsEntry(a, a.name());
+    assertThat(groups).containsEntry(m, a.name());
+  }
+
+  @Test
+  public void mergeCommitWithOneNewParentAndTwoExistingPatchSets()
+      throws Exception {
+    RevCommit branchTip = tr.commit().create();
+    RevCommit a = tr.commit().parent(branchTip).create();
+    RevCommit b = tr.commit().parent(branchTip).create();
+    RevCommit c = tr.commit().parent(b).create();
+    RevCommit m = tr.commit().parent(a).parent(c).create();
+
+    String group1 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    String group2 = "1234567812345678123456781234567812345678";
+    Multimap<ObjectId, String> groups = collectGroups(
+        newWalk(m, branchTip),
+        patchSets()
+            .put(a, psId(1, 1))
+            .put(b, psId(2, 1)),
+        groups()
+            .put(psId(1, 1), group1)
+            .put(psId(2, 1), group2));
+
+    assertThat(groups).containsEntry(a, group1);
+    assertThat(groups).containsEntry(b, group2);
+    assertThat(groups).containsEntry(c, group2);
+    assertThat(groups.asMap())
+        .containsEntry(m, ImmutableSet.of(group1, group2));
+  }
+
+  @Test
+  public void collectGroupsForMultipleTipsInParallel() throws Exception {
+    RevCommit branchTip = tr.commit().create();
+    RevCommit a = tr.commit().parent(branchTip).create();
+    RevCommit b = tr.commit().parent(a).create();
+    RevCommit c = tr.commit().parent(branchTip).create();
+    RevCommit d = tr.commit().parent(c).create();
+
+    RevWalk rw = newWalk(b, branchTip);
+    rw.markStart(rw.parseCommit(d));
+    // Schema upgrade case: all commits are existing patch sets, but none have
+    // groups assigned yet.
+    Multimap<ObjectId, String> groups = collectGroups(
+        rw,
+        patchSets()
+            .put(branchTip, psId(1, 1))
+            .put(a, psId(2, 1))
+            .put(b, psId(3, 1))
+            .put(c, psId(4, 1))
+            .put(d, psId(5, 1)),
+        groups());
+
+    assertThat(groups).containsEntry(a, a.name());
+    assertThat(groups).containsEntry(b, a.name());
+    assertThat(groups).containsEntry(c, c.name());
+    assertThat(groups).containsEntry(d, c.name());
+  }
+
+  // TODO(dborowitz): Tests for octopus merges.
+
+  private static PatchSet.Id psId(int c, int p) {
+    return new PatchSet.Id(new Change.Id(c), p);
+  }
+
+  private RevWalk newWalk(ObjectId start, ObjectId branchTip) throws Exception {
+    // Match RevWalk conditions from ReceiveCommits.
+    RevWalk rw = new RevWalk(tr.getRepository());
+    rw.sort(RevSort.TOPO);
+    rw.sort(RevSort.REVERSE, true);
+    rw.markStart(rw.parseCommit(start));
+    rw.markUninteresting(rw.parseCommit(branchTip));
+    return rw;
+  }
+
+  private static Multimap<ObjectId, String> collectGroups(
+      RevWalk rw,
+      ImmutableMultimap.Builder<ObjectId, PatchSet.Id> patchSetsBySha,
+      ImmutableListMultimap.Builder<PatchSet.Id, String> groupLookup)
+      throws Exception {
+    GroupCollector gc =
+        new GroupCollector(patchSetsBySha.build(), groupLookup.build());
+    RevCommit c;
+    while ((c = rw.next()) != null) {
+      gc.visit(c);
+    }
+    return gc.getGroups();
+  }
+
+  // Helper methods for constructing various map arguments, to avoid lots of
+  // type specifications.
+  private static ImmutableMultimap.Builder<ObjectId, PatchSet.Id> patchSets() {
+    return ImmutableMultimap.builder();
+  }
+
+  private static ImmutableListMultimap.Builder<PatchSet.Id, String> groups() {
+    return ImmutableListMultimap.builder();
+  }
+}