Add the GitModifiedFilesCache and ModifiedFilesCache

The two new caches are part of the refactoring done to the Diff cache,
previously implemented in PatchListLoader.java.

The 2 new caches compute the set of modified files (added, deleted,
modified, etc...) between 2 specific commits.

1) The GitModifiedFilesCache computes the modified files between two
commits according to a Git (tree) diff. File contents are not available
with the cache values. The cache key uses the SHA-1s of the trees
instead of the SHA-1s of the commits. This allows the re-use of
computations when commits keep their trees but change in other ways
(e.g. for different commit messages).

2) The ModifiedFilesCache is a wrapper on top of the first cache and
adds extra Gerrit logic. This cache filters out the files that were not
touched between the 2 commits, previously implemented in
PatchListLoader. Similar to the first cache, the file contents and edits
are not available with the cache values.

For now, the magic file paths "/COMMIT_MSG", and "/MERGE_LIST" are not
returned from this cache. In a follow up change, the single file diff
caches will be added and the caller can query the magic file paths from
them. I excluded them from here because:
  * Checking for the cases when we should compute the merge list (e.g.
  determining if the diff is against base / patchsets) would require
  adding some more logic.
  * For commit messages between patchsets, to determine if there's a
  change or not, we would need to parse the actual commit message text.

In the next follow up change, I will implement the protobuf
serialization for the cache entities as marked with TODOs in this
change.

Change-Id: I0c6fc4e015009624c4f93471a2e6335d180f6c82
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 605b493..512e0cb 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -49,6 +49,7 @@
 * commons:compress
 * commons:dbcp
 * commons:lang
+* commons:lang3
 * commons:net
 * commons:pool
 * commons:validator
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 069006b..b7a00dd 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -113,6 +113,7 @@
         "//lib/commons:compress",
         "//lib/commons:dbcp",
         "//lib/commons:lang",
+        "//lib/commons:lang3",
         "//lib/commons:net",
         "//lib/commons:validator",
         "//lib/errorprone:annotations",
diff --git a/java/com/google/gerrit/server/patch/DiffNotAvailableException.java b/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
new file mode 100644
index 0000000..34e1577
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
@@ -0,0 +1,28 @@
+//  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 com.google.gerrit.server.patch.diff.ModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+
+/**
+ * Thrown by the diff caches - the {@link GitModifiedFilesCache} and the {@link ModifiedFilesCache},
+ * if the implementations failed to retrieve the modified files between the 2 commits.
+ */
+public class DiffNotAvailableException extends Exception {
+  public DiffNotAvailableException(Throwable cause) {
+    super(cause);
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/DiffUtil.java b/java/com/google/gerrit/server/patch/DiffUtil.java
new file mode 100644
index 0000000..9198666
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffUtil.java
@@ -0,0 +1,79 @@
+// 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 com.google.gerrit.server.patch.diff.ModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * A utility class used by the diff cache interfaces {@link GitModifiedFilesCache} and {@link
+ * ModifiedFilesCache}.
+ */
+public class DiffUtil {
+
+  /**
+   * Returns the Git tree object ID pointed to by the commitId parameter.
+   *
+   * @param rw a {@link RevWalk} of an opened repository that is used to walk the commit graph.
+   * @param commitId 20 bytes commitId SHA-1 hash.
+   * @return Git tree object ID pointed to by the commitId.
+   */
+  public static ObjectId getTreeId(RevWalk rw, ObjectId commitId) throws IOException {
+    RevCommit current = rw.parseCommit(commitId);
+    return current.getTree().getId();
+  }
+
+  /**
+   * Returns the RevCommit object given the 20 bytes commitId SHA-1 hash.
+   *
+   * @param rw a {@link RevWalk} of an opened repository that is used to walk the commit graph.
+   * @param commitId 20 bytes commitId SHA-1 hash
+   * @return The RevCommit representing the commit in Git
+   * @throws IOException a pack file or loose object could not be read while parsing the commits.
+   */
+  public static RevCommit getRevCommit(RevWalk rw, ObjectId commitId) throws IOException {
+    return rw.parseCommit(commitId);
+  }
+
+  /**
+   * Returns true if the commitA and commitB parameters are parent/child, if they have a common
+   * parent, or if any of them is a root or merge commit.
+   */
+  public static boolean areRelated(RevCommit commitA, RevCommit commitB) {
+    return commitA == null
+        || isRootOrMergeCommit(commitA)
+        || isRootOrMergeCommit(commitB)
+        || areParentAndChild(commitA, commitB)
+        || haveCommonParent(commitA, commitB);
+  }
+
+  private static boolean isRootOrMergeCommit(RevCommit commit) {
+    return commit.getParentCount() != 1;
+  }
+
+  private static boolean areParentAndChild(RevCommit commitA, RevCommit commitB) {
+    return ObjectId.isEqual(commitA.getParent(0), commitB)
+        || ObjectId.isEqual(commitB.getParent(0), commitA);
+  }
+
+  private static boolean haveCommonParent(RevCommit commitA, RevCommit commitB) {
+    return ObjectId.isEqual(commitA.getParent(0), commitB.getParent(0));
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
new file mode 100644
index 0000000..9fc8150
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
@@ -0,0 +1,44 @@
+//  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.diff;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+
+/**
+ * A cache for the list of Git modified files between 2 commits (patchsets) with extra Gerrit logic.
+ *
+ * <p>The loader uses the underlying {@link GitModifiedFilesCacheImpl} to retrieve the git modified
+ * files.
+ *
+ * <p>If the {@link ModifiedFilesCacheImpl.Key#aCommit()} is equal to {@link
+ * org.eclipse.jgit.lib.Constants#EMPTY_TREE_ID}, the diff will be evaluated against the empty tree,
+ * and the result will be exactly the same as the caller can get from {@link
+ * GitModifiedFilesCache#get(GitModifiedFilesCacheImpl.Key)}
+ */
+public interface ModifiedFilesCache {
+
+  /**
+   * @param key used to identify two git commits and contains other attributes to control the diff
+   *     calculation.
+   * @return the list of {@link ModifiedFile}s between the 2 git commits identified by the key.
+   * @throws DiffNotAvailableException the supplied commits IDs of the key do no exist, are not IDs
+   *     of a commit, or an exception occurred while reading a pack file.
+   */
+  ImmutableList<ModifiedFile> get(ModifiedFilesCacheImpl.Key key) throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
new file mode 100644
index 0000000..1e6b0f8
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
@@ -0,0 +1,264 @@
+//  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.diff;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffUtil;
+import com.google.gerrit.server.patch.diff.ModifiedFilesCacheImpl.Key.Serializer;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.io.Serializable;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * A cache for the list of Git modified files between 2 commits (patchsets) with extra Gerrit logic.
+ *
+ * <p>The loader of this cache wraps a {@link GitModifiedFilesCache} to retrieve the git modified
+ * files.
+ *
+ * <p>If the {@link Key#aCommit()} is equal to {@link org.eclipse.jgit.lib.Constants#EMPTY_TREE_ID},
+ * the diff will be evaluated against the empty tree, and the result will be exactly the same as the
+ * caller can get from {@link GitModifiedFilesCache#get(GitModifiedFilesCacheImpl.Key)}
+ */
+public class ModifiedFilesCacheImpl implements ModifiedFilesCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final String MODIFIED_FILES = "modified_files";
+
+  private final LoadingCache<Key, ImmutableList<ModifiedFile>> cache;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(ModifiedFilesCache.class).to(ModifiedFilesCacheImpl.class);
+
+        // The documentation has some defaults and recommendations for setting the cache
+        // attributes:
+        // https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#cache.
+        // The cache is using the default disk limit as per section cache.<name>.diskLimit
+        // in the cache documentation link.
+        persist(
+                ModifiedFilesCacheImpl.MODIFIED_FILES,
+                Key.class,
+                new TypeLiteral<ImmutableList<ModifiedFile>>() {})
+            .keySerializer(Serializer.INSTANCE)
+            .valueSerializer(GitModifiedFilesCacheImpl.ValueSerializer.INSTANCE)
+            .maximumWeight(10 << 20)
+            .weigher(ModifiedFilesWeigher.class)
+            .version(1)
+            .loader(ModifiedFilesLoader.class);
+      }
+    };
+  }
+
+  @Inject
+  public ModifiedFilesCacheImpl(
+      @Named(ModifiedFilesCacheImpl.MODIFIED_FILES)
+          LoadingCache<Key, ImmutableList<ModifiedFile>> cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public ImmutableList<ModifiedFile> get(Key key) throws DiffNotAvailableException {
+    try {
+      return cache.get(key);
+    } catch (Exception e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  static class ModifiedFilesLoader extends CacheLoader<Key, ImmutableList<ModifiedFile>> {
+    private final GitModifiedFilesCache gitCache;
+    private final GitRepositoryManager repoManager;
+
+    @Inject
+    ModifiedFilesLoader(GitModifiedFilesCache gitCache, GitRepositoryManager repoManager) {
+      this.gitCache = gitCache;
+      this.repoManager = repoManager;
+    }
+
+    @Override
+    public ImmutableList<ModifiedFile> load(Key key) throws IOException, DiffNotAvailableException {
+      try (Repository repo = repoManager.openRepository(key.project());
+          RevWalk rw = new RevWalk(repo.newObjectReader())) {
+        return loadModifiedFiles(key, rw);
+      }
+    }
+
+    private ImmutableList<ModifiedFile> loadModifiedFiles(Key key, RevWalk rw)
+        throws IOException, DiffNotAvailableException {
+      ObjectId aTree =
+          key.aCommit().equals(EMPTY_TREE_ID)
+              ? key.aCommit()
+              : DiffUtil.getTreeId(rw, key.aCommit());
+      ObjectId bTree = DiffUtil.getTreeId(rw, key.bCommit());
+      GitModifiedFilesCacheImpl.Key gitKey =
+          GitModifiedFilesCacheImpl.Key.builder()
+              .project(key.project())
+              .aTree(aTree)
+              .bTree(bTree)
+              .renameScore(key.renameScore())
+              .build();
+      List<ModifiedFile> modifiedFiles = gitCache.get(gitKey);
+      if (key.aCommit().equals(EMPTY_TREE_ID)) {
+        return ImmutableList.copyOf(modifiedFiles);
+      }
+      RevCommit revCommitA = DiffUtil.getRevCommit(rw, key.aCommit());
+      RevCommit revCommitB = DiffUtil.getRevCommit(rw, key.bCommit());
+      if (DiffUtil.areRelated(revCommitA, revCommitB)) {
+        return ImmutableList.copyOf(modifiedFiles);
+      }
+      Set<String> touchedFiles =
+          getTouchedFilesWithParents(
+              key, revCommitA.getParent(0).getId(), revCommitB.getParent(0).getId(), rw);
+      return modifiedFiles.stream()
+          .filter(f -> isTouched(touchedFiles, f))
+          .collect(toImmutableList());
+    }
+
+    /**
+     * Returns the paths of files that were modified between the old and new commits versus their
+     * parents (i.e. old commit vs. its parent, and new commit vs. its parent).
+     *
+     * @param key the {@link Key} representing the commits we are diffing
+     * @param rw a {@link RevWalk} for the repository
+     * @return The list of modified files between the old/new commits and their parents
+     */
+    private Set<String> getTouchedFilesWithParents(
+        Key key, ObjectId parentOfA, ObjectId parentOfB, RevWalk rw) throws IOException {
+      try {
+        // TODO(ghareeb): as an enhancement: the 3 calls of the underlying git cache can be combined
+        GitModifiedFilesCacheImpl.Key oldVsBaseKey =
+            GitModifiedFilesCacheImpl.Key.create(
+                key.project(), parentOfA, key.aCommit(), key.renameScore(), rw);
+        List<ModifiedFile> oldVsBase = gitCache.get(oldVsBaseKey);
+
+        GitModifiedFilesCacheImpl.Key newVsBaseKey =
+            GitModifiedFilesCacheImpl.Key.create(
+                key.project(), parentOfB, key.bCommit(), key.renameScore(), rw);
+        List<ModifiedFile> newVsBase = gitCache.get(newVsBaseKey);
+
+        return Sets.union(getOldAndNewPaths(oldVsBase), getOldAndNewPaths(newVsBase));
+      } catch (DiffNotAvailableException e) {
+        logger.atWarning().log(
+            "Failed to retrieve the touched files' commits (%s, %s) and parents (%s, %s): %s",
+            key.aCommit(), key.bCommit(), parentOfA, parentOfB, e.getMessage());
+        return ImmutableSet.of();
+      }
+    }
+
+    private ImmutableSet<String> getOldAndNewPaths(List<ModifiedFile> files) {
+      return files.stream()
+          .flatMap(
+              file -> Stream.concat(Streams.stream(file.oldPath()), Streams.stream(file.newPath())))
+          .collect(ImmutableSet.toImmutableSet());
+    }
+
+    private static boolean isTouched(Set<String> touchedFilePaths, ModifiedFile modifiedFile) {
+      String oldFilePath = modifiedFile.oldPath().orElse(null);
+      String newFilePath = modifiedFile.newPath().orElse(null);
+      // One of the above file paths could be /dev/null but we need not explicitly check for this
+      // value as the set of file paths shouldn't contain it.
+      return touchedFilePaths.contains(oldFilePath) || touchedFilePaths.contains(newFilePath);
+    }
+  }
+
+  @AutoValue
+  public abstract static class Key implements Serializable {
+    public abstract Project.NameKey project();
+
+    /** @return the old commit ID used in the git tree diff */
+    public abstract ObjectId aCommit();
+
+    /** @return the new commit ID used in the git tree diff */
+    public abstract ObjectId bCommit();
+
+    public abstract boolean renameDetectionFlag();
+
+    /**
+     * Percentage score used to identify a file as a "rename". A special value of -1 means that the
+     * computation will ignore renames and rename detection will be disabled.
+     */
+    public abstract int renameScore();
+
+    public int weight() {
+      return project().get().length() + 20 * 2 + 4;
+    }
+
+    public static Builder builder() {
+      return new AutoValue_ModifiedFilesCacheImpl_Key.Builder();
+    }
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+
+      public abstract Builder project(NameKey value);
+
+      public abstract Builder aCommit(ObjectId value);
+
+      public abstract Builder bCommit(ObjectId value);
+
+      public abstract Builder renameDetectionFlag(boolean value);
+
+      public abstract Builder renameScore(int value);
+
+      public abstract Key build();
+    }
+
+    enum Serializer implements CacheSerializer<Key> {
+      INSTANCE;
+
+      @Override
+      public byte[] serialize(Key object) {
+        // TODO(ghareeb): implement protobuf serialization
+        return new byte[0];
+      }
+
+      @Override
+      public Key deserialize(byte[] in) {
+        return null;
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java
new file mode 100644
index 0000000..0b2c69e
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java
@@ -0,0 +1,31 @@
+//  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.diff;
+
+import com.google.common.cache.Weigher;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+
+public class ModifiedFilesWeigher
+    implements Weigher<ModifiedFilesCacheImpl.Key, ImmutableList<ModifiedFile>> {
+  @Override
+  public int weigh(ModifiedFilesCacheImpl.Key key, ImmutableList<ModifiedFile> modifiedFiles) {
+    int weight = key.weight();
+    for (ModifiedFile modifiedFile : modifiedFiles) {
+      weight += modifiedFile.weight();
+    }
+    return weight;
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java
new file mode 100644
index 0000000..36af9fe
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java
@@ -0,0 +1,41 @@
+//  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.gitdiff;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
+
+/**
+ * A cache interface for identifying the list of Git modified files between 2 different git trees.
+ * This cache does not read the actual file contents, nor does it include the edits (modified
+ * regions) of the file.
+ *
+ * <p>The other {@link ModifiedFilesCache} is similar to this cache, and includes other extra Gerrit
+ * logic that we need to add with the list of modified files.
+ */
+public interface GitModifiedFilesCache {
+
+  /**
+   * Computes the list of of {@link ModifiedFile}s between the 2 git trees.
+   *
+   * @param key used to identify two git trees and contains other attributes to control the diff
+   *     calculation.
+   * @return the list of {@link ModifiedFile}s between the 2 git trees identified by the key.
+   * @throws DiffNotAvailableException trees cannot be read or file contents cannot be read.
+   */
+  ImmutableList<ModifiedFile> get(GitModifiedFilesCacheImpl.Key key)
+      throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
new file mode 100644
index 0000000..bbdf814
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
@@ -0,0 +1,258 @@
+//  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.gitdiff;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+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.gerrit.entities.Patch;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffUtil;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffEntry.ChangeType;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
+
+/** Implementation of the {@link GitModifiedFilesCache} */
+public class GitModifiedFilesCacheImpl implements GitModifiedFilesCache {
+  private static final String GIT_MODIFIED_FILES = "git_modified_files";
+  private static final ImmutableMap<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 LoadingCache<Key, ImmutableList<ModifiedFile>> cache;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(GitModifiedFilesCache.class).to(GitModifiedFilesCacheImpl.class);
+
+        persist(GIT_MODIFIED_FILES, Key.class, new TypeLiteral<ImmutableList<ModifiedFile>>() {})
+            .keySerializer(Key.KeySerializer.INSTANCE)
+            .valueSerializer(ValueSerializer.INSTANCE)
+            // The documentation has some defaults and recommendations for setting the cache
+            // attributes:
+            // https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#cache.
+            .maximumWeight(10 << 20)
+            .weigher(GitModifiedFilesWeigher.class)
+            // The cache is using the default disk limit as per section cache.<name>.diskLimit
+            // in the cache documentation link.
+            .version(1)
+            .loader(GitModifiedFilesCacheImpl.Loader.class);
+      }
+    };
+  }
+
+  @Inject
+  public GitModifiedFilesCacheImpl(
+      @Named(GIT_MODIFIED_FILES) LoadingCache<Key, ImmutableList<ModifiedFile>> cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public ImmutableList<ModifiedFile> get(Key key) throws DiffNotAvailableException {
+    try {
+      return cache.get(key);
+    } catch (ExecutionException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  static class Loader extends CacheLoader<Key, ImmutableList<ModifiedFile>> {
+    private final GitRepositoryManager repoManager;
+
+    @Inject
+    Loader(GitRepositoryManager repoManager) {
+      this.repoManager = repoManager;
+    }
+
+    @Override
+    public ImmutableList<ModifiedFile> load(Key key) throws IOException {
+      try (Repository repo = repoManager.openRepository(key.project());
+          ObjectReader reader = repo.newObjectReader()) {
+        List<DiffEntry> entries = getGitTreeDiff(repo, reader, key);
+
+        return entries.stream().map(Loader::toModifiedFile).collect(toImmutableList());
+      }
+    }
+
+    private List<DiffEntry> getGitTreeDiff(
+        Repository repo, ObjectReader reader, GitModifiedFilesCacheImpl.Key key)
+        throws IOException {
+      try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
+        df.setReader(reader, repo.getConfig());
+        if (key.renameDetection()) {
+          df.setDetectRenames(true);
+          df.getRenameDetector().setRenameScore(key.renameScore());
+        }
+        // The scan method only returns the file paths that are different. Callers may choose to
+        // format these paths themselves.
+        return df.scan(key.aTree(), key.bTree());
+      }
+    }
+
+    private static ModifiedFile toModifiedFile(DiffEntry entry) {
+      String oldPath = entry.getOldPath();
+      String newPath = entry.getNewPath();
+      return ModifiedFile.builder()
+          .changeType(toChangeType(entry.getChangeType()))
+          .oldPath(oldPath.equals(DiffEntry.DEV_NULL) ? Optional.empty() : Optional.of(oldPath))
+          .newPath(newPath.equals(DiffEntry.DEV_NULL) ? Optional.empty() : Optional.of(newPath))
+          .build();
+    }
+
+    private static Patch.ChangeType toChangeType(DiffEntry.ChangeType changeType) {
+      if (!changeTypeMap.containsKey(changeType)) {
+        throw new IllegalArgumentException("Unsupported type " + changeType);
+      }
+      return changeTypeMap.get(changeType);
+    }
+  }
+
+  /**
+   * In this cache, we evaluate the diffs between two git trees (instead of git commits), hence the
+   * key contains the tree IDs.
+   */
+  @AutoValue
+  public abstract static class Key {
+
+    public abstract Project.NameKey project();
+
+    /**
+     * The git SHA-1 {@link ObjectId} of the first git tree object for which the diff should be
+     * computed.
+     */
+    public abstract ObjectId aTree();
+
+    /**
+     * The git SHA-1 {@link ObjectId} of the second git tree object for which the diff should be
+     * computed.
+     */
+    public abstract ObjectId bTree();
+
+    /**
+     * Percentage score used to identify a file as a rename. This value is only available if {@link
+     * #renameDetection()} is true. Otherwise, this method will return -1.
+     *
+     * <p>This value will be used to set the rename score of {@link
+     * DiffFormatter#getRenameDetector()}.
+     */
+    public abstract int renameScore();
+
+    /** Returns true if rename detection was set for this key. */
+    public boolean renameDetection() {
+      return renameScore() != -1;
+    }
+
+    public static Key create(
+        Project.NameKey project, ObjectId aCommit, ObjectId bCommit, int renameScore, RevWalk rw)
+        throws IOException {
+      ObjectId aTree = DiffUtil.getTreeId(rw, aCommit);
+      ObjectId bTree = DiffUtil.getTreeId(rw, bCommit);
+      return builder().project(project).aTree(aTree).bTree(bTree).renameScore(renameScore).build();
+    }
+
+    public static Builder builder() {
+      return new AutoValue_GitModifiedFilesCacheImpl_Key.Builder();
+    }
+
+    /** Returns the size of the object in bytes */
+    public int weight() {
+      return project().get().length()
+          + 20 * 2 // old and new tree IDs
+          + 4; // rename score
+    }
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+
+      public abstract Builder project(NameKey value);
+
+      public abstract Builder aTree(ObjectId value);
+
+      public abstract Builder bTree(ObjectId value);
+
+      public abstract Builder renameScore(int value);
+
+      public Builder disableRenameDetection() {
+        renameScore(-1);
+        return this;
+      }
+
+      public abstract Key build();
+    }
+
+    // TODO(ghareeb): Implement protobuf serialization
+    enum KeySerializer implements CacheSerializer<Key> {
+      INSTANCE;
+
+      @Override
+      public byte[] serialize(Key object) {
+        return null;
+      }
+
+      @Override
+      public Key deserialize(byte[] in) {
+        return null;
+      }
+    }
+  }
+
+  // TODO(ghareeb): Implement protobuf serialization
+  public enum ValueSerializer implements CacheSerializer<ImmutableList<ModifiedFile>> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(ImmutableList<ModifiedFile> modifiedFiles) {
+      return new byte[0];
+    }
+
+    @Override
+    public ImmutableList<ModifiedFile> deserialize(byte[] in) {
+      return null;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java
new file mode 100644
index 0000000..f096cef
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java
@@ -0,0 +1,26 @@
+//  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.gitdiff;
+
+import com.google.common.cache.Weigher;
+import com.google.common.collect.ImmutableList;
+
+public class GitModifiedFilesWeigher
+    implements Weigher<GitModifiedFilesCacheImpl.Key, ImmutableList<ModifiedFile>> {
+  @Override
+  public int weigh(GitModifiedFilesCacheImpl.Key key, ImmutableList<ModifiedFile> modifiedFiles) {
+    return key.weight() + modifiedFiles.stream().mapToInt(ModifiedFile::weight).sum();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
new file mode 100644
index 0000000..1ca683f
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
@@ -0,0 +1,90 @@
+//  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.gitdiff;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import java.util.Optional;
+import org.apache.commons.lang.NotImplementedException;
+
+/**
+ * An entity representing a Modified file due to a diff between 2 git trees. This entity contains
+ * the change type and the old & new paths, but does not include any actual content diff of the
+ * file.
+ */
+@AutoValue
+public abstract class ModifiedFile {
+  /**
+   * Returns the change type (i.e. add, delete, modify, rename, etc...) associated with this
+   * modified file.
+   */
+  public abstract ChangeType changeType();
+
+  /**
+   * Returns the old name associated with this file. An empty optional is returned if {@link
+   * #changeType()} is equal to {@link ChangeType#ADDED}.
+   */
+  public abstract Optional<String> oldPath();
+
+  /**
+   * Returns the new name associated with this file. An empty optional is returned if {@link
+   * #changeType()} is equal to {@link ChangeType#DELETED}
+   */
+  public abstract Optional<String> newPath();
+
+  public static Builder builder() {
+    return new AutoValue_ModifiedFile.Builder();
+  }
+
+  /** Computes this object's weight, which is its size in bytes. */
+  public int weight() {
+    int weight = 1; // the changeType field
+    if (oldPath().isPresent()) {
+      weight += oldPath().get().length();
+    }
+    if (newPath().isPresent()) {
+      weight += newPath().get().length();
+    }
+    return weight;
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder changeType(ChangeType value);
+
+    public abstract Builder oldPath(Optional<String> value);
+
+    public abstract Builder newPath(Optional<String> value);
+
+    public abstract ModifiedFile build();
+  }
+
+  // TODO(ghareeb): Implement protobuf serialization
+  enum Serializer implements CacheSerializer<ModifiedFile> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(ModifiedFile object) {
+      throw new NotImplementedException("This method is not yet implemented");
+    }
+
+    @Override
+    public ModifiedFile deserialize(byte[] in) {
+      throw new NotImplementedException("This method is not yet implemented");
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 82215b6..9fa562a 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -118,6 +118,12 @@
     assertDiffForNewFile(result, COMMIT_MSG, result.getCommit().getFullMessage());
   }
 
+  @Ignore
+  @Test
+  public void diffWithRootCommit() throws Exception {
+    // TODO(ghareeb): Implement this test
+  }
+
   @Test
   public void patchsetLevelFileDiffIsEmpty() throws Exception {
     PushOneCommit.Result result = createChange();