| // 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; | |
| import static com.google.gerrit.entities.Patch.COMMIT_MSG; | |
| import static com.google.gerrit.entities.Patch.MERGE_LIST; | |
| import com.google.auto.value.AutoValue; | |
| import com.google.common.collect.ImmutableCollection; | |
| import com.google.common.collect.ImmutableList; | |
| import com.google.common.collect.ImmutableMap; | |
| import com.google.common.flogger.FluentLogger; | |
| import com.google.gerrit.common.Nullable; | |
| import com.google.gerrit.entities.Patch; | |
| import com.google.gerrit.entities.Patch.ChangeType; | |
| import com.google.gerrit.entities.Project; | |
| import com.google.gerrit.extensions.client.DiffPreferencesInfo; | |
| import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace; | |
| import com.google.gerrit.server.cache.CacheModule; | |
| import com.google.gerrit.server.patch.diff.ModifiedFilesCache; | |
| import com.google.gerrit.server.patch.diff.ModifiedFilesCacheImpl; | |
| import com.google.gerrit.server.patch.diff.ModifiedFilesCacheKey; | |
| import com.google.gerrit.server.patch.filediff.FileDiffCache; | |
| import com.google.gerrit.server.patch.filediff.FileDiffCacheImpl; | |
| import com.google.gerrit.server.patch.filediff.FileDiffCacheKey; | |
| import com.google.gerrit.server.patch.filediff.FileDiffOutput; | |
| import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl; | |
| import com.google.gerrit.server.patch.gitdiff.ModifiedFile; | |
| import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl; | |
| import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm; | |
| import com.google.inject.Inject; | |
| import com.google.inject.Module; | |
| import com.google.inject.Singleton; | |
| import java.io.IOException; | |
| import java.util.ArrayList; | |
| import java.util.List; | |
| import java.util.Map; | |
| import java.util.Optional; | |
| import java.util.function.Function; | |
| import java.util.stream.Collectors; | |
| import org.eclipse.jgit.diff.DiffEntry; | |
| import org.eclipse.jgit.diff.DiffFormatter; | |
| import org.eclipse.jgit.lib.Config; | |
| import org.eclipse.jgit.lib.ObjectId; | |
| import org.eclipse.jgit.lib.ObjectReader; | |
| import org.eclipse.jgit.revwalk.RevCommit; | |
| import org.eclipse.jgit.revwalk.RevWalk; | |
| import org.eclipse.jgit.util.io.DisabledOutputStream; | |
| /** | |
| * Provides different file diff operations. Uses the underlying Git/Gerrit caches to speed up the | |
| * diff computation. | |
| */ | |
| @Singleton | |
| public class DiffOperationsImpl implements DiffOperations { | |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); | |
| private static final ImmutableMap<DiffEntry.ChangeType, Patch.ChangeType> changeTypeMap = | |
| ImmutableMap.of( | |
| DiffEntry.ChangeType.ADD, | |
| Patch.ChangeType.ADDED, | |
| DiffEntry.ChangeType.MODIFY, | |
| Patch.ChangeType.MODIFIED, | |
| DiffEntry.ChangeType.DELETE, | |
| Patch.ChangeType.DELETED, | |
| DiffEntry.ChangeType.RENAME, | |
| Patch.ChangeType.RENAMED, | |
| DiffEntry.ChangeType.COPY, | |
| Patch.ChangeType.COPIED); | |
| private static final int RENAME_SCORE = 60; | |
| private static final DiffAlgorithm DEFAULT_DIFF_ALGORITHM = | |
| DiffAlgorithm.HISTOGRAM_WITH_FALLBACK_MYERS; | |
| private static final Whitespace DEFAULT_WHITESPACE = Whitespace.IGNORE_NONE; | |
| private final ModifiedFilesCache modifiedFilesCache; | |
| private final FileDiffCache fileDiffCache; | |
| private final BaseCommitUtil baseCommitUtil; | |
| public static Module module() { | |
| return new CacheModule() { | |
| @Override | |
| protected void configure() { | |
| bind(DiffOperations.class).to(DiffOperationsImpl.class); | |
| install(GitModifiedFilesCacheImpl.module()); | |
| install(ModifiedFilesCacheImpl.module()); | |
| install(GitFileDiffCacheImpl.module()); | |
| install(FileDiffCacheImpl.module()); | |
| } | |
| }; | |
| } | |
| @Inject | |
| public DiffOperationsImpl( | |
| ModifiedFilesCache modifiedFilesCache, | |
| FileDiffCache fileDiffCache, | |
| BaseCommitUtil baseCommit) { | |
| this.modifiedFilesCache = modifiedFilesCache; | |
| this.fileDiffCache = fileDiffCache; | |
| this.baseCommitUtil = baseCommit; | |
| } | |
| @Override | |
| public Map<String, FileDiffOutput> listModifiedFilesAgainstParent( | |
| Project.NameKey project, ObjectId newCommit, int parent, DiffOptions diffOptions) | |
| throws DiffNotAvailableException { | |
| try { | |
| DiffParameters diffParams = computeDiffParameters(project, newCommit, parent); | |
| return getModifiedFiles(diffParams, diffOptions); | |
| } catch (IOException e) { | |
| throw new DiffNotAvailableException( | |
| "Failed to evaluate the parent/base commit for commit " + newCommit, e); | |
| } | |
| } | |
| @Override | |
| public Map<String, ModifiedFile> loadModifiedFilesAgainstParent( | |
| Project.NameKey project, | |
| ObjectId newCommit, | |
| int parentNum, | |
| DiffOptions diffOptions, | |
| RevWalk revWalk, | |
| Config repoConfig) | |
| throws DiffNotAvailableException { | |
| try { | |
| DiffParameters diffParams = computeDiffParameters(project, newCommit, parentNum); | |
| return loadModifiedFilesWithoutCache(project, diffParams, revWalk, repoConfig); | |
| } catch (IOException e) { | |
| throw new DiffNotAvailableException( | |
| String.format( | |
| "Failed to evaluate the parent/base commit for commit '%s' with parentNum=%d", | |
| newCommit, parentNum), | |
| e); | |
| } | |
| } | |
| @Override | |
| public Map<String, FileDiffOutput> listModifiedFiles( | |
| Project.NameKey project, ObjectId oldCommit, ObjectId newCommit, DiffOptions diffOptions) | |
| throws DiffNotAvailableException { | |
| DiffParameters params = | |
| DiffParameters.builder() | |
| .project(project) | |
| .newCommit(newCommit) | |
| .baseCommit(oldCommit) | |
| .comparisonType(ComparisonType.againstOtherPatchSet()) | |
| .build(); | |
| return getModifiedFiles(params, diffOptions); | |
| } | |
| @Override | |
| public Map<String, ModifiedFile> loadModifiedFiles( | |
| Project.NameKey project, | |
| ObjectId oldCommit, | |
| ObjectId newCommit, | |
| DiffOptions diffOptions, | |
| RevWalk revWalk, | |
| Config repoConfig) | |
| throws DiffNotAvailableException { | |
| DiffParameters params = | |
| DiffParameters.builder() | |
| .project(project) | |
| .newCommit(newCommit) | |
| .baseCommit(oldCommit) | |
| .comparisonType(ComparisonType.againstOtherPatchSet()) | |
| .build(); | |
| return loadModifiedFilesWithoutCache(project, params, revWalk, repoConfig); | |
| } | |
| @Override | |
| public FileDiffOutput getModifiedFileAgainstParent( | |
| Project.NameKey project, | |
| ObjectId newCommit, | |
| int parent, | |
| String fileName, | |
| @Nullable DiffPreferencesInfo.Whitespace whitespace) | |
| throws DiffNotAvailableException { | |
| try { | |
| DiffParameters diffParams = computeDiffParameters(project, newCommit, parent); | |
| FileDiffCacheKey key = | |
| createFileDiffCacheKey( | |
| project, | |
| diffParams.baseCommit(), | |
| newCommit, | |
| fileName, | |
| DEFAULT_DIFF_ALGORITHM, | |
| /* useTimeout= */ true, | |
| whitespace); | |
| return getModifiedFileForKey(key); | |
| } catch (IOException e) { | |
| throw new DiffNotAvailableException( | |
| "Failed to evaluate the parent/base commit for commit " + newCommit, e); | |
| } | |
| } | |
| @Override | |
| public FileDiffOutput getModifiedFile( | |
| Project.NameKey project, | |
| ObjectId oldCommit, | |
| ObjectId newCommit, | |
| String fileName, | |
| @Nullable DiffPreferencesInfo.Whitespace whitespace) | |
| throws DiffNotAvailableException { | |
| FileDiffCacheKey key = | |
| createFileDiffCacheKey( | |
| project, | |
| oldCommit, | |
| newCommit, | |
| fileName, | |
| DEFAULT_DIFF_ALGORITHM, | |
| /* useTimeout= */ true, | |
| whitespace); | |
| return getModifiedFileForKey(key); | |
| } | |
| private ImmutableMap<String, FileDiffOutput> getModifiedFiles( | |
| DiffParameters diffParams, DiffOptions diffOptions) throws DiffNotAvailableException { | |
| try { | |
| Project.NameKey project = diffParams.project(); | |
| ObjectId newCommit = diffParams.newCommit(); | |
| ObjectId oldCommit = diffParams.baseCommit(); | |
| ComparisonType cmp = diffParams.comparisonType(); | |
| ImmutableList<ModifiedFile> modifiedFiles = | |
| modifiedFilesCache.get(createModifiedFilesKey(project, oldCommit, newCommit)); | |
| List<FileDiffCacheKey> fileCacheKeys = new ArrayList<>(); | |
| fileCacheKeys.add( | |
| createFileDiffCacheKey( | |
| project, | |
| oldCommit, | |
| newCommit, | |
| COMMIT_MSG, | |
| DEFAULT_DIFF_ALGORITHM, | |
| /* useTimeout= */ true, | |
| /* whitespace= */ null)); | |
| if (cmp.isAgainstAutoMerge() || isMergeAgainstParent(cmp, project, newCommit)) { | |
| fileCacheKeys.add( | |
| createFileDiffCacheKey( | |
| project, | |
| oldCommit, | |
| newCommit, | |
| MERGE_LIST, | |
| DEFAULT_DIFF_ALGORITHM, | |
| /* useTimeout= */ true, | |
| /*whitespace = */ null)); | |
| } | |
| if (diffParams.skipFiles() == null) { | |
| modifiedFiles.stream() | |
| .map( | |
| entity -> | |
| createFileDiffCacheKey( | |
| project, | |
| oldCommit, | |
| newCommit, | |
| entity.newPath().isPresent() | |
| ? entity.newPath().get() | |
| : entity.oldPath().get(), | |
| DEFAULT_DIFF_ALGORITHM, | |
| /* useTimeout= */ true, | |
| /* whitespace= */ null)) | |
| .forEach(fileCacheKeys::add); | |
| } | |
| return getModifiedFilesForKeys(fileCacheKeys, diffOptions); | |
| } catch (IOException e) { | |
| throw new DiffNotAvailableException(e); | |
| } | |
| } | |
| private FileDiffOutput getModifiedFileForKey(FileDiffCacheKey key) | |
| throws DiffNotAvailableException { | |
| Map<String, FileDiffOutput> diffList = | |
| getModifiedFilesForKeys(ImmutableList.of(key), DiffOptions.DEFAULTS); | |
| return diffList.containsKey(key.newFilePath()) | |
| ? diffList.get(key.newFilePath()) | |
| : FileDiffOutput.empty(key.newFilePath(), key.oldCommit(), key.newCommit()); | |
| } | |
| /** | |
| * Lookup the file diffs for the input {@code keys}. For results where the cache reports negative | |
| * results, e.g. due to timeouts in the cache loader, this method requests the diff again using | |
| * the fallback algorithm {@link DiffAlgorithm#HISTOGRAM_NO_FALLBACK}. | |
| */ | |
| private ImmutableMap<String, FileDiffOutput> getModifiedFilesForKeys( | |
| List<FileDiffCacheKey> keys, DiffOptions diffOptions) throws DiffNotAvailableException { | |
| ImmutableMap<FileDiffCacheKey, FileDiffOutput> fileDiffs = fileDiffCache.getAll(keys); | |
| List<FileDiffCacheKey> fallbackKeys = new ArrayList<>(); | |
| ImmutableList.Builder<FileDiffOutput> result = ImmutableList.builder(); | |
| // Use the fallback diff algorithm for negative results | |
| for (FileDiffCacheKey key : fileDiffs.keySet()) { | |
| FileDiffOutput diff = fileDiffs.get(key); | |
| if (diff.isNegative()) { | |
| FileDiffCacheKey fallbackKey = | |
| createFileDiffCacheKey( | |
| key.project(), | |
| key.oldCommit(), | |
| key.newCommit(), | |
| key.newFilePath(), | |
| // Use the fallback diff algorithm | |
| DiffAlgorithm.HISTOGRAM_NO_FALLBACK, | |
| // We don't enforce timeouts with the fallback algorithm. Timeouts were introduced | |
| // because of a bug in JGit that happens only when the histogram algorithm uses | |
| // Myers as fallback. See https://issues.gerritcodereview.com/issues/40000618 | |
| /* useTimeout= */ false, | |
| key.whitespace()); | |
| fallbackKeys.add(fallbackKey); | |
| } else { | |
| result.add(diff); | |
| } | |
| } | |
| result.addAll(fileDiffCache.getAll(fallbackKeys).values()); | |
| return mapByFilePath(result.build(), diffOptions); | |
| } | |
| /** | |
| * Map a collection of {@link FileDiffOutput} based on their file paths. The result map keys | |
| * represent the old file path for deleted files, or the new path otherwise. | |
| */ | |
| private ImmutableMap<String, FileDiffOutput> mapByFilePath( | |
| ImmutableCollection<FileDiffOutput> fileDiffOutputs, DiffOptions diffOptions) { | |
| ImmutableMap.Builder<String, FileDiffOutput> diffs = ImmutableMap.builder(); | |
| for (FileDiffOutput fileDiffOutput : fileDiffOutputs) { | |
| if (fileDiffOutput.isEmpty() | |
| || (diffOptions.skipFilesWithAllEditsDueToRebase() && allDueToRebase(fileDiffOutput))) { | |
| continue; | |
| } | |
| if (fileDiffOutput.changeType() == ChangeType.DELETED) { | |
| diffs.put(fileDiffOutput.oldPath().get(), fileDiffOutput); | |
| } else { | |
| diffs.put(fileDiffOutput.newPath().get(), fileDiffOutput); | |
| } | |
| } | |
| return diffs.build(); | |
| } | |
| private static boolean allDueToRebase(FileDiffOutput fileDiffOutput) { | |
| return fileDiffOutput.allEditsDueToRebase() | |
| && !(fileDiffOutput.changeType() == ChangeType.RENAMED | |
| || fileDiffOutput.changeType() == ChangeType.COPIED); | |
| } | |
| private boolean isMergeAgainstParent(ComparisonType cmp, Project.NameKey project, ObjectId commit) | |
| throws IOException { | |
| return (cmp.isAgainstParent() && baseCommitUtil.getNumParents(project, commit) > 1); | |
| } | |
| private static ModifiedFilesCacheKey createModifiedFilesKey( | |
| Project.NameKey project, ObjectId aCommit, ObjectId bCommit) { | |
| return ModifiedFilesCacheKey.builder() | |
| .project(project) | |
| .aCommit(aCommit) | |
| .bCommit(bCommit) | |
| .renameScore(RENAME_SCORE) | |
| .build(); | |
| } | |
| private static FileDiffCacheKey createFileDiffCacheKey( | |
| Project.NameKey project, | |
| ObjectId aCommit, | |
| ObjectId bCommit, | |
| String newPath, | |
| DiffAlgorithm diffAlgorithm, | |
| boolean useTimeout, | |
| @Nullable Whitespace whitespace) { | |
| whitespace = whitespace == null ? DEFAULT_WHITESPACE : whitespace; | |
| return FileDiffCacheKey.builder() | |
| .project(project) | |
| .oldCommit(aCommit) | |
| .newCommit(bCommit) | |
| .newFilePath(newPath) | |
| .renameScore(RENAME_SCORE) | |
| .diffAlgorithm(diffAlgorithm) | |
| .whitespace(whitespace) | |
| .useTimeout(useTimeout) | |
| .build(); | |
| } | |
| /** Loads the modified file paths between two commits without inspecting the diff cache. */ | |
| private static Map<String, ModifiedFile> loadModifiedFilesWithoutCache( | |
| Project.NameKey project, DiffParameters diffParams, RevWalk revWalk, Config repoConfig) | |
| throws DiffNotAvailableException { | |
| ObjectId newCommit = diffParams.newCommit(); | |
| ObjectId oldCommit = diffParams.baseCommit(); | |
| try { | |
| ObjectReader reader = revWalk.getObjectReader(); | |
| List<DiffEntry> diffEntries; | |
| try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) { | |
| df.setReader(reader, repoConfig); | |
| df.setDetectRenames(false); | |
| diffEntries = df.scan(oldCommit.equals(ObjectId.zeroId()) ? null : oldCommit, newCommit); | |
| } | |
| List<ModifiedFile> modifiedFiles = | |
| diffEntries.stream() | |
| .map( | |
| entry -> | |
| ModifiedFile.builder() | |
| .changeType(toChangeType(entry.getChangeType())) | |
| .oldPath(getGitPath(entry.getOldPath())) | |
| .newPath(getGitPath(entry.getNewPath())) | |
| .build()) | |
| .collect(Collectors.toList()); | |
| return DiffUtil.mergeRewrittenModifiedFiles(modifiedFiles).stream() | |
| .collect(ImmutableMap.toImmutableMap(ModifiedFile::getDefaultPath, Function.identity())); | |
| } catch (IOException e) { | |
| throw new DiffNotAvailableException( | |
| String.format( | |
| "Failed to compute the modified files for project '%s'," | |
| + " old commit '%s', new commit '%s'.", | |
| project, oldCommit.name(), newCommit.name()), | |
| e); | |
| } | |
| } | |
| private static Optional<String> getGitPath(String path) { | |
| return path.equals(DiffEntry.DEV_NULL) ? Optional.empty() : Optional.of(path); | |
| } | |
| private static Patch.ChangeType toChangeType(DiffEntry.ChangeType changeType) { | |
| if (!changeTypeMap.containsKey(changeType)) { | |
| throw new IllegalArgumentException("Unsupported type " + changeType); | |
| } | |
| return changeTypeMap.get(changeType); | |
| } | |
| @AutoValue | |
| abstract static class DiffParameters { | |
| abstract Project.NameKey project(); | |
| abstract ObjectId newCommit(); | |
| /** | |
| * Base commit represents the old commit of the diff. For diffs against the root commit, this | |
| * should be set to {@link ObjectId#zeroId()}. | |
| */ | |
| abstract ObjectId baseCommit(); | |
| abstract ComparisonType comparisonType(); | |
| @Nullable | |
| abstract Integer parent(); | |
| /** Compute the diff for {@value Patch#COMMIT_MSG} and {@link Patch#MERGE_LIST} only. */ | |
| @Nullable | |
| abstract Boolean skipFiles(); | |
| static Builder builder() { | |
| return new AutoValue_DiffOperationsImpl_DiffParameters.Builder(); | |
| } | |
| @AutoValue.Builder | |
| abstract static class Builder { | |
| abstract Builder project(Project.NameKey project); | |
| abstract Builder newCommit(ObjectId newCommit); | |
| abstract Builder baseCommit(ObjectId baseCommit); | |
| abstract Builder parent(@Nullable Integer parent); | |
| abstract Builder skipFiles(@Nullable Boolean skipFiles); | |
| abstract Builder comparisonType(ComparisonType comparisonType); | |
| public abstract DiffParameters build(); | |
| } | |
| } | |
| /** Compute Diff parameters - the base commit and the comparison type - using the input args. */ | |
| private DiffParameters computeDiffParameters( | |
| Project.NameKey project, ObjectId newCommit, Integer parent) throws IOException { | |
| DiffParameters.Builder result = | |
| DiffParameters.builder().project(project).newCommit(newCommit).parent(parent); | |
| if (parent > 0) { | |
| RevCommit baseCommit = baseCommitUtil.getBaseCommit(project, newCommit, parent); | |
| if (baseCommit == null) { | |
| // The specified parent doesn't exist or is not supported, fall back to comparing against | |
| // the root. | |
| result.baseCommit(ObjectId.zeroId()); | |
| result.comparisonType(ComparisonType.againstRoot()); | |
| return result.build(); | |
| } | |
| result.baseCommit(baseCommit); | |
| result.comparisonType(ComparisonType.againstParent(parent)); | |
| return result.build(); | |
| } | |
| int numParents = baseCommitUtil.getNumParents(project, newCommit); | |
| if (numParents == 0) { | |
| result.baseCommit(ObjectId.zeroId()); | |
| result.comparisonType(ComparisonType.againstRoot()); | |
| return result.build(); | |
| } | |
| if (numParents == 1) { | |
| result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent)); | |
| result.comparisonType(ComparisonType.againstParent(1)); | |
| return result.build(); | |
| } | |
| if (numParents > 2) { | |
| logger.atFine().log( | |
| "Diff against auto-merge for merge commits " | |
| + "with more than two parents is not supported. Commit %s has %d parents." | |
| + " Falling back to the diff against the first parent.", | |
| newCommit, numParents); | |
| result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, 1).getId()); | |
| result.comparisonType(ComparisonType.againstParent(1)); | |
| result.skipFiles(true); | |
| } else { | |
| result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, null)); | |
| result.comparisonType(ComparisonType.againstAutoMerge()); | |
| } | |
| return result.build(); | |
| } | |
| } |