Indicate copy/rename source in HTML log view

Eking this out of the RevWalk is a little tricky, because the only way
to see when a rename happened is to set a RenameCallback on the
FollowFilter. Do this in Paginator, which is already a stateful
wrapper around the RevWalk, by keeping track of a map of commit to
rename entry present in that commit. Plumb this through to LogSoyData,
which is a little ugly because it adds an element after the fact into
the entry map produced by CommitSoyData. Actually shoving this all the
way through to CommitData would have been more trouble than it's
worth.

Change-Id: I4538c9a15351f3c1cc5ab1fa7ea7b58e6fa6d4e4
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/LogSoyData.java b/gitiles-servlet/src/main/java/com/google/gitiles/LogSoyData.java
index 85355ff..97fe1ce 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/LogSoyData.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/LogSoyData.java
@@ -24,6 +24,8 @@
 import com.google.gitiles.CommitData.Field;
 import com.google.template.soy.tofu.SoyTofu;
 
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffEntry.ChangeType;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -101,11 +103,30 @@
     }
 
     Map<String, Object> entry = csd.setRevWalk(paginator.getWalk()).toSoyData(req, c, fields, df);
+    DiffEntry rename = paginator.getRename(c);
+    if (rename != null) {
+      entry.put("rename", toRenameSoyData(rename));
+    }
     return ImmutableMap.of(
         "variant", variant,
         "entry", entry);
   }
 
+  private Map<String, Object> toRenameSoyData(DiffEntry entry) {
+    if (entry == null) {
+      return null;
+    }
+    ChangeType type = entry.getChangeType();
+    if (type != ChangeType.RENAME && type != ChangeType.COPY) {
+      return null;
+    }
+    return ImmutableMap.<String, Object> of(
+        "changeType", type.toString(),
+        "oldPath", entry.getOldPath(),
+        "newPath", entry.getNewPath(),
+        "score", entry.getScore());
+  }
+
   private Map<String, Object> toFooterSoyData(Paginator paginator, @Nullable String revision) {
     Map<String, Object> data = Maps.newHashMapWithExpectedSize(1);
     ObjectId next = paginator.getNextStart();
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/Paginator.java b/gitiles-servlet/src/main/java/com/google/gitiles/Paginator.java
index b94d959..1a1a053 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/Paginator.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/Paginator.java
@@ -18,17 +18,23 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 
+import org.eclipse.jgit.diff.DiffEntry;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.RevWalkException;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.FollowFilter;
+import org.eclipse.jgit.revwalk.RenameCallback;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.filter.TreeFilter;
 
 import java.io.IOException;
 import java.util.ArrayDeque;
 import java.util.Deque;
+import java.util.HashMap;
 import java.util.Iterator;
+import java.util.Map;
 
 import javax.annotation.Nullable;
 
@@ -45,19 +51,36 @@
  * "c0ffee".
  */
 class Paginator implements Iterable<RevCommit> {
+  private static class RenameWatcher extends RenameCallback {
+    private DiffEntry entry;
+
+    @Override
+    public void renamed(DiffEntry entry) {
+      this.entry = entry;
+    }
+
+    private DiffEntry getAndClear() {
+      DiffEntry e = entry;
+      entry = null;
+      return e;
+    }
+  }
+
   private final RevWalk walk;
   private final int limit;
   private final ObjectId prevStart;
+  private final RenameWatcher renameWatcher;
 
   private RevCommit first;
   private boolean done;
   private int n;
   private ObjectId nextStart;
+  private Map<ObjectId, DiffEntry> renamed;
 
   /**
    * Construct a paginator and walk eagerly to the first returned commit.
    *
-   * @param walk revision walk.
+   * @param walk revision walk; must be fully initialized before calling.
    * @param limit page size.
    * @param start commit at which to start the walk, or null to start at the
    *     beginning.
@@ -68,10 +91,17 @@
     checkArgument(limit > 0, "limit must be positive: %s", limit);
     this.limit = limit;
 
+    TreeFilter filter = walk.getTreeFilter();
+    if (filter instanceof FollowFilter) {
+      renameWatcher = new RenameWatcher();
+      ((FollowFilter) filter).setRenameCallback(renameWatcher);
+    } else {
+      renameWatcher = null;
+    }
 
     Deque<ObjectId> prevBuffer = new ArrayDeque<>(start != null ? limit : 0);
     while (true) {
-      RevCommit commit = walk.next();
+      RevCommit commit = nextWithRename();
       if (commit == null) {
         done = true;
         break;
@@ -107,10 +137,10 @@
       commit = first;
       first = null;
     } else {
-      commit = walk.next();
+      commit = nextWithRename();
     }
     if (++n == limit) {
-      nextStart = walk.next();
+      nextStart = nextWithRename();
       done = true;
     } else if (commit == null) {
       done = true;
@@ -118,6 +148,24 @@
     return commit;
   }
 
+  private RevCommit nextWithRename() throws IOException {
+    RevCommit next = walk.next();
+    if (renameWatcher != null) {
+      // The commit that triggered the rename isn't available to RenameWatcher,
+      // so we can't populate the map from the callback directly. Instead, we
+      // need to check after each call to walk.next() whether a rename occurred
+      // due to this commit.
+      DiffEntry entry = renameWatcher.getAndClear();
+      if (entry != null) {
+        if (renamed == null) {
+          renamed = new HashMap<>();
+        }
+        renamed.put(next.copy(), entry);
+      }
+    }
+    return next;
+  }
+
   /**
    * @return the ID at the start of the page of results preceding this one, or
    *     null if this is the first page.
@@ -136,6 +184,13 @@
   }
 
   /**
+   * @return entry corresponding to a rename or copy at the given commit.
+   */
+  public DiffEntry getRename(ObjectId commitId) {
+    return renamed != null ? renamed.get(commitId) : null;
+  }
+
+  /**
    * @return an iterator over the commits in this walk.
    * @throws RevWalkException if an error occurred, wrapping the checked
    *     exception from {@link #next()}.
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/base.css b/gitiles-servlet/src/main/resources/com/google/gitiles/static/base.css
index 21e1c4c..c64b14d 100644
--- a/gitiles-servlet/src/main/resources/com/google/gitiles/static/base.css
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/base.css
@@ -350,6 +350,11 @@
 .CommitLog-tagLabel {
   color: #093;
 }
+.CommitLog-rename {
+  font-size: 0.9em;
+  display: block;
+  padding-left: 5px;
+}
 
 /* ObjectDetail.soy */
 
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/LogDetail.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/LogDetail.soy
index 015b88a..ba2c24d 100644
--- a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/LogDetail.soy
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/LogDetail.soy
@@ -106,6 +106,11 @@
  * @param branches list of branches for this entry, with "name" and "url" keys.
  * @param tags list of tags for this entry, with "name" and "url" keys.
  * @param diffTree unused in this variant.
+ * @param rename if this entry was a rename or a copy of the path, an object containg:
+ *     changeType: the change type, "RENAME" or "COPY".
+ *     oldPath: the old path prior to the rename or copy.
+ *     newPath: the new path after the rename or copy.
+ *     score: the similarity score of the rename or copy.
  */
 {deltemplate gitiles.logEntry variant="'oneline'"}
 <a class="u-sha1 u-monospace CommitLog-sha1" href="{$url}">{$abbrevSha}</a>
@@ -123,6 +128,22 @@
   {/foreach}
 {/if}
 
+{if $rename}
+  <span class="CommitLog-rename">
+    [
+    {switch $rename.changeType}
+      {case 'RENAME'}
+        Renamed
+      {case 'COPY'}
+        Copied
+    {/switch}
+    {if $rename.score != 100}
+      {sp}({$rename.score}%)
+    {/if}
+    {sp}from {$rename.oldPath}]
+  </span>
+{/if}
+
 {/deltemplate}
 
 
@@ -141,6 +162,11 @@
  * @param branches list of branches for this entry, with "name" and "url" keys.
  * @param tags list of tags for this entry, with "name" and "url" keys.
  * @param diffTree unused in this variant.
+ * @param rename if this entry was a rename or a copy of the path, an object containg:
+ *     changeType: the change type, "RENAME" or "COPY".
+ *     oldPath: the old path prior to the rename or copy.
+ *     newPath: the new path after the rename or copy.
+ *     score: the similarity score of the rename or copy.
  */
 {deltemplate gitiles.logEntry variant="'default'"}
 {delcall gitiles.logEntry variant="'oneline'" data="all" /}
@@ -162,6 +188,11 @@
  * @param branches list of branches for this entry, with "name" and "url" keys.
  * @param tags list of tags for this entry, with "name" and "url" keys.
  * @param diffTree unused in this variant.
+ * @param rename if this entry was a rename or a copy of the path, an object containg:
+ *     changeType: the change type, "RENAME" or "COPY".
+ *     oldPath: the old path prior to the rename or copy.
+ *     newPath: the new path after the rename or copy.
+ *     score: the similarity score of the rename or copy.
  */
 {deltemplate gitiles.logEntry variant="'full'"}
 <div class="u-monospace Metadata">
@@ -196,6 +227,27 @@
     <td>{call .person_ data="$committer" /}</td>
     <td>{$committer.time}</td>
   </tr>
+
+  {if $rename}
+    <tr>
+      <td colspan="3">
+        <span class="CommitLog-rename">
+          [
+          {switch $rename.changeType}
+            {case 'RENAME'}
+              Renamed
+            {case 'COPY'}
+              Copied
+          {/switch}
+          {if $rename.score != 100}
+            {sp}({$rename.score}%)
+          {/if}
+          {sp}from {$rename.oldPath}]
+        </span>
+      </td>
+    </tr>
+  {/if}
+
 </table>
 </div>
 <pre class="u-pre u-monospace MetadataMessage">