blob: d1bda5cb6de248f95cba3a7dd1d6cc53b8744ca1 [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.patch.filediff;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.common.collect.Multimap;
import com.google.common.collect.Streams;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Patch;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
import com.google.gerrit.server.cache.CacheModule;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.TraceContext;
import com.google.gerrit.server.logging.TraceContext.TraceTimer;
import com.google.gerrit.server.patch.AutoMerger;
import com.google.gerrit.server.patch.ComparisonType;
import com.google.gerrit.server.patch.DiffNotAvailableException;
import com.google.gerrit.server.patch.DiffUtil;
import com.google.gerrit.server.patch.Text;
import com.google.gerrit.server.patch.filediff.EditTransformer.ContextAwareEdit;
import com.google.gerrit.server.patch.gitfilediff.FileHeaderUtil;
import com.google.gerrit.server.patch.gitfilediff.GitFileDiff;
import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl;
import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithmFactory;
import com.google.inject.Inject;
import com.google.inject.Module;
import com.google.inject.Singleton;
import com.google.inject.name.Named;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
import org.eclipse.jgit.diff.EditList;
import org.eclipse.jgit.diff.RawText;
import org.eclipse.jgit.diff.RawTextComparator;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.patch.FileHeader;
import org.eclipse.jgit.patch.FileHeader.PatchType;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevTree;
import org.eclipse.jgit.revwalk.RevWalk;
/**
* Cache for the single file diff between two commits for a single file path. This cache adds extra
* Gerrit logic such as identifying edits due to rebase.
*
* <p>If the {@link FileDiffCacheKey#oldCommit()} is equal to {@link ObjectId#zeroId()}, the git
* diff will be evaluated against the empty tree.
*/
@Singleton
public class FileDiffCacheImpl implements FileDiffCache {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private static final String DIFF = "gerrit_file_diff";
private final LoadingCache<FileDiffCacheKey, FileDiffOutput> cache;
public static Module module() {
return new CacheModule() {
@Override
protected void configure() {
bind(FileDiffCache.class).to(FileDiffCacheImpl.class);
factory(AllDiffsEvaluator.Factory.class);
persist(DIFF, FileDiffCacheKey.class, FileDiffOutput.class)
.maximumWeight(10 << 20)
.weigher(FileDiffWeigher.class)
.version(9)
.keySerializer(FileDiffCacheKey.Serializer.INSTANCE)
.valueSerializer(FileDiffOutput.Serializer.INSTANCE)
.loader(FileDiffLoader.class);
}
};
}
private enum MagicPath {
COMMIT,
MERGE_LIST
}
@Inject
public FileDiffCacheImpl(@Named(DIFF) LoadingCache<FileDiffCacheKey, FileDiffOutput> cache) {
this.cache = cache;
}
@Override
public FileDiffOutput get(FileDiffCacheKey key) throws DiffNotAvailableException {
try {
return cache.get(key);
} catch (ExecutionException e) {
throw new DiffNotAvailableException(e);
}
}
@Override
public ImmutableMap<FileDiffCacheKey, FileDiffOutput> getAll(Iterable<FileDiffCacheKey> keys)
throws DiffNotAvailableException {
try {
ImmutableMap<FileDiffCacheKey, FileDiffOutput> result = cache.getAll(keys);
if (result.size() != Iterables.size(keys)) {
throw new DiffNotAvailableException(
String.format(
"Failed to load the value for all %d keys. Returned "
+ "map contains only %d values",
Iterables.size(keys), result.size()));
}
return result;
} catch (ExecutionException e) {
throw new DiffNotAvailableException(e);
}
}
static class FileDiffLoader extends CacheLoader<FileDiffCacheKey, FileDiffOutput> {
private final GitRepositoryManager repoManager;
private final AllDiffsEvaluator.Factory allDiffsEvaluatorFactory;
@Inject
FileDiffLoader(
AllDiffsEvaluator.Factory allDiffsEvaluatorFactory, GitRepositoryManager manager) {
this.allDiffsEvaluatorFactory = allDiffsEvaluatorFactory;
this.repoManager = manager;
}
@Override
public FileDiffOutput load(FileDiffCacheKey key) throws IOException, DiffNotAvailableException {
try (TraceTimer timer =
TraceContext.newTimer(
"Loading a single key from file diff cache",
Metadata.builder().filePath(key.newFilePath()).build())) {
return loadAll(ImmutableList.of(key)).get(key);
}
}
@Override
public Map<FileDiffCacheKey, FileDiffOutput> loadAll(Iterable<? extends FileDiffCacheKey> keys)
throws DiffNotAvailableException {
try (TraceTimer timer = TraceContext.newTimer("Loading multiple keys from file diff cache")) {
ImmutableMap.Builder<FileDiffCacheKey, FileDiffOutput> result = ImmutableMap.builder();
Map<Project.NameKey, List<FileDiffCacheKey>> keysByProject =
Streams.stream(keys)
.distinct()
.collect(Collectors.groupingBy(FileDiffCacheKey::project));
for (Project.NameKey project : keysByProject.keySet()) {
List<FileDiffCacheKey> fileKeys = new ArrayList<>();
try (Repository repo = repoManager.openRepository(project);
ObjectReader reader = repo.newObjectReader();
RevWalk rw = new RevWalk(reader)) {
for (FileDiffCacheKey key : keysByProject.get(project)) {
if (key.newFilePath().equals(Patch.COMMIT_MSG)) {
result.put(key, createMagicPathEntry(key, reader, rw, MagicPath.COMMIT));
} else if (key.newFilePath().equals(Patch.MERGE_LIST)) {
result.put(key, createMagicPathEntry(key, reader, rw, MagicPath.MERGE_LIST));
} else {
fileKeys.add(key);
}
}
result.putAll(createFileEntries(reader, fileKeys, rw));
} catch (IOException e) {
logger.atWarning().log("Failed to open the repository %s: %s", project, e.getMessage());
}
}
return result.build();
}
}
private ComparisonType getComparisonType(
RevWalk rw, ObjectReader reader, ObjectId oldCommitId, ObjectId newCommitId)
throws IOException {
if (oldCommitId.equals(ObjectId.zeroId())) {
return ComparisonType.againstRoot();
}
RevCommit oldCommit = DiffUtil.getRevCommit(rw, oldCommitId);
RevCommit newCommit = DiffUtil.getRevCommit(rw, newCommitId);
for (int i = 0; i < newCommit.getParentCount(); i++) {
if (newCommit.getParent(i).equals(oldCommit)) {
return ComparisonType.againstParent(i + 1);
}
}
// TODO(ghareeb): it's not trivial to distinguish if diff with old commit is against another
// patchset or auto-merge. Looking at the commit message of old commit gives a strong
// signal that we are diffing against auto-merge, though not 100% accurate (e.g. if old commit
// has the auto-merge prefix in the commit message). A better resolution would be to move the
// COMMIT_MSG and MERGE_LIST evaluations outside of the diff cache. For more details, see
// discussion in
// https://gerrit-review.googlesource.com/c/gerrit/+/280519/6..18/java/com/google/gerrit/server/patch/FileDiffCache.java#b540
String oldCommitMsgTxt = new String(Text.forCommit(reader, oldCommit).getContent(), UTF_8);
if (oldCommitMsgTxt.contains(AutoMerger.AUTO_MERGE_MSG_PREFIX)) {
return ComparisonType.againstAutoMerge();
}
return ComparisonType.againstOtherPatchSet();
}
/**
* Creates a {@link FileDiffOutput} entry for the "Commit message" or "Merge list" magic paths.
*/
private FileDiffOutput createMagicPathEntry(
FileDiffCacheKey key, ObjectReader reader, RevWalk rw, MagicPath magicPath) {
try {
RawTextComparator cmp = comparatorFor(key.whitespace());
ComparisonType comparisonType =
getComparisonType(rw, reader, key.oldCommit(), key.newCommit());
RevCommit aCommit =
key.oldCommit().equals(ObjectId.zeroId())
? null
: DiffUtil.getRevCommit(rw, key.oldCommit());
RevCommit bCommit = DiffUtil.getRevCommit(rw, key.newCommit());
return magicPath == MagicPath.COMMIT
? createCommitEntry(reader, aCommit, bCommit, comparisonType, cmp, key.diffAlgorithm())
: createMergeListEntry(
reader, aCommit, bCommit, comparisonType, cmp, key.diffAlgorithm());
} catch (IOException e) {
logger.atWarning().log("Failed to compute commit entry for key %s", key);
}
return FileDiffOutput.empty(key.newFilePath(), key.oldCommit(), key.newCommit());
}
private static RawTextComparator comparatorFor(Whitespace ws) {
switch (ws) {
case IGNORE_ALL:
return RawTextComparator.WS_IGNORE_ALL;
case IGNORE_TRAILING:
return RawTextComparator.WS_IGNORE_TRAILING;
case IGNORE_LEADING_AND_TRAILING:
return RawTextComparator.WS_IGNORE_CHANGE;
case IGNORE_NONE:
default:
return RawTextComparator.DEFAULT;
}
}
/**
* Creates a commit entry. {@code oldCommit} is null if the comparison is against a root commit.
*/
private FileDiffOutput createCommitEntry(
ObjectReader reader,
@Nullable RevCommit oldCommit,
RevCommit newCommit,
ComparisonType comparisonType,
RawTextComparator rawTextComparator,
GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm)
throws IOException {
Text aText =
oldCommit == null || comparisonType.isAgainstParentOrAutoMerge()
? Text.EMPTY
: Text.forCommit(reader, oldCommit);
Text bText = Text.forCommit(reader, newCommit);
return createMagicFileDiffOutput(
oldCommit,
newCommit,
comparisonType,
rawTextComparator,
aText,
bText,
Patch.COMMIT_MSG,
diffAlgorithm);
}
/**
* Creates a merge list entry. {@code oldCommit} is null if the comparison is against a root
* commit.
*/
private FileDiffOutput createMergeListEntry(
ObjectReader reader,
@Nullable RevCommit oldCommit,
RevCommit newCommit,
ComparisonType comparisonType,
RawTextComparator rawTextComparator,
GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm)
throws IOException {
Text aText =
oldCommit == null || comparisonType.isAgainstParentOrAutoMerge()
? Text.EMPTY
: Text.forMergeList(comparisonType, reader, oldCommit);
Text bText = Text.forMergeList(comparisonType, reader, newCommit);
return createMagicFileDiffOutput(
oldCommit,
newCommit,
comparisonType,
rawTextComparator,
aText,
bText,
Patch.MERGE_LIST,
diffAlgorithm);
}
private static FileDiffOutput createMagicFileDiffOutput(
@Nullable ObjectId oldCommit,
ObjectId newCommit,
ComparisonType comparisonType,
RawTextComparator rawTextComparator,
Text aText,
Text bText,
String fileName,
GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm) {
byte[] rawHdr = getRawHeader(!comparisonType.isAgainstParentOrAutoMerge(), fileName);
byte[] aContent = aText.getContent();
byte[] bContent = bText.getContent();
long size = bContent.length;
long sizeDelta = size - aContent.length;
RawText aRawText = new RawText(aContent);
RawText bRawText = new RawText(bContent);
EditList edits =
DiffAlgorithmFactory.create(diffAlgorithm).diff(rawTextComparator, aRawText, bRawText);
FileHeader fileHeader = new FileHeader(rawHdr, edits, PatchType.UNIFIED);
Patch.ChangeType changeType = FileHeaderUtil.getChangeType(fileHeader);
return FileDiffOutput.builder()
.oldCommitId(oldCommit == null ? ObjectId.zeroId() : oldCommit)
.newCommitId(newCommit)
.comparisonType(comparisonType)
.oldPath(FileHeaderUtil.getOldPath(fileHeader))
.newPath(FileHeaderUtil.getNewPath(fileHeader))
.changeType(changeType)
.patchType(Optional.of(FileHeaderUtil.getPatchType(fileHeader)))
.headerLines(FileHeaderUtil.getHeaderLines(fileHeader))
.edits(
asTaggedEdits(
edits.stream().map(Edit::fromJGitEdit).collect(Collectors.toList()),
ImmutableList.of()))
.size(size)
.sizeDelta(sizeDelta)
.build();
}
private static byte[] getRawHeader(boolean hasA, String fileName) {
StringBuilder hdr = new StringBuilder();
hdr.append("diff --git");
if (hasA) {
hdr.append(" a/").append(fileName);
} else {
hdr.append(" ").append(FileHeader.DEV_NULL);
}
hdr.append(" b/").append(fileName);
hdr.append("\n");
if (hasA) {
hdr.append("--- a/").append(fileName).append("\n");
} else {
hdr.append("--- ").append(FileHeader.DEV_NULL).append("\n");
}
hdr.append("+++ b/").append(fileName).append("\n");
return hdr.toString().getBytes(UTF_8);
}
private Map<FileDiffCacheKey, FileDiffOutput> createFileEntries(
ObjectReader reader, List<FileDiffCacheKey> keys, RevWalk rw)
throws DiffNotAvailableException, IOException {
Map<AugmentedFileDiffCacheKey, AllFileGitDiffs> allFileDiffs =
allDiffsEvaluatorFactory.create(rw).execute(wrapKeys(keys, rw));
Map<FileDiffCacheKey, FileDiffOutput> result = new HashMap<>();
for (AugmentedFileDiffCacheKey augmentedKey : allFileDiffs.keySet()) {
AllFileGitDiffs allDiffs = allFileDiffs.get(augmentedKey);
GitFileDiff mainGitDiff = allDiffs.mainDiff().gitDiff();
if (mainGitDiff.isNegative()) {
// If the result of the git diff computation was negative, i.e. due to timeout, cache a
// negative result.
result.put(
augmentedKey.key(),
FileDiffOutput.createNegative(
mainGitDiff.newPath().orElse(""),
augmentedKey.key().oldCommit(),
augmentedKey.key().newCommit()));
continue;
}
FileEdits rebaseFileEdits = FileEdits.empty();
if (!augmentedKey.ignoreRebase()) {
rebaseFileEdits = computeRebaseEdits(allDiffs);
}
List<Edit> rebaseEdits = rebaseFileEdits.edits();
ObjectId oldTreeId = allDiffs.mainDiff().gitKey().oldTree();
RevTree aTree = oldTreeId.equals(ObjectId.zeroId()) ? null : rw.parseTree(oldTreeId);
RevTree bTree = rw.parseTree(allDiffs.mainDiff().gitKey().newTree());
Long oldSize =
aTree != null && mainGitDiff.oldMode().isPresent() && mainGitDiff.oldPath().isPresent()
? new FileSizeEvaluator(reader, aTree)
.compute(
mainGitDiff.oldId(),
mainGitDiff.oldMode().get(),
mainGitDiff.oldPath().get())
: 0;
Long newSize =
mainGitDiff.newMode().isPresent() && mainGitDiff.newPath().isPresent()
? new FileSizeEvaluator(reader, bTree)
.compute(
mainGitDiff.newId(),
mainGitDiff.newMode().get(),
mainGitDiff.newPath().get())
: 0;
ObjectId oldCommit = augmentedKey.key().oldCommit();
ObjectId newCommit = augmentedKey.key().newCommit();
FileDiffOutput fileDiff =
FileDiffOutput.builder()
.oldCommitId(oldCommit)
.newCommitId(newCommit)
.comparisonType(getComparisonType(rw, reader, oldCommit, newCommit))
.changeType(mainGitDiff.changeType())
.patchType(mainGitDiff.patchType())
.oldPath(mainGitDiff.oldPath())
.newPath(mainGitDiff.newPath())
.oldMode(mainGitDiff.oldMode())
.newMode(mainGitDiff.newMode())
.headerLines(FileHeaderUtil.getHeaderLines(mainGitDiff.fileHeader()))
.edits(asTaggedEdits(mainGitDiff.edits(), rebaseEdits))
.size(newSize)
.sizeDelta(newSize - oldSize)
.build();
result.put(augmentedKey.key(), fileDiff);
}
return result;
}
/**
* Convert the list of input keys {@link FileDiffCacheKey} to a list of {@link
* AugmentedFileDiffCacheKey} that also include the old and new parent commit IDs, and a boolean
* that indicates whether we should include the rebase edits for each key.
*
* <p>The output list is expected to have the same size of the input list, i.e. we map all keys.
*/
private List<AugmentedFileDiffCacheKey> wrapKeys(List<FileDiffCacheKey> keys, RevWalk rw) {
List<AugmentedFileDiffCacheKey> result = new ArrayList<>();
for (FileDiffCacheKey key : keys) {
if (key.oldCommit().equals(ObjectId.zeroId())) {
result.add(AugmentedFileDiffCacheKey.builder().key(key).ignoreRebase(true).build());
continue;
}
try {
RevCommit oldRevCommit = DiffUtil.getRevCommit(rw, key.oldCommit());
RevCommit newRevCommit = DiffUtil.getRevCommit(rw, key.newCommit());
if (!DiffUtil.areRelated(oldRevCommit, newRevCommit)) {
result.add(
AugmentedFileDiffCacheKey.builder()
.key(key)
.oldParentId(Optional.of(oldRevCommit.getParent(0).getId()))
.newParentId(Optional.of(newRevCommit.getParent(0).getId()))
.ignoreRebase(false)
.build());
} else {
result.add(AugmentedFileDiffCacheKey.builder().key(key).ignoreRebase(true).build());
}
} catch (IOException e) {
logger.atWarning().log(
"Failed to evaluate commits relation for key "
+ key
+ ". Skipping this key: "
+ e.getMessage(),
e);
result.add(AugmentedFileDiffCacheKey.builder().key(key).ignoreRebase(true).build());
}
}
return result;
}
private static ImmutableList<TaggedEdit> asTaggedEdits(
List<Edit> normalEdits, List<Edit> rebaseEdits) {
Set<Edit> rebaseEditsSet = new HashSet<>(rebaseEdits);
ImmutableList.Builder<TaggedEdit> result =
ImmutableList.builderWithExpectedSize(normalEdits.size());
for (Edit e : normalEdits) {
result.add(TaggedEdit.create(e, rebaseEditsSet.contains(e)));
}
return result.build();
}
/**
* Computes the subset of edits that are due to rebase between 2 commits.
*
* <p>The input parameter {@link AllFileGitDiffs#mainDiff} contains all the edits in
* consideration. Of those, we identify the edits due to rebase as a function of:
*
* <ol>
* <li>The edits between the old commit and its parent {@link
* AllFileGitDiffs#oldVsParentDiff}.
* <li>The edits between the new commit and its parent {@link
* AllFileGitDiffs#newVsParentDiff}.
* <li>The edits between the parents of the old commit and new commits {@link
* AllFileGitDiffs#parentVsParentDiff}.
* </ol>
*
* @param diffs an entity containing 4 sets of edits: those between the old and new commit,
* between the old and new commits vs. their parents, and between the old and new parents.
* @return the list of edits that are due to rebase.
*/
private FileEdits computeRebaseEdits(AllFileGitDiffs diffs) {
if (!diffs.parentVsParentDiff().isPresent()) {
return FileEdits.empty();
}
GitFileDiff parentVsParentDiff = diffs.parentVsParentDiff().get().gitDiff();
EditTransformer editTransformer =
new EditTransformer(
ImmutableList.of(
FileEdits.create(
parentVsParentDiff.edits().stream().collect(toImmutableList()),
parentVsParentDiff.oldPath(),
parentVsParentDiff.newPath())));
if (diffs.oldVsParentDiff().isPresent()) {
GitFileDiff oldVsParDiff = diffs.oldVsParentDiff().get().gitDiff();
editTransformer.transformReferencesOfSideA(
ImmutableList.of(
FileEdits.create(
oldVsParDiff.edits().stream().collect(toImmutableList()),
oldVsParDiff.oldPath(),
oldVsParDiff.newPath())));
}
if (diffs.newVsParentDiff().isPresent()) {
GitFileDiff newVsParDiff = diffs.newVsParentDiff().get().gitDiff();
editTransformer.transformReferencesOfSideB(
ImmutableList.of(
FileEdits.create(
newVsParDiff.edits().stream().collect(toImmutableList()),
newVsParDiff.oldPath(),
newVsParDiff.newPath())));
}
Multimap<String, ContextAwareEdit> editsPerFilePath = editTransformer.getEditsPerFilePath();
if (editsPerFilePath.isEmpty()) {
return FileEdits.empty();
}
// editsPerFilePath is expected to have a single item representing the file
String filePath = editsPerFilePath.keys().iterator().next();
Collection<ContextAwareEdit> edits = editsPerFilePath.get(filePath);
return FileEdits.create(
edits.stream()
.map(ContextAwareEdit::toEdit)
.filter(Optional::isPresent)
.map(Optional::get)
.map(Edit::fromJGitEdit)
.collect(toImmutableList()),
edits.iterator().next().getOldFilePath(),
edits.iterator().next().getNewFilePath());
}
}
}