| // 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.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.MultimapBuilder; | |
| import com.google.common.collect.SetMultimap; | |
| import com.google.common.collect.Sets; | |
| import com.google.common.collect.SortedSetMultimap; | |
| import com.google.common.flogger.FluentLogger; | |
| import com.google.gerrit.common.Nullable; | |
| import com.google.gerrit.entities.PatchSet; | |
| import com.google.gerrit.entities.Project; | |
| import com.google.gerrit.server.PatchSetUtil; | |
| import com.google.gerrit.server.change.RevisionResource; | |
| import com.google.gerrit.server.git.receive.ReceivePackRefCache; | |
| import com.google.gerrit.server.notedb.ChangeNotes; | |
| import java.io.IOException; | |
| 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; | |
| import org.eclipse.jgit.lib.ObjectId; | |
| import org.eclipse.jgit.revwalk.RevCommit; | |
| /** | |
| * Helper for assigning groups to commits during {@code 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>If all of a commit's parents are merged into the branch, then its group is its own SHA-1. | |
| * <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>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 FluentLogger logger = FluentLogger.forEnclosingClass(); | |
| public static List<String> getDefaultGroups(ObjectId commit) { | |
| return ImmutableList.of(commit.name()); | |
| } | |
| 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().groups(); | |
| } | |
| return rsrc.getPatchSet().groups(); | |
| } | |
| interface Lookup { | |
| List<String> lookup(PatchSet.Id psId); | |
| } | |
| private final ReceivePackRefCache receivePackRefCache; | |
| private final ListMultimap<ObjectId, String> groups; | |
| private final SetMultimap<String, String> groupAliases; | |
| private final Lookup groupLookup; | |
| private boolean done; | |
| /** | |
| * Returns a new {@link GroupCollector} instance. | |
| * | |
| * @see GroupCollector for what this class does. | |
| */ | |
| public static GroupCollector create( | |
| ReceivePackRefCache receivePackRefCache, | |
| PatchSetUtil psUtil, | |
| ChangeNotes.Factory notesFactory, | |
| Project.NameKey project) { | |
| return new GroupCollector( | |
| receivePackRefCache, | |
| psId -> { | |
| // TODO(dborowitz): Reuse open repository from caller. | |
| ChangeNotes notes = notesFactory.createChecked(project, psId.changeId()); | |
| PatchSet ps = psUtil.get(notes, psId); | |
| return ps != null ? ps.groups() : null; | |
| }); | |
| } | |
| /** | |
| * Returns a new {@link GroupCollector} instance. | |
| * | |
| * <p>Used in production code by using {@link com.google.gerrit.server.notedb.ChangeNotes.Factory} | |
| * to get a group SHA1 (40 bytes string representation) from a {@link | |
| * com.google.gerrit.entities.PatchSet.Id}. Unit tests use this method directly by passing their | |
| * own lookup function. | |
| * | |
| * @see GroupCollector for what this class does. | |
| */ | |
| @VisibleForTesting | |
| GroupCollector(ReceivePackRefCache receivePackRefCache, Lookup groupLookup) { | |
| this.receivePackRefCache = receivePackRefCache; | |
| this.groupLookup = groupLookup; | |
| groups = MultimapBuilder.hashKeys().arrayListValues().build(); | |
| groupAliases = MultimapBuilder.hashKeys().hashSetValues().build(); | |
| } | |
| /** | |
| * Process the given {@link RevCommit}. 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. | |
| * | |
| * @see GroupCollector for what this class does. | |
| */ | |
| public void visit(RevCommit c) throws IOException { | |
| checkState(!done, "visit() called after getGroups()"); | |
| Set<RevCommit> interestingParents = getInterestingParents(c); | |
| if (interestingParents.isEmpty()) { | |
| // 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); | |
| } | |
| } | |
| /** | |
| * Returns the groups that got collected from visiting commits using {@link #visit(RevCommit)}. | |
| */ | |
| public SortedSetMultimap<ObjectId, String> getGroups() throws IOException { | |
| done = true; | |
| SortedSetMultimap<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) throws IOException { | |
| ObjectId id = parseGroup(commit, group); | |
| return id != null && !receivePackRefCache.patchSetIdsFromObjectId(id).isEmpty(); | |
| } | |
| private Set<String> resolveGroups(ObjectId forCommit, Collection<String> candidates) | |
| throws IOException { | |
| 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; | |
| } | |
| @Nullable | |
| 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? | |
| logger.atWarning().log("group for commit %s is not a SHA-1: %s", forCommit.name(), group); | |
| return null; | |
| } | |
| } | |
| private Iterable<String> resolveGroup(ObjectId forCommit, String group) throws IOException { | |
| ObjectId id = parseGroup(forCommit, group); | |
| if (id != null) { | |
| PatchSet.Id psId = Iterables.getFirst(receivePackRefCache.patchSetIdsFromObjectId(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); | |
| } | |
| } |