blob: 37df66b051ab34845904d34837afb77d1c6d4e2d [file] [log] [blame]
// Copyright (C) 2020 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.submit;
import static java.util.Comparator.comparing;
import static java.util.stream.Collectors.toList;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.SubmoduleSubscription;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.VerboseSuperprojectUpdate;
import com.google.gerrit.server.git.CodeReviewCommit;
import com.google.gerrit.server.project.NoSuchProjectException;
import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.jgit.dircache.DirCache;
import org.eclipse.jgit.dircache.DirCacheBuilder;
import org.eclipse.jgit.dircache.DirCacheEditor;
import org.eclipse.jgit.dircache.DirCacheEditor.DeletePath;
import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
import org.eclipse.jgit.dircache.DirCacheEntry;
import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.FileMode;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
/** Create commit or amend existing one updating gitlinks. */
class SubmoduleCommits {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private final PersonIdent myIdent;
private final VerboseSuperprojectUpdate verboseSuperProject;
private final MergeOpRepoManager orm;
private final long maxCombinedCommitMessageSize;
private final long maxCommitMessages;
private final BranchTips branchTips = new BranchTips();
@Singleton
public static class Factory {
private final Provider<PersonIdent> serverIdent;
private final Config cfg;
@Inject
Factory(@GerritPersonIdent Provider<PersonIdent> serverIdent, @GerritServerConfig Config cfg) {
this.serverIdent = serverIdent;
this.cfg = cfg;
}
public SubmoduleCommits create(MergeOpRepoManager orm) {
return new SubmoduleCommits(orm, serverIdent.get(), cfg);
}
}
SubmoduleCommits(MergeOpRepoManager orm, PersonIdent myIdent, Config cfg) {
this.orm = orm;
this.myIdent = myIdent;
this.verboseSuperProject =
cfg.getEnum("submodule", null, "verboseSuperprojectUpdate", VerboseSuperprojectUpdate.TRUE);
this.maxCombinedCommitMessageSize =
cfg.getLong("submodule", "maxCombinedCommitMessageSize", 256 << 10);
this.maxCommitMessages = cfg.getLong("submodule", "maxCommitMessages", 1000);
}
/**
* Use the commit as tip of the branch
*
* <p>This keeps track of the tip of the branch as the submission progresses.
*/
void addBranchTip(BranchNameKey branch, CodeReviewCommit tip) {
branchTips.put(branch, tip);
}
/**
* Create a separate gitlink commit
*
* @param subscriber superproject (and branch)
* @param subscriptions subprojects the superproject is subscribed to
* @return a new commit on top of subscriber with gitlinks update to the tips of the subprojects;
* empty if nothing has changed. Subproject tips are read from the cached branched tips
* (defaulting to the mergeOpRepoManager).
*/
Optional<CodeReviewCommit> composeGitlinksCommit(
BranchNameKey subscriber, Collection<SubmoduleSubscription> subscriptions)
throws IOException, SubmoduleConflictException {
OpenRepo or;
try {
or = orm.getRepo(subscriber.project());
} catch (NoSuchProjectException | IOException e) {
throw new StorageException("Cannot access superproject", e);
}
CodeReviewCommit currentCommit =
branchTips
.getTip(subscriber, or)
.orElseThrow(
() ->
new SubmoduleConflictException(
"The branch was probably deleted from the subscriber repository"));
StringBuilder msgbuf = new StringBuilder();
PersonIdent author = null;
DirCache dc = readTree(or.getCodeReviewRevWalk(), currentCommit);
DirCacheEditor ed = dc.editor();
int count = 0;
for (SubmoduleSubscription s : sortByPath(subscriptions)) {
if (count > 0) {
msgbuf.append("\n\n");
}
RevCommit newCommit = updateSubmodule(dc, ed, msgbuf, s);
count++;
if (newCommit != null) {
PersonIdent newCommitAuthor = newCommit.getAuthorIdent();
if (author == null) {
author = new PersonIdent(newCommitAuthor, myIdent.getWhen());
} else if (!author.getName().equals(newCommitAuthor.getName())
|| !author.getEmailAddress().equals(newCommitAuthor.getEmailAddress())) {
author = myIdent;
}
}
}
ed.finish();
ObjectId newTreeId = dc.writeTree(or.ins);
// Gitlinks are already in the branch, return null
if (newTreeId.equals(currentCommit.getTree())) {
return Optional.empty();
}
CommitBuilder commit = new CommitBuilder();
commit.setTreeId(newTreeId);
commit.setParentId(currentCommit);
StringBuilder commitMsg = new StringBuilder("Update git submodules\n\n");
if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
commitMsg.append(msgbuf);
}
commit.setMessage(commitMsg.toString());
commit.setAuthor(author);
commit.setCommitter(myIdent);
ObjectId id = or.ins.insert(commit);
return Optional.of(or.getCodeReviewRevWalk().parseCommit(id));
}
/** Amend an existing commit with gitlink updates */
CodeReviewCommit amendGitlinksCommit(
BranchNameKey subscriber,
CodeReviewCommit currentCommit,
Collection<SubmoduleSubscription> subscriptions)
throws IOException, SubmoduleConflictException {
OpenRepo or;
try {
or = orm.getRepo(subscriber.project());
} catch (NoSuchProjectException | IOException e) {
throw new StorageException("Cannot access superproject", e);
}
StringBuilder msgbuf = new StringBuilder();
DirCache dc = readTree(or.rw, currentCommit);
DirCacheEditor ed = dc.editor();
for (SubmoduleSubscription s : sortByPath(subscriptions)) {
updateSubmodule(dc, ed, msgbuf, s);
}
ed.finish();
ObjectId newTreeId = dc.writeTree(or.ins);
// Gitlinks are already updated, just return the commit
if (newTreeId.equals(currentCommit.getTree())) {
return currentCommit;
}
or.rw.parseBody(currentCommit);
CommitBuilder commit = new CommitBuilder();
commit.setTreeId(newTreeId);
commit.setParentIds(currentCommit.getParents());
if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
// TODO(czhen): handle cherrypick footer
commit.setMessage(currentCommit.getFullMessage() + "\n\n* submodules:\n" + msgbuf.toString());
} else {
commit.setMessage(currentCommit.getFullMessage());
}
commit.setAuthor(currentCommit.getAuthorIdent());
commit.setCommitter(myIdent);
ObjectId id = or.ins.insert(commit);
CodeReviewCommit newCommit = or.getCodeReviewRevWalk().parseCommit(id);
newCommit.copyFrom(currentCommit);
return newCommit;
}
private RevCommit updateSubmodule(
DirCache dc, DirCacheEditor ed, StringBuilder msgbuf, SubmoduleSubscription s)
throws SubmoduleConflictException, IOException {
logger.atFine().log("Updating gitlink for %s", s);
OpenRepo subOr;
try {
subOr = orm.getRepo(s.getSubmodule().project());
} catch (NoSuchProjectException | IOException e) {
throw new StorageException("Cannot access submodule", e);
}
DirCacheEntry dce = dc.getEntry(s.getPath());
RevCommit oldCommit = null;
if (dce != null) {
if (!dce.getFileMode().equals(FileMode.GITLINK)) {
String errMsg =
"Requested to update gitlink "
+ s.getPath()
+ " in "
+ s.getSubmodule().project().get()
+ " but entry "
+ "doesn't have gitlink file mode.";
throw new SubmoduleConflictException(errMsg);
}
// Parse the current gitlink entry commit in the subproject repo. This is used to add a
// shortlog for this submodule to the commit message in the superproject.
//
// Even if we don't strictly speaking need that commit message, parsing the commit is a sanity
// check that the old gitlink is a commit that actually exists. If not, then there is an
// inconsistency between the superproject and subproject state, and we don't want to risk
// making things worse by updating the gitlink to something else.
try {
oldCommit = subOr.getCodeReviewRevWalk().parseCommit(dce.getObjectId());
} catch (IOException e) {
// Broken gitlink; sanity check failed. Warn and continue so the submit operation can
// proceed, it will just skip this gitlink update.
logger.atSevere().withCause(e).log("Failed to read commit %s", dce.getObjectId().name());
return null;
}
}
Optional<CodeReviewCommit> maybeNewCommit = branchTips.getTip(s.getSubmodule(), subOr);
if (!maybeNewCommit.isPresent()) {
// For whatever reason, this submodule was not updated as part of this submit batch, but the
// superproject is still subscribed to this branch. Re-read the ref to see if anything has
// changed since the last time the gitlink was updated, and roll that update into the same
// commit as all other submodule updates.
ed.add(new DeletePath(s.getPath()));
return null;
}
CodeReviewCommit newCommit = maybeNewCommit.get();
if (Objects.equals(newCommit, oldCommit)) {
// gitlink have already been updated for this submodule
return null;
}
ed.add(
new PathEdit(s.getPath()) {
@Override
public void apply(DirCacheEntry ent) {
ent.setFileMode(FileMode.GITLINK);
ent.setObjectId(newCommit.getId());
}
});
if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
createSubmoduleCommitMsg(msgbuf, s, subOr, newCommit, oldCommit);
}
subOr.getCodeReviewRevWalk().parseBody(newCommit);
return newCommit;
}
private void createSubmoduleCommitMsg(
StringBuilder msgbuf,
SubmoduleSubscription s,
OpenRepo subOr,
RevCommit newCommit,
RevCommit oldCommit) {
msgbuf.append("* Update ");
msgbuf.append(s.getPath());
msgbuf.append(" from branch '");
msgbuf.append(s.getSubmodule().shortName());
msgbuf.append("'");
msgbuf.append("\n to ");
msgbuf.append(newCommit.getName());
// newly created submodule gitlink, do not append whole history
if (oldCommit == null) {
return;
}
try {
subOr.rw.resetRetain(subOr.canMergeFlag);
subOr.rw.markStart(newCommit);
subOr.rw.markUninteresting(oldCommit);
int numMessages = 0;
for (Iterator<RevCommit> iter = subOr.rw.iterator(); iter.hasNext(); ) {
RevCommit c = iter.next();
subOr.rw.parseBody(c);
String message =
verboseSuperProject == VerboseSuperprojectUpdate.SUBJECT_ONLY
? c.getShortMessage()
: StringUtils.replace(c.getFullMessage(), "\n", "\n ");
String bullet = "\n - ";
String ellipsis = "\n\n[...]";
int newSize = msgbuf.length() + bullet.length() + message.length();
if (++numMessages > maxCommitMessages
|| newSize > maxCombinedCommitMessageSize
|| (iter.hasNext() && (newSize + ellipsis.length()) > maxCombinedCommitMessageSize)) {
msgbuf.append(ellipsis);
break;
}
msgbuf.append(bullet);
msgbuf.append(message);
}
} catch (IOException e) {
throw new StorageException(
"Could not perform a revwalk to create superproject commit message", e);
}
}
private static DirCache readTree(RevWalk rw, ObjectId base) throws IOException {
final DirCache dc = DirCache.newInCore();
final DirCacheBuilder b = dc.builder();
b.addTree(
new byte[0], // no prefix path
DirCacheEntry.STAGE_0, // standard stage
rw.getObjectReader(),
rw.parseTree(base));
b.finish();
return dc;
}
private static List<SubmoduleSubscription> sortByPath(
Collection<SubmoduleSubscription> subscriptions) {
return subscriptions.stream()
.sorted(comparing(SubmoduleSubscription::getPath))
.collect(toList());
}
}