blob: 4773680f8e5b3c9caef20c6f851c953dad90eb89 [file] [log] [blame]
// Copyright (C) 2008 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 java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.concurrent.TimeUnit.MINUTES;
import com.google.gerrit.common.ChangeHooks;
import com.google.gerrit.common.data.ApprovalType;
import com.google.gerrit.common.data.ApprovalTypes;
import com.google.gerrit.common.data.Capable;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.ApprovalCategory;
import com.google.gerrit.reviewdb.client.Branch;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.ChangeMessage;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.PatchSetAncestor;
import com.google.gerrit.reviewdb.client.PatchSetApproval;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.reviewdb.client.RevId;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.config.CanonicalWebUrl;
import com.google.gerrit.server.mail.MergeFailSender;
import com.google.gerrit.server.mail.MergedSender;
import com.google.gerrit.server.patch.PatchSetInfoFactory;
import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
import com.google.gerrit.server.project.ChangeControl;
import com.google.gerrit.server.project.NoSuchChangeException;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.util.RequestScopePropagator;
import com.google.gerrit.server.workflow.CategoryFunction;
import com.google.gerrit.server.workflow.FunctionState;
import com.google.gwtorm.server.AtomicUpdate;
import com.google.gwtorm.server.OrmConcurrencyException;
import com.google.gwtorm.server.OrmException;
import com.google.gwtorm.server.SchemaFactory;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.assistedinject.Assisted;
import org.eclipse.jgit.errors.IncorrectObjectTypeException;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.lib.AnyObjectId;
import org.eclipse.jgit.lib.CommitBuilder;
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.Ref;
import org.eclipse.jgit.lib.RefUpdate;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.merge.MergeStrategy;
import org.eclipse.jgit.merge.Merger;
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.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import javax.annotation.Nullable;
/**
* Merges changes in submission order into a single branch.
* <p>
* Branches are reduced to the minimum number of heads needed to merge
* everything. This allows commits to be entered into the queue in any order
* (such as ancestors before descendants) and only the most recent commit on any
* line of development will be merged. All unmerged commits along a line of
* development must be in the submission queue in order to merge the tip of that
* line.
* <p>
* Conflicts are handled by discarding the entire line of development and
* marking it as conflicting, even if an earlier commit along that same line can
* be merged cleanly.
*/
public class MergeOp {
public interface Factory {
MergeOp create(Branch.NameKey branch);
}
private static final Logger log = LoggerFactory.getLogger(MergeOp.class);
private static final String R_HEADS_MASTER =
Constants.R_HEADS + Constants.MASTER;
private static final ApprovalCategory.Id CRVW =
new ApprovalCategory.Id("CRVW");
private static final ApprovalCategory.Id VRIF =
new ApprovalCategory.Id("VRIF");
private static final FooterKey REVIEWED_ON = new FooterKey("Reviewed-on");
private static final FooterKey CHANGE_ID = new FooterKey("Change-Id");
/** Amount of time to wait between submit and checking for missing deps. */
private static final long DEPENDENCY_DELAY =
MILLISECONDS.convert(15, MINUTES);
private final GitRepositoryManager repoManager;
private final SchemaFactory<ReviewDb> schemaFactory;
private final ProjectCache projectCache;
private final FunctionState.Factory functionState;
private final ReplicationQueue replication;
private final MergedSender.Factory mergedSenderFactory;
private final MergeFailSender.Factory mergeFailSenderFactory;
private final Provider<String> urlProvider;
private final ApprovalTypes approvalTypes;
private final PatchSetInfoFactory patchSetInfoFactory;
private final IdentifiedUser.GenericFactory identifiedUserFactory;
private final ChangeControl.GenericFactory changeControlFactory;
private final MergeQueue mergeQueue;
private final PersonIdent myIdent;
private final Branch.NameKey destBranch;
private Project destProject;
private final List<CodeReviewCommit> toMerge;
private List<Change> submitted;
private final Map<Change.Id, CodeReviewCommit> commits;
private ReviewDb db;
private Repository repo;
private RevWalk rw;
private RevFlag CAN_MERGE;
private CodeReviewCommit branchTip;
private CodeReviewCommit mergeTip;
private Set<RevCommit> alreadyAccepted;
private RefUpdate branchUpdate;
private final ChangeHooks hooks;
private final AccountCache accountCache;
private final TagCache tagCache;
private final CreateCodeReviewNotes.Factory codeReviewNotesFactory;
private final SubmoduleOp.Factory subOpFactory;
private final WorkQueue workQueue;
private final RequestScopePropagator requestScopePropagator;
@Inject
MergeOp(final GitRepositoryManager grm, final SchemaFactory<ReviewDb> sf,
final ProjectCache pc, final FunctionState.Factory fs,
final ReplicationQueue rq, final MergedSender.Factory msf,
final MergeFailSender.Factory mfsf,
@CanonicalWebUrl @Nullable final Provider<String> cwu,
final ApprovalTypes approvalTypes, final PatchSetInfoFactory psif,
final IdentifiedUser.GenericFactory iuf,
final ChangeControl.GenericFactory changeControlFactory,
@GerritPersonIdent final PersonIdent myIdent,
final MergeQueue mergeQueue, @Assisted final Branch.NameKey branch,
final ChangeHooks hooks, final AccountCache accountCache,
final TagCache tagCache, final CreateCodeReviewNotes.Factory crnf,
final SubmoduleOp.Factory subOpFactory,
final WorkQueue workQueue,
final RequestScopePropagator requestScopePropagator) {
repoManager = grm;
schemaFactory = sf;
functionState = fs;
projectCache = pc;
replication = rq;
mergedSenderFactory = msf;
mergeFailSenderFactory = mfsf;
urlProvider = cwu;
this.approvalTypes = approvalTypes;
patchSetInfoFactory = psif;
identifiedUserFactory = iuf;
this.changeControlFactory = changeControlFactory;
this.mergeQueue = mergeQueue;
this.hooks = hooks;
this.accountCache = accountCache;
this.tagCache = tagCache;
codeReviewNotesFactory = crnf;
this.subOpFactory = subOpFactory;
this.workQueue = workQueue;
this.requestScopePropagator = requestScopePropagator;
this.myIdent = myIdent;
destBranch = branch;
toMerge = new ArrayList<CodeReviewCommit>();
commits = new HashMap<Change.Id, CodeReviewCommit>();
}
public void verifyMergeability(Change change) {
try {
setDestProject();
openRepository();
final Ref destBranchRef = repo.getRef(destBranch.get());
submitted = new ArrayList<Change>();
submitted.add(change);
// Test mergeability of the change if the last merged sha1
// in the branch is different from the last sha1
// the change was tested against.
if ((destBranchRef == null && change.getLastSha1MergeTested() == null)
|| change.getLastSha1MergeTested() == null
|| (destBranchRef != null && !destBranchRef.getObjectId().getName()
.equals(change.getLastSha1MergeTested().get()))) {
openSchema();
preMerge();
// update sha1 tested merge.
if (destBranchRef != null) {
change.setLastSha1MergeTested(new RevId(destBranchRef
.getObjectId().getName()));
} else {
change.setLastSha1MergeTested(new RevId(""));
}
change.setMergeable(isMergeable(change));
db.changes().update(Collections.singleton(change));
}
} catch (MergeException e) {
log.error("Test merge attempt for change: " + change.getId()
+ " failed", e);
} catch (OrmException e) {
log.error("Test merge attempt for change: " + change.getId()
+ " failed: Not able to query the database", e);
} catch (IOException e) {
log.error("Test merge attempt for change: " + change.getId()
+ " failed", e);
} finally {
if (db != null) {
db.close();
}
db = null;
}
}
private void setDestProject() throws MergeException {
final ProjectState pe = projectCache.get(destBranch.getParentKey());
if (pe == null) {
throw new MergeException("No such project: " + destBranch.getParentKey());
}
destProject = pe.getProject();
}
private void openSchema() throws OrmException {
if (db == null) {
db = schemaFactory.open();
}
}
public void merge() throws MergeException {
setDestProject();
try {
openSchema();
openRepository();
submitted = db.changes().submitted(destBranch).toList();
preMerge();
updateBranch();
updateChangeStatus();
updateSubscriptions();
} catch (OrmException e) {
throw new MergeException("Cannot query the database", e);
} finally {
if (rw != null) {
rw.release();
}
if (repo != null) {
repo.close();
}
db.close();
db = null;
}
}
private void preMerge() throws MergeException, OrmException {
openBranch();
validateChangeList();
mergeTip = branchTip;
switch (destProject.getSubmitType()) {
case CHERRY_PICK:
cherryPickChanges();
break;
case FAST_FORWARD_ONLY:
case MERGE_ALWAYS:
case MERGE_IF_NECESSARY:
default:
reduceToMinimalMerge();
mergeTopics();
markCleanMerges();
break;
}
}
private void openRepository() throws MergeException {
final Project.NameKey name = destBranch.getParentKey();
try {
repo = repoManager.openRepository(name);
} catch (RepositoryNotFoundException notGit) {
final String m = "Repository \"" + name.get() + "\" unknown.";
throw new MergeException(m, notGit);
}
rw = new RevWalk(repo) {
@Override
protected RevCommit createCommit(final AnyObjectId id) {
return new CodeReviewCommit(id);
}
};
rw.sort(RevSort.TOPO);
rw.sort(RevSort.COMMIT_TIME_DESC, true);
CAN_MERGE = rw.newFlag("CAN_MERGE");
}
private void openBranch() throws MergeException {
alreadyAccepted = new HashSet<RevCommit>();
try {
branchUpdate = repo.updateRef(destBranch.get());
if (branchUpdate.getOldObjectId() != null) {
branchTip =
(CodeReviewCommit) rw.parseCommit(branchUpdate.getOldObjectId());
alreadyAccepted.add(branchTip);
} else {
branchTip = null;
}
for (final Ref r : repo.getAllRefs().values()) {
if (r.getName().startsWith(Constants.R_HEADS)
|| r.getName().startsWith(Constants.R_TAGS)) {
try {
alreadyAccepted.add(rw.parseCommit(r.getObjectId()));
} catch (IncorrectObjectTypeException iote) {
// Not a commit? Skip over it.
}
}
}
} catch (IOException e) {
throw new MergeException("Cannot open branch", e);
}
}
private void validateChangeList() throws MergeException {
final Set<ObjectId> tips = new HashSet<ObjectId>();
for (final Ref r : repo.getAllRefs().values()) {
tips.add(r.getObjectId());
}
int commitOrder = 0;
for (final Change chg : submitted) {
final Change.Id changeId = chg.getId();
if (chg.currentPatchSetId() == null) {
commits.put(changeId, CodeReviewCommit
.error(CommitMergeStatus.NO_PATCH_SET));
continue;
}
final PatchSet ps;
try {
ps = db.patchSets().get(chg.currentPatchSetId());
} catch (OrmException e) {
throw new MergeException("Cannot query the database", e);
}
if (ps == null || ps.getRevision() == null
|| ps.getRevision().get() == null) {
commits.put(changeId, CodeReviewCommit
.error(CommitMergeStatus.NO_PATCH_SET));
continue;
}
final String idstr = ps.getRevision().get();
final ObjectId id;
try {
id = ObjectId.fromString(idstr);
} catch (IllegalArgumentException iae) {
commits.put(changeId, CodeReviewCommit
.error(CommitMergeStatus.NO_PATCH_SET));
continue;
}
if (!tips.contains(id)) {
// TODO Technically the proper way to do this test is to use a
// RevWalk on "$id --not --all" and test for an empty set. But
// that is way slower than looking for a ref directly pointing
// at the desired tip. We should always have a ref available.
//
// TODO this is actually an error, the branch is gone but we
// want to merge the issue. We can't safely do that if the
// tip is not reachable.
//
commits.put(changeId, CodeReviewCommit
.error(CommitMergeStatus.REVISION_GONE));
continue;
}
final CodeReviewCommit commit;
try {
commit = (CodeReviewCommit) rw.parseCommit(id);
} catch (IOException e) {
log.error("Invalid commit " + id.name() + " on " + chg.getKey(), e);
commits.put(changeId, CodeReviewCommit
.error(CommitMergeStatus.REVISION_GONE));
continue;
}
commit.change = chg;
commit.patchsetId = ps.getId();
commit.originalOrder = commitOrder++;
commits.put(changeId, commit);
if (branchTip != null) {
// If this commit is already merged its a bug in the queuing code
// that we got back here. Just mark it complete and move on. Its
// merged and that is all that mattered to the requestor.
//
try {
if (rw.isMergedInto(commit, branchTip)) {
commit.statusCode = CommitMergeStatus.ALREADY_MERGED;
continue;
}
} catch (IOException err) {
throw new MergeException("Cannot perform merge base test", err);
}
}
commit.add(CAN_MERGE);
toMerge.add(commit);
}
}
private void reduceToMinimalMerge() throws MergeException {
final Collection<CodeReviewCommit> heads;
try {
heads = new MergeSorter(rw, alreadyAccepted, CAN_MERGE).sort(toMerge);
} catch (IOException e) {
throw new MergeException("Branch head sorting failed", e);
}
toMerge.clear();
toMerge.addAll(heads);
Collections.sort(toMerge, new Comparator<CodeReviewCommit>() {
public int compare(final CodeReviewCommit a, final CodeReviewCommit b) {
return a.originalOrder - b.originalOrder;
}
});
}
private void mergeTopics() throws MergeException {
// Take the first fast-forward available, if any is available in the set.
//
if (destProject.getSubmitType() != Project.SubmitType.MERGE_ALWAYS) {
for (final Iterator<CodeReviewCommit> i = toMerge.iterator(); i.hasNext();) {
try {
final CodeReviewCommit n = i.next();
if (mergeTip == null || rw.isMergedInto(mergeTip, n)) {
mergeTip = n;
i.remove();
break;
}
} catch (IOException e) {
throw new MergeException("Cannot fast-forward test during merge", e);
}
}
}
if (destProject.getSubmitType() == Project.SubmitType.FAST_FORWARD_ONLY) {
// If this project only permits fast-forwards, abort everything else.
//
while (!toMerge.isEmpty()) {
final CodeReviewCommit n = toMerge.remove(0);
n.statusCode = CommitMergeStatus.NOT_FAST_FORWARD;
}
} else {
// For every other commit do a pair-wise merge.
//
while (!toMerge.isEmpty()) {
mergeOneCommit(toMerge.remove(0));
}
}
}
private void mergeOneCommit(final CodeReviewCommit n) throws MergeException {
final ThreeWayMerger m;
if (destProject.isUseContentMerge()) {
// Settings for this project allow us to try and
// automatically resolve conflicts within files if needed.
// Use ResolveMerge and instruct to operate in core.
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);
}
try {
if (m.merge(new AnyObjectId[] {mergeTip, n})) {
writeMergeCommit(m, n);
} else {
failed(n, CommitMergeStatus.PATH_CONFLICT);
}
} catch (IOException e) {
if (e.getMessage().startsWith("Multiple merge bases for")) {
try {
failed(n, CommitMergeStatus.CRISS_CROSS_MERGE);
} catch (IOException e2) {
throw new MergeException("Cannot merge " + n.name(), e);
}
} else {
throw new MergeException("Cannot merge " + n.name(), e);
}
}
}
private CodeReviewCommit failed(final CodeReviewCommit n,
final CommitMergeStatus failure) throws MissingObjectException,
IncorrectObjectTypeException, IOException {
rw.reset();
rw.markStart(n);
rw.markUninteresting(mergeTip);
CodeReviewCommit failed;
while ((failed = (CodeReviewCommit) rw.next()) != null) {
failed.statusCode = failure;
}
return failed;
}
private void writeMergeCommit(final Merger m, final CodeReviewCommit n)
throws IOException, MissingObjectException, IncorrectObjectTypeException {
final List<CodeReviewCommit> merged = new ArrayList<CodeReviewCommit>();
rw.reset();
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 = computeAuthor(merged);
final CommitBuilder mergeCommit = new CommitBuilder();
mergeCommit.setTreeId(m.getResultTreeId());
mergeCommit.setParentIds(mergeTip, n);
mergeCommit.setAuthor(authorIdent);
mergeCommit.setCommitter(myIdent);
mergeCommit.setMessage(msgbuf.toString());
mergeTip = (CodeReviewCommit) rw.parseCommit(commit(m, mergeCommit));
}
private PersonIdent computeAuthor(
final List<CodeReviewCommit> codeReviewCommits) {
PatchSetApproval submitter = null;
for (final CodeReviewCommit c : codeReviewCommits) {
PatchSetApproval s = getSubmitter(db, 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) {
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;
}
private void markCleanMerges() 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;
}
try {
rw.reset();
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 (branchUpdate.getRefLogIdent() == null) {
setRefLogIdent(getSubmitter(db, c.patchsetId));
}
}
}
} catch (IOException e) {
throw new MergeException("Cannot mark clean merges", e);
}
}
private void setRefLogIdent(final PatchSetApproval submitAudit) {
if (submitAudit != null) {
branchUpdate.setRefLogIdent(identifiedUserFactory.create(
submitAudit.getAccountId()).newRefLogIdent());
}
}
private void cherryPickChanges() throws MergeException, OrmException {
while (!toMerge.isEmpty()) {
final CodeReviewCommit n = toMerge.remove(0);
final ThreeWayMerger m;
if (destProject.isUseContentMerge()) {
// Settings for this project allow us to try and
// automatically resolve conflicts within files if needed.
// Use ResolveMerge and instruct to operate in core.
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);
}
try {
if (mergeTip == null) {
// The branch is unborn. Take a fast-forward resolution to
// create the branch.
//
mergeTip = n;
n.statusCode = CommitMergeStatus.CLEAN_MERGE;
} else if (n.getParentCount() == 0) {
// Refuse to merge a root commit into an existing branch,
// we cannot obtain a delta for the cherry-pick to apply.
//
n.statusCode = CommitMergeStatus.CANNOT_CHERRY_PICK_ROOT;
} else if (n.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.
//
m.setBase(n.getParent(0));
if (m.merge(mergeTip, n)) {
writeCherryPickCommit(m, n);
} else {
n.statusCode = CommitMergeStatus.PATH_CONFLICT;
}
} else {
// 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.
//
if (hasDependenciesMet(n)) {
if (rw.isMergedInto(mergeTip, n)) {
mergeTip = n;
} else {
mergeOneCommit(n);
}
markCleanMerges();
} else {
// One or more dependencies were not met. The status was
// already marked on the commit so we have nothing further
// to perform at this time.
//
}
}
} catch (IOException e) {
throw new MergeException("Cannot merge " + n.name(), e);
}
}
}
private boolean hasDependenciesMet(final CodeReviewCommit n)
throws IOException {
// Oddly we can determine this by running the merge sorter and
// look for the one commit to come out as a result. This works
// as the merge sorter checks the dependency chain as part of
// its logic trying to find a minimal merge path.
//
return new MergeSorter(rw, alreadyAccepted, CAN_MERGE).sort(
Collections.singleton(n)).contains(n);
}
private void writeCherryPickCommit(final Merger m, final CodeReviewCommit n)
throws IOException, OrmException {
rw.parseBody(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;
List<PatchSetApproval> approvalList = null;
try {
approvalList =
db.patchSetApprovals().byPatchSet(n.patchsetId).toList();
Collections.sort(approvalList, new Comparator<PatchSetApproval>() {
public int compare(final PatchSetApproval a, final PatchSetApproval b) {
return a.getGranted().compareTo(b.getGranted());
}
});
for (final PatchSetApproval a : approvalList) {
if (a.getValue() <= 0) {
// Negative votes aren't counted.
continue;
}
if (ApprovalCategory.SUBMIT.equals(a.getCategoryId())) {
// 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 (CRVW.equals(a.getCategoryId())) {
tag = "Reviewed-by";
} else if (VRIF.equals(a.getCategoryId())) {
tag = "Tested-by";
} else {
final ApprovalType at =
approvalTypes.byId(a.getCategoryId());
if (at == null) {
// A deprecated/deleted approval type, ignore it.
continue;
}
tag = at.getCategory().getName().replace(' ', '-');
}
if (!contains(footers, new FooterKey(tag), identbuf.toString())) {
msgbuf.append(tag);
msgbuf.append(": ");
msgbuf.append(identbuf);
msgbuf.append('\n');
}
}
} catch (OrmException e) {
log.error("Can't read approval records for " + n.patchsetId, e);
}
final CommitBuilder mergeCommit = new CommitBuilder();
mergeCommit.setTreeId(m.getResultTreeId());
mergeCommit.setParentId(mergeTip);
mergeCommit.setAuthor(n.getAuthorIdent());
mergeCommit.setCommitter(toCommitterIdent(submitAudit));
mergeCommit.setMessage(msgbuf.toString());
final ObjectId id = commit(m, mergeCommit);
final CodeReviewCommit newCommit = (CodeReviewCommit) rw.parseCommit(id);
n.change =
db.changes().atomicUpdate(n.change.getId(),
new AtomicUpdate<Change>() {
@Override
public Change update(Change change) {
change.nextPatchSetId();
return change;
}
});
final PatchSet ps = new PatchSet(n.change.currPatchSetId());
ps.setCreatedOn(new Timestamp(System.currentTimeMillis()));
ps.setUploader(submitAudit.getAccountId());
ps.setRevision(new RevId(id.getName()));
insertAncestors(ps.getId(), newCommit);
db.patchSets().insert(Collections.singleton(ps));
n.change =
db.changes().atomicUpdate(n.change.getId(),
new AtomicUpdate<Change>() {
@Override
public Change update(Change change) {
change.setCurrentPatchSet(patchSetInfoFactory.get(newCommit,
ps.getId()));
return change;
}
});
if (approvalList != null) {
for (PatchSetApproval a : approvalList) {
db.patchSetApprovals().insert(
Collections.singleton(new PatchSetApproval(ps.getId(), a)));
}
}
newCommit.copyFrom(n);
newCommit.statusCode = CommitMergeStatus.CLEAN_PICK;
commits.put(newCommit.patchsetId.getParentKey(), newCommit);
mergeTip = newCommit;
setRefLogIdent(submitAudit);
}
private void insertAncestors(PatchSet.Id id, RevCommit src)
throws OrmException {
final int cnt = src.getParentCount();
List<PatchSetAncestor> toInsert = new ArrayList<PatchSetAncestor>(cnt);
for (int p = 0; p < cnt; p++) {
PatchSetAncestor a;
a = new PatchSetAncestor(new PatchSetAncestor.Id(id, p + 1));
a.setAncestorRevision(new RevId(src.getParent(p).getId().name()));
toInsert.add(a);
}
db.patchSetAncestors().insert(toInsert);
}
private ObjectId commit(final Merger m, final CommitBuilder mergeCommit)
throws IOException, UnsupportedEncodingException {
ObjectInserter oi = m.getObjectInserter();
try {
ObjectId id = oi.insert(mergeCommit);
oi.flush();
return id;
} finally {
oi.release();
}
}
private 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 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;
}
private PersonIdent toCommitterIdent(final PatchSetApproval audit) {
if (audit != null) {
return identifiedUserFactory.create(audit.getAccountId())
.newCommitterIdent(audit.getGranted(), myIdent.getTimeZone());
}
return myIdent;
}
private void updateBranch() throws MergeException {
if (mergeTip != null && (branchTip == null || branchTip != mergeTip)) {
if (GitRepositoryManager.REF_CONFIG.equals(branchUpdate.getName())) {
try {
ProjectConfig cfg = new ProjectConfig(destProject.getNameKey());
cfg.load(repo, mergeTip);
} catch (Exception e) {
throw new MergeException("Submit would store invalid"
+ " project configuration " + mergeTip.name() + " for "
+ destProject.getName(), e);
}
}
branchUpdate.setForceUpdate(false);
branchUpdate.setNewObjectId(mergeTip);
branchUpdate.setRefLogMessage("merged", true);
try {
switch (branchUpdate.update(rw)) {
case NEW:
case FAST_FORWARD:
if (branchUpdate.getResult() == RefUpdate.Result.FAST_FORWARD) {
tagCache.updateFastForward(destBranch.getParentKey(),
branchUpdate.getName(),
branchUpdate.getOldObjectId(),
mergeTip);
}
if (GitRepositoryManager.REF_CONFIG.equals(branchUpdate.getName())) {
projectCache.evict(destProject);
ProjectState ps = projectCache.get(destProject.getNameKey());
repoManager.setProjectDescription(destProject.getNameKey(), //
ps.getProject().getDescription());
}
replication.scheduleUpdate(destBranch.getParentKey(), branchUpdate
.getName());
Account account = null;
final PatchSetApproval submitter = getSubmitter(db, mergeTip.patchsetId);
if (submitter != null) {
account = accountCache.get(submitter.getAccountId()).getAccount();
}
hooks.doRefUpdatedHook(destBranch, branchUpdate, account);
break;
default:
throw new IOException(branchUpdate.getResult().name());
}
} catch (IOException e) {
throw new MergeException("Cannot update " + branchUpdate.getName(), e);
}
}
}
private boolean isMergeable(Change c) {
final CodeReviewCommit commit = commits.get(c.getId());
final CommitMergeStatus s = commit != null ? commit.statusCode : null;
boolean isMergeable = false;
if (s != null
&& (s.equals(CommitMergeStatus.CLEAN_MERGE)
|| s.equals(CommitMergeStatus.CLEAN_PICK) || s
.equals(CommitMergeStatus.ALREADY_MERGED))) {
isMergeable = true;
}
return isMergeable;
}
private void updateChangeStatus() {
List<CodeReviewCommit> merged = new ArrayList<CodeReviewCommit>();
for (final Change c : submitted) {
final CodeReviewCommit commit = commits.get(c.getId());
final CommitMergeStatus s = commit != null ? commit.statusCode : null;
if (s == null) {
// Shouldn't ever happen, but leave the change alone. We'll pick
// it up on the next pass.
//
continue;
}
final String txt = s.getMessage();
switch (s) {
case CLEAN_MERGE: {
setMerged(c, message(c, txt));
merged.add(commit);
break;
}
case CLEAN_PICK: {
setMerged(c, message(c, txt + " as " + commit.name()));
merged.add(commit);
break;
}
case ALREADY_MERGED:
setMerged(c, null);
merged.add(commit);
break;
case PATH_CONFLICT:
case CRISS_CROSS_MERGE:
case CANNOT_CHERRY_PICK_ROOT:
case NOT_FAST_FORWARD: {
setNew(c, message(c, txt));
break;
}
case MISSING_DEPENDENCY: {
final Capable capable = isSubmitStillPossible(commit);
if (capable != Capable.OK) {
sendMergeFail(c, message(c, capable.getMessage()), false);
}
break;
}
default:
setNew(c, message(c, "Unspecified merge failure: " + s.name()));
break;
}
}
CreateCodeReviewNotes codeReviewNotes =
codeReviewNotesFactory.create(db, repo);
try {
codeReviewNotes.create(merged, computeAuthor(merged));
} catch (CodeReviewNoteCreationException e) {
log.error(e.getMessage());
}
replication.scheduleUpdate(destBranch.getParentKey(),
GitRepositoryManager.REFS_NOTES_REVIEW);
}
private void updateSubscriptions() {
if (mergeTip != null && (branchTip == null || branchTip != mergeTip)) {
SubmoduleOp subOp =
subOpFactory.create(destBranch, mergeTip, rw, repo, destProject,
submitted, commits);
try {
subOp.update();
} catch (SubmoduleException e) {
log
.error("The gitLinks were not updated according to the subscriptions "
+ e.getMessage());
}
}
}
private Capable isSubmitStillPossible(final CodeReviewCommit commit) {
final Capable capable;
final Change c = commit.change;
if (commit.missing == null) {
commit.missing = new ArrayList<CodeReviewCommit>();
}
boolean submitStillPossible = commit.missing.size() > 0;
for (CodeReviewCommit missingCommit : commit.missing) {
loadChangeInfo(missingCommit);
if (missingCommit.patchsetId == null) {
// The commit doesn't have a patch set, so it cannot be
// submitted to the branch.
//
submitStillPossible = false;
break;
}
if (!missingCommit.change.currentPatchSetId().equals(
missingCommit.patchsetId)) {
// If the missing commit is not the current patch set,
// the change must be rebased to use the proper parent.
//
submitStillPossible = false;
break;
}
}
final long now = System.currentTimeMillis();
final long waitUntil = c.getLastUpdatedOn().getTime() + DEPENDENCY_DELAY;
if (submitStillPossible && now < waitUntil) {
// If we waited a short while we might still be able to get
// this change submitted. Reschedule an attempt in a bit.
//
mergeQueue.recheckAfter(destBranch, waitUntil - now, MILLISECONDS);
capable = Capable.OK;
} else if (submitStillPossible) {
// It would be possible to submit the change if the missing
// dependencies are also submitted. Perhaps the user just
// forgot to submit those.
//
String txt =
"Change could not be merged because of a missing dependency.";
if (!isAlreadySent(c, txt)) {
StringBuilder m = new StringBuilder();
m.append(txt);
m.append("\n");
m.append("\n");
m.append("The following changes must also be submitted:\n");
m.append("\n");
for (CodeReviewCommit missingCommit : commit.missing) {
m.append("* ");
m.append(missingCommit.change.getKey().get());
m.append("\n");
}
txt = m.toString();
}
capable = new Capable(txt);
} else {
// It is impossible to submit this change as-is. The author
// needs to rebase it in order to work around the missing
// dependencies.
//
StringBuilder m = new StringBuilder();
m.append("Change cannot be merged due"
+ " to unsatisfiable dependencies.\n");
m.append("\n");
m.append("The following dependency errors were found:\n");
m.append("\n");
for (CodeReviewCommit missingCommit : commit.missing) {
if (missingCommit.patchsetId != null) {
m.append("* Depends on patch set ");
m.append(missingCommit.patchsetId.get());
m.append(" of ");
m.append(missingCommit.change.getKey().abbreviate());
m.append(", however the current patch set is ");
m.append(missingCommit.change.currentPatchSetId().get());
m.append(".\n");
} else {
m.append("* Depends on commit ");
m.append(missingCommit.name());
m.append(" which has no change associated with it.\n");
}
}
m.append("\n");
m.append("Please rebase the change and upload a replacement commit.");
capable = new Capable(m.toString());
}
return capable;
}
private void loadChangeInfo(final CodeReviewCommit commit) {
if (commit.patchsetId == null) {
try {
List<PatchSet> matches =
db.patchSets().byRevision(new RevId(commit.name())).toList();
if (matches.size() == 1) {
final PatchSet ps = matches.get(0);
commit.patchsetId = ps.getId();
commit.change = db.changes().get(ps.getId().getParentKey());
}
} catch (OrmException e) {
}
}
}
private boolean isAlreadySent(final Change c, final String prefix) {
try {
final List<ChangeMessage> msgList =
db.changeMessages().byChange(c.getId()).toList();
if (msgList.size() > 0) {
final ChangeMessage last = msgList.get(msgList.size() - 1);
if (last.getAuthor() == null && last.getMessage().startsWith(prefix)) {
// The last message was written by us, and it said this
// same message already. Its unlikely anything has changed
// that would cause us to need to repeat ourselves.
//
return true;
}
}
// The last message was not sent by us, or doesn't match the text
// we are about to send.
//
return false;
} catch (OrmException e) {
return true;
}
}
private ChangeMessage message(final Change c, final String body) {
final String uuid;
try {
uuid = ChangeUtil.messageUUID(db);
} catch (OrmException e) {
return null;
}
final ChangeMessage m =
new ChangeMessage(new ChangeMessage.Key(c.getId(), uuid), null,
c.currentPatchSetId());
m.setMessage(body);
return m;
}
private static PatchSetApproval getSubmitter(ReviewDb reviewDb,
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
&& ApprovalCategory.SUBMIT.equals(a.getCategoryId())) {
if (submitter == null
|| a.getGranted().compareTo(submitter.getGranted()) > 0) {
submitter = a;
}
}
}
} catch (OrmException e) {
}
return submitter;
}
private void setMerged(final Change c, final ChangeMessage msg) {
final Change.Id changeId = c.getId();
// We must pull the patchset out of commits, because the patchset ID is
// modified when using the cherry-pick merge strategy.
final CodeReviewCommit commit = commits.get(c.getId());
final PatchSet.Id merged = commit.change.currentPatchSetId();
try {
db.changes().atomicUpdate(changeId, new AtomicUpdate<Change>() {
@Override
public Change update(Change c) {
c.setStatus(Change.Status.MERGED);
// It could be possible that the change being merged
// has never had its mergeability tested. So we insure
// merged changes has mergeable field true.
c.setMergeable(true);
if (!merged.equals(c.currentPatchSetId())) {
// Uncool; the patch set changed after we merged it.
// Go back to the patch set that was actually merged.
//
try {
c.setCurrentPatchSet(patchSetInfoFactory.get(db, merged));
} catch (PatchSetInfoNotAvailableException e1) {
log.error("Cannot read merged patch set " + merged, e1);
}
}
ChangeUtil.updated(c);
return c;
}
});
} catch (OrmConcurrencyException err) {
} catch (OrmException err) {
log.warn("Cannot update change status", err);
}
// Flatten out all existing approvals based upon the current
// permissions. Once the change is closed the approvals are
// not updated at presentation view time, so we need to make.
// sure they are accurate now. This way if permissions get
// modified in the future, historical records stay accurate.
//
PatchSetApproval submitter = null;
try {
c.setStatus(Change.Status.MERGED);
final List<PatchSetApproval> approvals =
db.patchSetApprovals().byChange(changeId).toList();
final FunctionState fs = functionState.create(
changeControlFactory.controlFor(
c,
identifiedUserFactory.create(c.getOwner())),
merged, approvals);
for (ApprovalType at : approvalTypes.getApprovalTypes()) {
CategoryFunction.forCategory(at.getCategory()).run(at, fs);
}
for (PatchSetApproval a : approvals) {
if (a.getValue() > 0
&& ApprovalCategory.SUBMIT.equals(a.getCategoryId())
&& a.getPatchSetId().equals(merged)) {
if (submitter == null
|| a.getGranted().compareTo(submitter.getGranted()) > 0) {
submitter = a;
}
}
a.cache(c);
}
db.patchSetApprovals().update(approvals);
} catch (NoSuchChangeException err) {
log.warn("Cannot normalize approvals for change " + changeId, err);
} catch (OrmException err) {
log.warn("Cannot normalize approvals for change " + changeId, err);
}
if (msg != null) {
if (submitter != null && msg.getAuthor() == null) {
msg.setAuthor(submitter.getAccountId());
}
try {
db.changeMessages().insert(Collections.singleton(msg));
} catch (OrmException err) {
log.warn("Cannot store message on change", err);
}
}
final PatchSetApproval from = submitter;
workQueue.getDefaultQueue()
.submit(requestScopePropagator.wrap(new Runnable() {
@Override
public void run() {
PatchSet patchSet;
try {
ReviewDb reviewDb = schemaFactory.open();
try {
patchSet = reviewDb.patchSets().get(c.currentPatchSetId());
} finally {
reviewDb.close();
}
} catch (Exception e) {
log.error("Cannot send email for submitted patch set " + c.getId(), e);
return;
}
try {
final MergedSender cm = mergedSenderFactory.create(c);
if (from != null) {
cm.setFrom(from.getAccountId());
}
cm.setPatchSet(patchSet);
cm.send();
} catch (Exception e) {
log.error("Cannot send email for submitted patch set " + c.getId(), e);
}
}
@Override
public String toString() {
return "send-email merged";
}
}));
try {
hooks.doChangeMergedHook(c, //
accountCache.get(submitter.getAccountId()).getAccount(), //
db.patchSets().get(c.currentPatchSetId()), db);
} catch (OrmException ex) {
log.error("Cannot run hook for submitted patch set " + c.getId(), ex);
}
}
private void setNew(Change c, ChangeMessage msg) {
sendMergeFail(c, msg, true);
}
private void sendMergeFail(final Change c, final ChangeMessage msg,
final boolean makeNew) {
try {
db.changeMessages().insert(Collections.singleton(msg));
} catch (OrmException err) {
log.warn("Cannot record merge failure message", err);
}
if (makeNew) {
try {
db.changes().atomicUpdate(c.getId(), new AtomicUpdate<Change>() {
@Override
public Change update(Change c) {
if (c.getStatus().isOpen()) {
c.setStatus(Change.Status.NEW);
ChangeUtil.updated(c);
}
return c;
}
});
} catch (OrmConcurrencyException err) {
} catch (OrmException err) {
log.warn("Cannot update change status", err);
}
} else {
try {
ChangeUtil.touch(c, db);
} catch (OrmException err) {
log.warn("Cannot update change timestamp", err);
}
}
workQueue.getDefaultQueue()
.submit(requestScopePropagator.wrap(new Runnable() {
@Override
public void run() {
PatchSet patchSet;
PatchSetApproval submitter;
try {
ReviewDb reviewDb = schemaFactory.open();
try {
patchSet = reviewDb.patchSets().get(c.currentPatchSetId());
submitter = getSubmitter(reviewDb, c.currentPatchSetId());
} finally {
reviewDb.close();
}
} catch (Exception e) {
log.error("Cannot send email notifications about merge failure", e);
return;
}
try {
final MergeFailSender cm = mergeFailSenderFactory.create(c);
if (submitter != null) {
cm.setFrom(submitter.getAccountId());
}
cm.setPatchSet(patchSet);
cm.setChangeMessage(msg);
cm.send();
} catch (Exception e) {
log.error("Cannot send email notifications about merge failure", e);
}
}
@Override
public String toString() {
return "send-email merge-failed";
}
}));
}
}