| // 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.common.Nullable; | |
| 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; | |
| } | |
| @Nullable | |
| 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()); | |
| } | |
| } |