blob: cc87ea84adde3ad1dc5556169866e5fffe7acedf [file] [log] [blame]
// 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.base.Preconditions.checkState;
import com.google.auto.value.AutoValue;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.MultimapBuilder;
import com.google.common.collect.Ordering;
import com.google.common.collect.SetMultimap;
import com.google.common.flogger.FluentLogger;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.inject.Inject;
import java.io.IOException;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.eclipse.jgit.errors.IncorrectObjectTypeException;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevFlag;
import org.eclipse.jgit.revwalk.RevWalk;
/**
* 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(Iterable)}. Perform a standard {@link RevWalk} on each project repository, do
* an approximate topo sort, 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.
*/
public class WalkSorter {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private static final Ordering<List<PatchSetData>> PROJECT_LIST_SORTER =
Ordering.natural()
.nullsFirst()
.onResultOf(
(List<PatchSetData> in) -> {
if (in == null || in.isEmpty()) {
return null;
}
try {
return in.get(0).data().change().getProject();
} catch (StorageException 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<>();
}
@CanIgnoreReturnValue
public WalkSorter includePatchSets(Iterable<PatchSet.Id> patchSets) {
Iterables.addAll(includePatchSets, patchSets);
return this;
}
@CanIgnoreReturnValue
public WalkSorter setRetainBody(boolean retainBody) {
this.retainBody = retainBody;
return this;
}
public Iterable<PatchSetData> sort(Iterable<ChangeData> in) throws IOException {
ListMultimap<Project.NameKey, ChangeData> byProject =
MultimapBuilder.hashKeys().arrayListValues().build();
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()));
}
sortedByProject.sort(PROJECT_LIST_SORTER);
return Iterables.concat(sortedByProject);
}
private ImmutableList<PatchSetData> sortProject(
Project.NameKey project, Collection<ChangeData> in) throws IOException {
try (Repository repo = repoManager.openRepository(project);
RevWalk rw = new RevWalk(repo)) {
rw.setRetainBody(retainBody);
ListMultimap<RevCommit, PatchSetData> byCommit = byCommit(rw, in);
if (byCommit.isEmpty()) {
return ImmutableList.of();
} else if (byCommit.size() == 1) {
return ImmutableList.of(byCommit.values().iterator().next());
}
// 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.
//
// Partially topo sort the list, ensuring no parent is emitted before a
// direct child that is also in the input set. This preserves the stable,
// expected sort in the case where many commits share the same timestamp,
// e.g. a quick rebase. It also avoids JGit's topo sort, which slurps all
// interesting commits at the beginning, which is a problem since we don't
// know which commits to mark as uninteresting. Finding a reasonable set
// of commits to mark uninteresting (the "rootmost" set) is at least as
// difficult as just implementing this partial topo sort ourselves.
//
// (This is slightly less efficient than JGit's topo sort, which uses a
// private in-degree field in RevCommit rather than multimaps. We assume
// the input size is small enough that this is not an issue.)
Set<RevCommit> commits = byCommit.keySet();
ListMultimap<RevCommit, RevCommit> children = collectChildren(commits);
SetMultimap<RevCommit, RevCommit> pending =
MultimapBuilder.hashKeys().hashSetValues().build();
Deque<RevCommit> todo = new ArrayDeque<>();
RevFlag done = rw.newFlag("done");
markStart(rw, commits);
int expected = commits.size();
int found = 0;
RevCommit c;
List<PatchSetData> result = new ArrayList<>(expected);
int maxPopSize = commits.size() * commits.size();
while (found < expected && (c = rw.next()) != null) {
if (!commits.contains(c)) {
continue;
}
todo.clear();
todo.add(c);
int i = 0;
while (!todo.isEmpty()) {
// Sanity check: in the worst case scenario, each commit can add all previous commits in
// the todo queue. This can lead to (n-1) + (n-2) + ... +1 iterations of the algorithm.
// So, in the worst case we can't pop more than N^2 pending commits, otherwise
// we have an infinite loop due to programmer error or something. (actually, it is
// (N-1) + (N-2) + (N-3) + (1) + 1 = N/2*(N-1)+1, but N^2 is enough for us.)
checkState(
++i <= maxPopSize,
"Too many pending steps while sorting %s - can be a problem in the algorithm.",
commits);
RevCommit t = todo.removeFirst();
if (t.has(done)) {
continue;
}
boolean ready = true;
for (RevCommit child : children.get(t)) {
if (!child.has(done)) {
pending.put(child, t);
ready = false;
}
}
if (ready) {
found += emit(t, byCommit, result, done);
todo.addAll(pending.get(t));
}
}
}
return ImmutableList.copyOf(result);
}
}
private static ListMultimap<RevCommit, RevCommit> collectChildren(Set<RevCommit> commits) {
ListMultimap<RevCommit, RevCommit> children =
MultimapBuilder.hashKeys().arrayListValues().build();
for (RevCommit c : commits) {
for (RevCommit p : c.getParents()) {
if (commits.contains(p)) {
children.put(p, c);
}
}
}
return children;
}
private static int emit(
RevCommit c,
ListMultimap<RevCommit, PatchSetData> byCommit,
List<PatchSetData> result,
RevFlag done) {
if (c.has(done)) {
return 0;
}
c.add(done);
List<PatchSetData> psds = byCommit.get(c);
if (!psds.isEmpty()) {
result.addAll(psds);
return 1;
}
return 0;
}
private ListMultimap<RevCommit, PatchSetData> byCommit(RevWalk rw, Collection<ChangeData> in)
throws IOException {
ListMultimap<RevCommit, PatchSetData> byCommit =
MultimapBuilder.hashKeys(in.size()).arrayListValues(1).build();
for (ChangeData cd : in) {
PatchSet maxPs = null;
for (PatchSet ps : cd.patchSets()) {
if (shouldInclude(ps) && (maxPs == null || ps.id().get() > maxPs.id().get())) {
maxPs = ps;
}
}
if (maxPs == null) {
continue; // No patch sets matched.
}
try {
RevCommit c = rw.parseCommit(maxPs.commitId());
byCommit.put(c, PatchSetData.create(cd, maxPs, c));
} catch (MissingObjectException | IncorrectObjectTypeException e) {
logger.atWarning().withCause(e).log(
"missing commit %s for patch set %s", maxPs.commitId().name(), maxPs.id());
}
}
return byCommit;
}
private boolean shouldInclude(PatchSet ps) {
return includePatchSets.isEmpty() || includePatchSets.contains(ps.id());
}
private static void markStart(RevWalk rw, Iterable<RevCommit> commits) throws IOException {
for (RevCommit c : commits) {
rw.markStart(c);
}
}
@AutoValue
public abstract static class PatchSetData {
@VisibleForTesting
static PatchSetData create(ChangeData cd, PatchSet ps, RevCommit commit) {
return new AutoValue_WalkSorter_PatchSetData(cd, ps, commit);
}
public abstract ChangeData data();
abstract PatchSet patchSet();
abstract RevCommit commit();
}
}