blob: 862eb2bde8a633fcfbb101cc82961683f6cb202b [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 com.google.gerrit.common.data.LabelType;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.Branch;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.PatchSetApproval;
import com.google.gerrit.reviewdb.client.PatchSetApproval.LabelId;
import com.google.gerrit.reviewdb.server.ReviewDb;
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.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.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.MutableObjectId;
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.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.eclipse.jgit.transport.PackParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.TimeZone;
import javax.annotation.Nullable;
public class MergeUtil {
private static final Logger log = LoggerFactory.getLogger(MergeUtil.class);
public static interface Factory {
MergeUtil create(ProjectState project);
MergeUtil create(ProjectState project, boolean useContentMerge);
}
private static final String R_HEADS_MASTER =
Constants.R_HEADS + Constants.MASTER;
private static final FooterKey REVIEWED_ON = new FooterKey("Reviewed-on");
private static final FooterKey CHANGE_ID = new FooterKey("Change-Id");
private final Provider<ReviewDb> db;
private final IdentifiedUser.GenericFactory identifiedUserFactory;
private final Provider<String> urlProvider;
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,
@Assisted final ProjectState project) {
this(serverConfig, db, identifiedUserFactory, urlProvider, project,
project.isUseContentMerge());
}
@AssistedInject
MergeUtil(@GerritServerConfig Config serverConfig,
final Provider<ReviewDb> db,
final IdentifiedUser.GenericFactory identifiedUserFactory,
@CanonicalWebUrl @Nullable final Provider<String> urlProvider,
@Assisted final ProjectState project,
@Assisted boolean useContentMerge) {
this.db = db;
this.identifiedUserFactory = identifiedUserFactory;
this.urlProvider = urlProvider;
this.project = project;
this.useContentMerge = useContentMerge;
this.useRecursiveMerge =
serverConfig.getBoolean("core", null, "useRecursiveMerge", false);
}
public CodeReviewCommit getFirstFastForward(
final CodeReviewCommit mergeTip, final RevWalk rw,
final List<CodeReviewCommit> toMerge) throws MergeException {
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 MergeException("Cannot fast-forward test during merge", e);
}
}
return mergeTip;
}
public void reduceToMinimalMerge(final MergeSorter mergeSorter,
final List<CodeReviewCommit> toSort) throws MergeException {
final Collection<CodeReviewCommit> heads;
try {
heads = mergeSorter.sort(toSort);
} catch (IOException e) {
throw new MergeException("Branch head sorting failed", e);
}
toSort.clear();
toSort.addAll(heads);
Collections.sort(toSort, new Comparator<CodeReviewCommit>() {
@Override
public int compare(final CodeReviewCommit a, final CodeReviewCommit b) {
return a.originalOrder - b.originalOrder;
}
});
}
public PatchSetApproval getSubmitter(final PatchSet.Id c) {
return getSubmitter(db.get(), c);
}
public static PatchSetApproval getSubmitter(final ReviewDb reviewDb,
final PatchSet.Id c) {
if (c == null) {
return null;
}
PatchSetApproval submitter = null;
try {
final List<PatchSetApproval> approvals =
reviewDb.patchSetApprovals().byPatchSet(c).toList();
for (PatchSetApproval a : approvals) {
if (a.getValue() > 0 && a.isSubmit()) {
if (submitter == null
|| a.getGranted().compareTo(submitter.getGranted()) > 0) {
submitter = a;
}
}
}
} catch (OrmException e) {
}
return submitter;
}
public CodeReviewCommit createCherryPickFromCommit(Repository repo,
ObjectInserter inserter, CodeReviewCommit mergeTip, CodeReviewCommit originalCommit,
PersonIdent cherryPickCommitterIdent, String commitMsg, RevWalk rw)
throws MissingObjectException, IncorrectObjectTypeException, IOException {
final ThreeWayMerger m = newThreeWayMerger(repo, inserter);
m.setBase(originalCommit.getParent(0));
if (m.merge(mergeTip, originalCommit)) {
final CommitBuilder mergeCommit = new CommitBuilder();
mergeCommit.setTreeId(m.getResultTreeId());
mergeCommit.setParentId(mergeTip);
mergeCommit.setAuthor(originalCommit.getAuthorIdent());
mergeCommit.setCommitter(cherryPickCommitterIdent);
mergeCommit.setMessage(commitMsg);
final ObjectId id = commit(inserter, mergeCommit);
final CodeReviewCommit newCommit =
(CodeReviewCommit) rw.parseCommit(id);
return newCommit;
} else {
return null;
}
}
public String createCherryPickCommitMessage(final CodeReviewCommit n) {
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, CHANGE_ID, n.change.getKey().get())) {
msgbuf.append(CHANGE_ID.getName());
msgbuf.append(": ");
msgbuf.append(n.change.getKey().get());
msgbuf.append('\n');
}
final String siteUrl = urlProvider.get();
if (siteUrl != null) {
final String url = siteUrl + n.patchsetId.getParentKey().get();
if (!contains(footers, REVIEWED_ON, url)) {
msgbuf.append(REVIEWED_ON.getName());
msgbuf.append(": ");
msgbuf.append(url);
msgbuf.append('\n');
}
}
PatchSetApproval submitAudit = null;
for (final PatchSetApproval a : getApprovalsForCommit(n)) {
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();
}
private static boolean isCodeReview(LabelId id) {
return "Code-Review".equalsIgnoreCase(id.get());
}
private static boolean isVerified(LabelId id) {
return "Verified".equalsIgnoreCase(id.get());
}
public List<PatchSetApproval> getApprovalsForCommit(final CodeReviewCommit n) {
try {
List<PatchSetApproval> approvalList =
db.get().patchSetApprovals().byPatchSet(n.patchsetId).toList();
Collections.sort(approvalList, new Comparator<PatchSetApproval>() {
@Override
public int compare(final PatchSetApproval a, final PatchSetApproval b) {
return a.getGranted().compareTo(b.getGranted());
}
});
return approvalList;
} catch (OrmException e) {
log.error("Can't read approval records for " + n.patchsetId, 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 PersonIdent computeMergeCommitAuthor(final PersonIdent myIdent,
final RevWalk rw, final List<CodeReviewCommit> codeReviewCommits) {
PatchSetApproval submitter = null;
for (final CodeReviewCommit c : codeReviewCommits) {
PatchSetApproval s = getSubmitter(c.patchsetId);
if (submitter == null
|| (s != null && s.getGranted().compareTo(submitter.getGranted()) > 0)) {
submitter = s;
}
}
// Try to use the submitter's identity for the merge commit author.
// If all of the commits being merged are created by the submitter,
// prefer the identity line they used in the commits rather than the
// preferred identity stored in the user account. This way the Git
// commit records are more consistent internally.
//
PersonIdent authorIdent;
if (submitter != null) {
IdentifiedUser who =
identifiedUserFactory.create(submitter.getAccountId());
Set<String> emails = new HashSet<String>();
for (RevCommit c : codeReviewCommits) {
try {
rw.parseBody(c);
} catch (IOException e) {
log.warn("Cannot parse commit " + c.name(), e);
continue;
}
emails.add(c.getAuthorIdent().getEmailAddress());
}
final Timestamp dt = submitter.getGranted();
final TimeZone tz = myIdent.getTimeZone();
if (emails.size() == 1
&& who.getEmailAddresses().contains(emails.iterator().next())) {
authorIdent =
new PersonIdent(codeReviewCommits.get(0).getAuthorIdent(), dt, tz);
} else {
authorIdent = who.newCommitterIdent(dt, tz);
}
} else {
authorIdent = myIdent;
}
return authorIdent;
}
public boolean canMerge(final MergeSorter mergeSorter,
final Repository repo, final CodeReviewCommit mergeTip,
final CodeReviewCommit toMerge)
throws MergeException {
if (hasMissingDependencies(mergeSorter, toMerge)) {
return false;
}
final ThreeWayMerger m = newThreeWayMerger(repo, createDryRunInserter());
try {
return m.merge(new AnyObjectId[] {mergeTip, toMerge});
} catch (NoMergeBaseException e) {
return false;
} catch (IOException e) {
throw new MergeException("Cannot merge " + toMerge.name(), e);
}
}
public boolean canFastForward(final MergeSorter mergeSorter,
final CodeReviewCommit mergeTip, final RevWalk rw,
final CodeReviewCommit toMerge) throws MergeException {
if (hasMissingDependencies(mergeSorter, toMerge)) {
return false;
}
try {
return mergeTip == null || rw.isMergedInto(mergeTip, toMerge);
} catch (IOException e) {
throw new MergeException("Cannot fast-forward test during merge", e);
}
}
public boolean canCherryPick(final MergeSorter mergeSorter,
final Repository repo, final CodeReviewCommit mergeTip, final RevWalk rw,
final CodeReviewCommit toMerge) throws MergeException {
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 {
final ThreeWayMerger m =
newThreeWayMerger(repo, createDryRunInserter());
m.setBase(toMerge.getParent(0));
return m.merge(mergeTip, toMerge);
} catch (IOException e) {
throw new MergeException("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 MergeException {
try {
return !mergeSorter.sort(Collections.singleton(toMerge)).contains(toMerge);
} catch (IOException e) {
throw new MergeException("Branch head sorting failed", e);
}
}
public ObjectInserter createDryRunInserter() {
return new ObjectInserter() {
private final MutableObjectId buf = new MutableObjectId();
private final static int LAST_BYTE = Constants.OBJECT_ID_LENGTH - 1;
@Override
public ObjectId insert(int objectType, long length, InputStream in)
throws IOException {
// create non-existing dummy ID
buf.setByte(LAST_BYTE, buf.getByte(LAST_BYTE) + 1);
return buf.copy();
}
@Override
public PackParser newPackParser(InputStream in) throws IOException {
throw new UnsupportedOperationException();
}
@Override
public void flush() throws IOException {
// Do nothing.
}
@Override
public void release() {
// Do nothing.
}
};
}
public CodeReviewCommit mergeOneCommit(final PersonIdent myIdent,
final Repository repo, final RevWalk rw, final ObjectInserter inserter,
final RevFlag canMergeFlag, final Branch.NameKey destBranch,
final CodeReviewCommit mergeTip, final CodeReviewCommit n)
throws MergeException {
final ThreeWayMerger m = newThreeWayMerger(repo, inserter);
try {
if (m.merge(new AnyObjectId[] {mergeTip, n})) {
return writeMergeCommit(myIdent, 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 MergeException("Cannot merge " + n.name(), e);
}
} catch (IOException e) {
throw new MergeException("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(final RevWalk rw,
final RevFlag canMergeFlag, final CodeReviewCommit mergeTip,
final CodeReviewCommit n, final CommitMergeStatus failure)
throws MissingObjectException, IncorrectObjectTypeException, IOException {
rw.resetRetain(canMergeFlag);
rw.markStart(n);
rw.markUninteresting(mergeTip);
CodeReviewCommit failed;
while ((failed = (CodeReviewCommit) rw.next()) != null) {
failed.statusCode = failure;
}
return failed;
}
public CodeReviewCommit writeMergeCommit(final PersonIdent myIdent,
final RevWalk rw, final ObjectInserter inserter,
final RevFlag canMergeFlag, final Branch.NameKey destBranch,
final CodeReviewCommit mergeTip, final ObjectId treeId,
final CodeReviewCommit n) throws IOException,
MissingObjectException, IncorrectObjectTypeException {
final List<CodeReviewCommit> merged = new ArrayList<CodeReviewCommit>();
rw.resetRetain(canMergeFlag);
rw.markStart(n);
rw.markUninteresting(mergeTip);
for (final RevCommit c : rw) {
final CodeReviewCommit crc = (CodeReviewCommit) c;
if (crc.patchsetId != null) {
merged.add(crc);
}
}
final StringBuilder msgbuf = new StringBuilder();
if (merged.size() == 1) {
final CodeReviewCommit c = merged.get(0);
rw.parseBody(c);
msgbuf.append("Merge \"");
msgbuf.append(c.getShortMessage());
msgbuf.append("\"");
} else {
msgbuf.append("Merge changes ");
for (final Iterator<CodeReviewCommit> i = merged.iterator(); i.hasNext();) {
msgbuf.append(i.next().change.getKey().abbreviate());
if (i.hasNext()) {
msgbuf.append(',');
}
}
}
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");
}
}
PersonIdent authorIdent = computeMergeCommitAuthor(myIdent, rw, merged);
final CommitBuilder mergeCommit = new CommitBuilder();
mergeCommit.setTreeId(treeId);
mergeCommit.setParentIds(mergeTip, n);
mergeCommit.setAuthor(authorIdent);
mergeCommit.setCommitter(myIdent);
mergeCommit.setMessage(msgbuf.toString());
return (CodeReviewCommit) rw.parseCommit(commit(inserter, mergeCommit));
}
public ThreeWayMerger newThreeWayMerger(final Repository repo,
final ObjectInserter inserter) {
ThreeWayMerger m;
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) {
m = MergeStrategy.RECURSIVE.newMerger(repo, true);
} else {
m = MergeStrategy.RESOLVE.newMerger(repo, true);
}
} else {
// No auto conflict resolving allowed. If any of the
// affected files was modified, merge will fail.
m = MergeStrategy.SIMPLE_TWO_WAY_IN_CORE.newMerger(repo);
}
m.setObjectInserter(new ObjectInserter.Filter() {
@Override
protected ObjectInserter delegate() {
return inserter;
}
@Override
public void flush() {
}
@Override
public void release() {
}
});
return m;
}
public ObjectId commit(final ObjectInserter inserter,
final CommitBuilder mergeCommit) throws IOException,
UnsupportedEncodingException {
ObjectId id = inserter.insert(mergeCommit);
inserter.flush();
return id;
}
public PatchSetApproval markCleanMerges(final RevWalk rw,
final RevFlag canMergeFlag, final CodeReviewCommit mergeTip,
final Set<RevCommit> alreadyAccepted) throws MergeException {
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 null;
}
try {
PatchSetApproval submitApproval = null;
rw.resetRetain(canMergeFlag);
rw.sort(RevSort.TOPO);
rw.sort(RevSort.REVERSE, true);
rw.markStart(mergeTip);
for (RevCommit c : alreadyAccepted) {
rw.markUninteresting(c);
}
CodeReviewCommit c;
while ((c = (CodeReviewCommit) rw.next()) != null) {
if (c.patchsetId != null) {
c.statusCode = CommitMergeStatus.CLEAN_MERGE;
if (submitApproval == null) {
submitApproval = getSubmitter(c.patchsetId);
}
}
}
return submitApproval;
} catch (IOException e) {
throw new MergeException("Cannot mark clean merges", e);
}
}
}