blob: 13476ef1a8ccf0b1e14cfa4a9bb0d48aaf4c1f13 [file] [log] [blame]
// Copyright (C) 2012 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.server.git;
import static com.google.common.base.Preconditions.checkArgument;
import com.google.common.base.Function;
import com.google.common.base.Joiner;
import com.google.common.base.Strings;
import com.google.common.collect.Iterables;
import com.google.gerrit.common.FooterConstants;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.data.LabelType;
import com.google.gerrit.extensions.restapi.MergeConflictException;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.Branch;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.LabelId;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.PatchSetApproval;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.ApprovalsUtil;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.config.CanonicalWebUrl;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
import com.google.gerrit.server.project.ChangeControl;
import com.google.gerrit.server.project.ProjectState;
import com.google.gwtorm.server.OrmException;
import com.google.inject.Provider;
import com.google.inject.assistedinject.Assisted;
import com.google.inject.assistedinject.AssistedInject;
import org.eclipse.jgit.errors.IncorrectObjectTypeException;
import org.eclipse.jgit.errors.LargeObjectException;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.errors.NoMergeBaseException;
import org.eclipse.jgit.errors.NoMergeBaseException.MergeBaseFailureReason;
import org.eclipse.jgit.lib.AnyObjectId;
import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.merge.MergeStrategy;
import org.eclipse.jgit.merge.Merger;
import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
import org.eclipse.jgit.merge.ThreeWayMerger;
import org.eclipse.jgit.revwalk.FooterKey;
import org.eclipse.jgit.revwalk.FooterLine;
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.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
public class MergeUtil {
private static final Logger log = LoggerFactory.getLogger(MergeUtil.class);
private static final String R_HEADS_MASTER =
Constants.R_HEADS + Constants.MASTER;
public static boolean useRecursiveMerge(Config cfg) {
return cfg.getBoolean("core", null, "useRecursiveMerge", true);
}
public static ThreeWayMergeStrategy getMergeStrategy(Config cfg) {
return useRecursiveMerge(cfg)
? MergeStrategy.RECURSIVE
: MergeStrategy.RESOLVE;
}
public static interface Factory {
MergeUtil create(ProjectState project);
MergeUtil create(ProjectState project, boolean useContentMerge);
}
private final Provider<ReviewDb> db;
private final IdentifiedUser.GenericFactory identifiedUserFactory;
private final Provider<String> urlProvider;
private final ApprovalsUtil approvalsUtil;
private final ProjectState project;
private final boolean useContentMerge;
private final boolean useRecursiveMerge;
@AssistedInject
MergeUtil(@GerritServerConfig Config serverConfig,
final Provider<ReviewDb> db,
final IdentifiedUser.GenericFactory identifiedUserFactory,
@CanonicalWebUrl @Nullable final Provider<String> urlProvider,
final ApprovalsUtil approvalsUtil,
@Assisted final ProjectState project) {
this(serverConfig, db, identifiedUserFactory, urlProvider, approvalsUtil,
project, project.isUseContentMerge());
}
@AssistedInject
MergeUtil(@GerritServerConfig Config serverConfig,
final Provider<ReviewDb> db,
final IdentifiedUser.GenericFactory identifiedUserFactory,
@CanonicalWebUrl @Nullable final Provider<String> urlProvider,
final ApprovalsUtil approvalsUtil,
@Assisted final ProjectState project,
@Assisted boolean useContentMerge) {
this.db = db;
this.identifiedUserFactory = identifiedUserFactory;
this.urlProvider = urlProvider;
this.approvalsUtil = approvalsUtil;
this.project = project;
this.useContentMerge = useContentMerge;
this.useRecursiveMerge = useRecursiveMerge(serverConfig);
}
public CodeReviewCommit getFirstFastForward(
final CodeReviewCommit mergeTip, final RevWalk rw,
final List<CodeReviewCommit> toMerge) throws IntegrationException {
for (final Iterator<CodeReviewCommit> i = toMerge.iterator(); i.hasNext();) {
try {
final CodeReviewCommit n = i.next();
if (mergeTip == null || rw.isMergedInto(mergeTip, n)) {
i.remove();
return n;
}
} catch (IOException e) {
throw new IntegrationException(
"Cannot fast-forward test during merge", e);
}
}
return mergeTip;
}
public List<CodeReviewCommit> reduceToMinimalMerge(MergeSorter mergeSorter,
Collection<CodeReviewCommit> toSort) throws IntegrationException {
List<CodeReviewCommit> result = new ArrayList<>();
try {
result.addAll(mergeSorter.sort(toSort));
} catch (IOException e) {
throw new IntegrationException("Branch head sorting failed", e);
}
Collections.sort(result, CodeReviewCommit.ORDER);
return result;
}
public CodeReviewCommit createCherryPickFromCommit(Repository repo,
ObjectInserter inserter, RevCommit mergeTip, RevCommit originalCommit,
PersonIdent cherryPickCommitterIdent, String commitMsg,
CodeReviewRevWalk rw)
throws MissingObjectException, IncorrectObjectTypeException, IOException,
MergeIdenticalTreeException, MergeConflictException {
final ThreeWayMerger m = newThreeWayMerger(repo, inserter);
m.setBase(originalCommit.getParent(0));
if (m.merge(mergeTip, originalCommit)) {
ObjectId tree = m.getResultTreeId();
if (tree.equals(mergeTip.getTree())) {
throw new MergeIdenticalTreeException("identical tree");
}
CommitBuilder mergeCommit = new CommitBuilder();
mergeCommit.setTreeId(tree);
mergeCommit.setParentId(mergeTip);
mergeCommit.setAuthor(originalCommit.getAuthorIdent());
mergeCommit.setCommitter(cherryPickCommitterIdent);
mergeCommit.setMessage(commitMsg);
return rw.parseCommit(commit(inserter, mergeCommit));
} else {
throw new MergeConflictException("merge conflict");
}
}
public String createCherryPickCommitMessage(RevCommit n, ChangeControl ctl,
PatchSet.Id psId) {
Change c = ctl.getChange();
final List<FooterLine> footers = n.getFooterLines();
final StringBuilder msgbuf = new StringBuilder();
msgbuf.append(n.getFullMessage());
if (msgbuf.length() == 0) {
// WTF, an empty commit message?
msgbuf.append("<no commit message provided>");
}
if (msgbuf.charAt(msgbuf.length() - 1) != '\n') {
// Missing a trailing LF? Correct it (perhaps the editor was broken).
msgbuf.append('\n');
}
if (footers.isEmpty()) {
// Doesn't end in a "Signed-off-by: ..." style line? Add another line
// break to start a new paragraph for the reviewed-by tag lines.
//
msgbuf.append('\n');
}
if (!contains(footers, FooterConstants.CHANGE_ID, c.getKey().get())) {
msgbuf.append(FooterConstants.CHANGE_ID.getName());
msgbuf.append(": ");
msgbuf.append(c.getKey().get());
msgbuf.append('\n');
}
final String siteUrl = urlProvider.get();
if (siteUrl != null) {
final String url = siteUrl + c.getId().get();
if (!contains(footers, FooterConstants.REVIEWED_ON, url)) {
msgbuf.append(FooterConstants.REVIEWED_ON.getName());
msgbuf.append(": ");
msgbuf.append(url);
msgbuf.append('\n');
}
}
PatchSetApproval submitAudit = null;
for (final PatchSetApproval a : safeGetApprovals(ctl, psId)) {
if (a.getValue() <= 0) {
// Negative votes aren't counted.
continue;
}
if (a.isSubmit()) {
// Submit is treated specially, below (becomes committer)
//
if (submitAudit == null
|| a.getGranted().compareTo(submitAudit.getGranted()) > 0) {
submitAudit = a;
}
continue;
}
final Account acc =
identifiedUserFactory.create(a.getAccountId()).getAccount();
final StringBuilder identbuf = new StringBuilder();
if (acc.getFullName() != null && acc.getFullName().length() > 0) {
if (identbuf.length() > 0) {
identbuf.append(' ');
}
identbuf.append(acc.getFullName());
}
if (acc.getPreferredEmail() != null
&& acc.getPreferredEmail().length() > 0) {
if (isSignedOffBy(footers, acc.getPreferredEmail())) {
continue;
}
if (identbuf.length() > 0) {
identbuf.append(' ');
}
identbuf.append('<');
identbuf.append(acc.getPreferredEmail());
identbuf.append('>');
}
if (identbuf.length() == 0) {
// Nothing reasonable to describe them by? Ignore them.
continue;
}
final String tag;
if (isCodeReview(a.getLabelId())) {
tag = "Reviewed-by";
} else if (isVerified(a.getLabelId())) {
tag = "Tested-by";
} else {
final LabelType lt = project.getLabelTypes().byLabel(a.getLabelId());
if (lt == null) {
continue;
}
tag = lt.getName();
}
if (!contains(footers, new FooterKey(tag), identbuf.toString())) {
msgbuf.append(tag);
msgbuf.append(": ");
msgbuf.append(identbuf);
msgbuf.append('\n');
}
}
return msgbuf.toString();
}
public String createCherryPickCommitMessage(final CodeReviewCommit n) {
return createCherryPickCommitMessage(n, n.getControl(), n.getPatchsetId());
}
private static boolean isCodeReview(LabelId id) {
return "Code-Review".equalsIgnoreCase(id.get());
}
private static boolean isVerified(LabelId id) {
return "Verified".equalsIgnoreCase(id.get());
}
private Iterable<PatchSetApproval> safeGetApprovals(
ChangeControl ctl, PatchSet.Id psId) {
try {
return approvalsUtil.byPatchSet(db.get(), ctl, psId);
} catch (OrmException e) {
log.error("Can't read approval records for " + psId, e);
return Collections.emptyList();
}
}
private static boolean contains(List<FooterLine> footers, FooterKey key, String val) {
for (final FooterLine line : footers) {
if (line.matches(key) && val.equals(line.getValue())) {
return true;
}
}
return false;
}
private static boolean isSignedOffBy(List<FooterLine> footers, String email) {
for (final FooterLine line : footers) {
if (line.matches(FooterKey.SIGNED_OFF_BY)
&& email.equals(line.getEmailAddress())) {
return true;
}
}
return false;
}
public boolean canMerge(final MergeSorter mergeSorter,
final Repository repo, final CodeReviewCommit mergeTip,
final CodeReviewCommit toMerge)
throws IntegrationException {
if (hasMissingDependencies(mergeSorter, toMerge)) {
return false;
}
try (ObjectInserter ins = new InMemoryInserter(repo)) {
return newThreeWayMerger(repo, ins)
.merge(new AnyObjectId[] {mergeTip, toMerge});
} catch (LargeObjectException e) {
log.warn("Cannot merge due to LargeObjectException: " + toMerge.name());
return false;
} catch (NoMergeBaseException e) {
return false;
} catch (IOException e) {
throw new IntegrationException("Cannot merge " + toMerge.name(), e);
}
}
public boolean canFastForward(MergeSorter mergeSorter,
CodeReviewCommit mergeTip, CodeReviewRevWalk rw, CodeReviewCommit toMerge)
throws IntegrationException {
if (hasMissingDependencies(mergeSorter, toMerge)) {
return false;
}
try {
return mergeTip == null || rw.isMergedInto(mergeTip, toMerge);
} catch (IOException e) {
throw new IntegrationException("Cannot fast-forward test during merge", e);
}
}
public boolean canCherryPick(MergeSorter mergeSorter, Repository repo,
CodeReviewCommit mergeTip, CodeReviewRevWalk rw, CodeReviewCommit toMerge)
throws IntegrationException {
if (mergeTip == null) {
// The branch is unborn. Fast-forward is possible.
//
return true;
}
if (toMerge.getParentCount() == 0) {
// Refuse to merge a root commit into an existing branch,
// we cannot obtain a delta for the cherry-pick to apply.
//
return false;
}
if (toMerge.getParentCount() == 1) {
// If there is only one parent, a cherry-pick can be done by
// taking the delta relative to that one parent and redoing
// that on the current merge tip.
//
try (ObjectInserter ins = new InMemoryInserter(repo)) {
ThreeWayMerger m = newThreeWayMerger(repo, ins);
m.setBase(toMerge.getParent(0));
return m.merge(mergeTip, toMerge);
} catch (IOException e) {
throw new IntegrationException("Cannot merge " + toMerge.name(), e);
}
}
// There are multiple parents, so this is a merge commit. We
// don't want to cherry-pick the merge as clients can't easily
// rebase their history with that merge present and replaced
// by an equivalent merge with a different first parent. So
// instead behave as though MERGE_IF_NECESSARY was configured.
//
return canFastForward(mergeSorter, mergeTip, rw, toMerge)
|| canMerge(mergeSorter, repo, mergeTip, toMerge);
}
public boolean hasMissingDependencies(final MergeSorter mergeSorter,
final CodeReviewCommit toMerge) throws IntegrationException {
try {
return !mergeSorter.sort(Collections.singleton(toMerge)).contains(toMerge);
} catch (IOException e) {
throw new IntegrationException("Branch head sorting failed", e);
}
}
public CodeReviewCommit mergeOneCommit(PersonIdent author,
PersonIdent committer, Repository repo, CodeReviewRevWalk rw,
ObjectInserter inserter, RevFlag canMergeFlag, Branch.NameKey destBranch,
CodeReviewCommit mergeTip, CodeReviewCommit n)
throws IntegrationException {
final ThreeWayMerger m = newThreeWayMerger(repo, inserter);
try {
if (m.merge(new AnyObjectId[] {mergeTip, n})) {
return writeMergeCommit(author, committer, rw, inserter, canMergeFlag,
destBranch, mergeTip, m.getResultTreeId(), n);
} else {
failed(rw, canMergeFlag, mergeTip, n, CommitMergeStatus.PATH_CONFLICT);
}
} catch (NoMergeBaseException e) {
try {
failed(rw, canMergeFlag, mergeTip, n,
getCommitMergeStatus(e.getReason()));
} catch (IOException e2) {
throw new IntegrationException("Cannot merge " + n.name(), e);
}
} catch (IOException e) {
throw new IntegrationException("Cannot merge " + n.name(), e);
}
return mergeTip;
}
private static CommitMergeStatus getCommitMergeStatus(
MergeBaseFailureReason reason) {
switch (reason) {
case MULTIPLE_MERGE_BASES_NOT_SUPPORTED:
case TOO_MANY_MERGE_BASES:
default:
return CommitMergeStatus.MANUAL_RECURSIVE_MERGE;
case CONFLICTS_DURING_MERGE_BASE_CALCULATION:
return CommitMergeStatus.PATH_CONFLICT;
}
}
private static CodeReviewCommit failed(CodeReviewRevWalk rw,
RevFlag canMergeFlag, CodeReviewCommit mergeTip, CodeReviewCommit n,
CommitMergeStatus failure)
throws MissingObjectException, IncorrectObjectTypeException, IOException {
rw.resetRetain(canMergeFlag);
rw.markStart(n);
rw.markUninteresting(mergeTip);
CodeReviewCommit failed;
while ((failed = rw.next()) != null) {
failed.setStatusCode(failure);
}
return failed;
}
public CodeReviewCommit writeMergeCommit(PersonIdent author,
PersonIdent committer, CodeReviewRevWalk rw, ObjectInserter inserter,
RevFlag canMergeFlag, Branch.NameKey destBranch,
CodeReviewCommit mergeTip, ObjectId treeId, CodeReviewCommit n)
throws IOException, MissingObjectException,
IncorrectObjectTypeException {
final List<CodeReviewCommit> merged = new ArrayList<>();
rw.resetRetain(canMergeFlag);
rw.markStart(n);
rw.markUninteresting(mergeTip);
CodeReviewCommit crc;
while ((crc = rw.next()) != null) {
if (crc.getPatchsetId() != null) {
merged.add(crc);
}
}
StringBuilder msgbuf = new StringBuilder().append(summarize(rw, merged));
if (!R_HEADS_MASTER.equals(destBranch.get())) {
msgbuf.append(" into ");
msgbuf.append(destBranch.getShortName());
}
if (merged.size() > 1) {
msgbuf.append("\n\n* changes:\n");
for (final CodeReviewCommit c : merged) {
rw.parseBody(c);
msgbuf.append(" ");
msgbuf.append(c.getShortMessage());
msgbuf.append("\n");
}
}
final CommitBuilder mergeCommit = new CommitBuilder();
mergeCommit.setTreeId(treeId);
mergeCommit.setParentIds(mergeTip, n);
mergeCommit.setAuthor(author);
mergeCommit.setCommitter(committer);
mergeCommit.setMessage(msgbuf.toString());
CodeReviewCommit mergeResult =
rw.parseCommit(commit(inserter, mergeCommit));
mergeResult.setControl(n.getControl());
return mergeResult;
}
private String summarize(RevWalk rw, List<CodeReviewCommit> merged)
throws IOException {
if (merged.size() == 1) {
CodeReviewCommit c = merged.get(0);
rw.parseBody(c);
return String.format("Merge \"%s\"", c.getShortMessage());
}
LinkedHashSet<String> topics = new LinkedHashSet<>(4);
for (CodeReviewCommit c : merged) {
if (!Strings.isNullOrEmpty(c.change().getTopic())) {
topics.add(c.change().getTopic());
}
}
if (topics.size() == 1) {
return String.format("Merge changes from topic '%s'",
Iterables.getFirst(topics, null));
} else if (topics.size() > 1) {
return String.format("Merge changes from topics '%s'",
Joiner.on("', '").join(topics));
} else {
return String.format("Merge changes %s%s",
Joiner.on(',').join(Iterables.transform(
Iterables.limit(merged, 5),
new Function<CodeReviewCommit, String>() {
@Override
public String apply(CodeReviewCommit in) {
return in.change().getKey().abbreviate();
}
})),
merged.size() > 5 ? ", ..." : "");
}
}
public ThreeWayMerger newThreeWayMerger(final Repository repo,
final ObjectInserter inserter) {
return newThreeWayMerger(repo, inserter, mergeStrategyName());
}
public String mergeStrategyName() {
return mergeStrategyName(useContentMerge, useRecursiveMerge);
}
public static String mergeStrategyName(boolean useContentMerge,
boolean useRecursiveMerge) {
if (useContentMerge) {
// Settings for this project allow us to try and automatically resolve
// conflicts within files if needed. Use either the old resolve merger or
// new recursive merger, and instruct to operate in core.
if (useRecursiveMerge) {
return MergeStrategy.RECURSIVE.getName();
} else {
return MergeStrategy.RESOLVE.getName();
}
} else {
// No auto conflict resolving allowed. If any of the
// affected files was modified, merge will fail.
return MergeStrategy.SIMPLE_TWO_WAY_IN_CORE.getName();
}
}
public static ThreeWayMerger newThreeWayMerger(Repository repo,
final ObjectInserter inserter, String strategyName) {
MergeStrategy strategy = MergeStrategy.get(strategyName);
checkArgument(strategy != null, "invalid merge strategy: %s", strategyName);
Merger m = strategy.newMerger(repo, true);
checkArgument(m instanceof ThreeWayMerger,
"merge strategy %s does not support three-way merging", strategyName);
m.setObjectInserter(new ObjectInserter.Filter() {
@Override
protected ObjectInserter delegate() {
return inserter;
}
@Override
public void flush() {
}
@Override
public void close() {
}
});
return (ThreeWayMerger) m;
}
public ObjectId commit(final ObjectInserter inserter,
final CommitBuilder mergeCommit) throws IOException,
UnsupportedEncodingException {
ObjectId id = inserter.insert(mergeCommit);
inserter.flush();
return id;
}
public void markCleanMerges(final RevWalk rw,
final RevFlag canMergeFlag, final CodeReviewCommit mergeTip,
final Set<RevCommit> alreadyAccepted) throws IntegrationException {
if (mergeTip == null) {
// If mergeTip is null here, branchTip was null, indicating a new branch
// at the start of the merge process. We also elected to merge nothing,
// probably due to missing dependencies. Nothing was cleanly merged.
//
return;
}
try {
rw.resetRetain(canMergeFlag);
rw.sort(RevSort.TOPO);
rw.sort(RevSort.REVERSE, true);
rw.markStart(mergeTip);
for (RevCommit c : alreadyAccepted) {
// If branch was not created by this submit.
if (!Objects.equals(c, mergeTip)) {
rw.markUninteresting(c);
}
}
CodeReviewCommit c;
while ((c = (CodeReviewCommit) rw.next()) != null) {
if (c.getPatchsetId() != null) {
c.setStatusCode(CommitMergeStatus.CLEAN_MERGE);
}
}
} catch (IOException e) {
throw new IntegrationException("Cannot mark clean merges", e);
}
}
}