Render README.md at bottom of repository index

If README.md exists in the HEAD tree show only the most recent
5 commits and then display the rendered README.md below that.
For Gitiles itself this is helpful as the top of README.md
explains the project and what the repository contains.

Change-Id: Ibc3b199893958fd1ecfebf2a06734eb8aa3f3375
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/ReadmeHelper.java b/gitiles-servlet/src/main/java/com/google/gitiles/ReadmeHelper.java
new file mode 100644
index 0000000..07e21f9
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/ReadmeHelper.java
@@ -0,0 +1,120 @@
+// Copyright 2015 Google Inc. All Rights Reserved.
+//
+// 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.gitiles;
+
+import com.google.gitiles.doc.GitilesMarkdown;
+import com.google.gitiles.doc.ImageLoader;
+import com.google.gitiles.doc.MarkdownToHtml;
+import com.google.template.soy.data.SanitizedContent;
+
+import org.eclipse.jgit.errors.CorruptObjectException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.LargeObjectException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.pegdown.ast.RootNode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+class ReadmeHelper {
+  private static final Logger log = LoggerFactory.getLogger(ReadmeHelper.class);
+
+  private final ObjectReader reader;
+  private final GitilesView view;
+  private final Config cfg;
+  private final RevTree rootTree;
+  private final boolean render;
+
+  private String readmePath;
+  private ObjectId readmeId;
+
+  ReadmeHelper(ObjectReader reader, GitilesView view, Config cfg,
+      RevTree rootTree) {
+    this.reader = reader;
+    this.view = view;
+    this.cfg = cfg;
+    this.rootTree = rootTree;
+    render = cfg.getBoolean("markdown", "render", true);
+  }
+
+  void scanTree(RevTree tree) throws MissingObjectException,
+      IncorrectObjectTypeException, CorruptObjectException, IOException {
+    if (render) {
+      TreeWalk tw = new TreeWalk(reader);
+      tw.setRecursive(false);
+      tw.addTree(tree);
+      while (tw.next() && !isPresent()) {
+        considerEntry(tw);
+      }
+    }
+  }
+
+  void considerEntry(TreeWalk tw) {
+    if (render
+        && FileMode.REGULAR_FILE.equals(tw.getRawMode(0))
+        && isReadmeFile(tw.getNameString())) {
+      readmePath = tw.getPathString();
+      readmeId = tw.getObjectId(0);
+    }
+  }
+
+  boolean isPresent() {
+    return readmeId != null;
+  }
+
+  String getPath() {
+    return readmePath;
+  }
+
+  SanitizedContent render() {
+    try {
+      int inputLimit = cfg.getInt("markdown", "inputLimit", 5 << 20);
+      byte[] raw = reader.open(readmeId, Constants.OBJ_BLOB).getCachedBytes(inputLimit);
+      String md = RawParseUtils.decode(raw);
+      RootNode root = GitilesMarkdown.parseFile(view, readmePath, md);
+      if (root == null) {
+        return null;
+      }
+
+      int imageLimit = cfg.getInt("markdown", "imageLimit", 256 << 10);
+      ImageLoader img = null;
+      if (imageLimit > 0) {
+        img = new ImageLoader(reader, view, rootTree, readmePath, imageLimit);
+      }
+
+      return new MarkdownToHtml(view, cfg)
+        .setImageLoader(img)
+        .toSoyHtml(root);
+    } catch (LargeObjectException | IOException e) {
+      log.error(String.format("error rendering %s/%s",
+          view.getRepositoryName(), readmePath), e);
+      return null;
+    }
+  }
+
+  /** True if the file is the default markdown file to render in tree view. */
+  private static boolean isReadmeFile(String name) {
+    return name.equalsIgnoreCase("README.md");
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java
index 7e8e573..21bf1bf 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java
@@ -18,16 +18,20 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
 import com.google.gitiles.DateFormatter.Format;
 import com.google.gson.reflect.TypeToken;
 
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 import java.io.IOException;
@@ -45,6 +49,7 @@
 
   static final int REF_LIMIT = 10;
   private static final int LOG_LIMIT = 20;
+  private static final int LOG_WITH_README_LIMIT = 5;
 
   private final TimeCache timeCache;
 
@@ -69,11 +74,17 @@
       ObjectId headId = repo.resolve(Constants.HEAD);
       if (headId != null) {
         RevObject head = walk.parseAny(headId);
+        int limit = LOG_LIMIT;
+        Map<String, Object> readme = renderReadme(walk, view, access.getConfig(), head);
+        if (readme != null) {
+          data.putAll(readme);
+          limit = LOG_WITH_README_LIMIT;
+        }
         // TODO(dborowitz): Handle non-commit or missing HEAD?
         if (head.getType() == Constants.OBJ_COMMIT) {
           walk.reset();
           walk.markStart((RevCommit) head);
-          paginator = new Paginator(walk, LOG_LIMIT, null);
+          paginator = new Paginator(walk, limit, null);
         }
       }
       if (!data.containsKey("entries")) {
@@ -121,4 +132,21 @@
   private static <T> List<T> trim(List<T> list) {
     return list.size() > REF_LIMIT ? list.subList(0, REF_LIMIT) : list;
   }
+
+  private Map<String, Object> renderReadme(RevWalk walk, GitilesView view,
+      Config cfg, RevObject head) throws IOException {
+    RevTree rootTree;
+    try {
+      rootTree = walk.parseTree(head);
+    } catch (IncorrectObjectTypeException notTreeish) {
+      return null;
+    }
+
+    ReadmeHelper readme = new ReadmeHelper(walk.getObjectReader(), view, cfg, rootTree);
+    readme.scanTree(rootTree);
+    if (readme.isPresent()) {
+      return ImmutableMap.<String, Object> of("readmeHtml", readme.render());
+    }
+    return null;
+  }
 }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java b/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java
index 4bef0f8..6967c65 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java
@@ -22,23 +22,13 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.gitiles.PathServlet.FileType;
-import com.google.gitiles.doc.GitilesMarkdown;
-import com.google.gitiles.doc.ImageLoader;
-import com.google.gitiles.doc.MarkdownToHtml;
-import com.google.template.soy.data.SanitizedContent;
 
-import org.eclipse.jgit.errors.LargeObjectException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.treewalk.TreeWalk;
-import org.eclipse.jgit.util.RawParseUtils;
-import org.pegdown.ast.RootNode;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.util.List;
@@ -46,8 +36,6 @@
 
 /** Soy data converter for git trees. */
 public class TreeSoyData {
-  private static final Logger log = LoggerFactory.getLogger(TreeSoyData.class);
-
   /**
    * Number of characters to display for a symlink target. Targets longer than
    * this are abbreviated for display in a tree listing.
@@ -104,9 +92,7 @@
 
   public Map<String, Object> toSoyData(ObjectId treeId, TreeWalk tw) throws MissingObjectException,
          IOException {
-    String readmePath = null;
-    ObjectId readmeId = null;
-
+    ReadmeHelper readme = new ReadmeHelper(reader, view, cfg, rootTree);
     List<Object> entries = Lists.newArrayList();
     GitilesView.Builder urlBuilder = GitilesView.path().copyFrom(view);
     while (tw.next()) {
@@ -144,9 +130,8 @@
         if (targetUrl != null) {
           entry.put("targetUrl", targetUrl);
         }
-      } else if (isReadmeFile(name) && type == FileType.REGULAR_FILE) {
-        readmePath = tw.getPathString();
-        readmeId = tw.getObjectId(0);
+      } else {
+        readme.considerEntry(tw);
       }
       entries.add(entry);
     }
@@ -166,49 +151,18 @@
       data.put("archiveType", archiveFormat.getShortName());
     }
 
-    if (readmeId != null && cfg.getBoolean("markdown", "render", true)) {
-      data.put("readmePath", readmePath);
-      data.put("readmeHtml", render(readmePath, readmeId));
+    if (readme.isPresent()) {
+      data.put("readmePath", readme.getPath());
+      data.put("readmeHtml", readme.render());
     }
 
     return data;
   }
 
-  /** True if the file is the default markdown file to render in tree view. */
-  private static boolean isReadmeFile(String name) {
-    return name.equalsIgnoreCase("README.md");
-  }
-
   public Map<String, Object> toSoyData(ObjectId treeId) throws MissingObjectException, IOException {
     TreeWalk tw = new TreeWalk(reader);
     tw.addTree(treeId);
     tw.setRecursive(false);
     return toSoyData(treeId, tw);
   }
-
-  private SanitizedContent render(String path, ObjectId id) {
-    try {
-      int inputLimit = cfg.getInt("markdown", "inputLimit", 5 << 20);
-      byte[] raw = reader.open(id, Constants.OBJ_BLOB).getCachedBytes(inputLimit);
-      String md = RawParseUtils.decode(raw);
-      RootNode root = GitilesMarkdown.parseFile(view, path, md);
-      if (root == null) {
-        return null;
-      }
-
-      int imageLimit = cfg.getInt("markdown", "imageLimit", 256 << 10);
-      ImageLoader img = null;
-      if (imageLimit > 0) {
-        img = new ImageLoader(reader, view, rootTree, path, imageLimit);
-      }
-
-      return new MarkdownToHtml(view, cfg)
-        .setImageLoader(img)
-        .toSoyHtml(root);
-    } catch (LargeObjectException | IOException e) {
-      log.error(String.format("error rendering %s/%s/%s",
-          view.getRepositoryName(), view.getPathPart(), path), e);
-      return null;
-    }
-  }
 }
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css b/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css
index 13c187f..37e3dca 100644
--- a/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/static/gitiles.css
@@ -427,6 +427,12 @@
   font-size: 9pt;
   padding-top: 5px; /* VPADDING */
 }
+.repository-index-doc {
+  border-top: #ddd solid 1px; /* BORDER */
+  margin-top: 5px; /* VPADDING */
+  padding-top: 5px; /* VPADDING */
+  margin-left: 200px;
+}
 .doc {
   border-bottom: #ddd solid 1px; /* BORDER */
 }
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RepositoryIndex.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RepositoryIndex.soy
index aaba64b..17b0053 100644
--- a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RepositoryIndex.soy
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/RepositoryIndex.soy
@@ -28,15 +28,27 @@
  * @param tags list of tag objects with url and name keys.
  * @param? moreTagsUrl URL to show more branches, if necessary.
  * @param hasLog whether a log should be shown for HEAD.
+ * @param? readmeHtml optional rendered README.md contents.
  */
 {template .repositoryIndex}
-{call .header}
-  {param title: $repositoryName /}
-  {param repositoryName: null /}
-  {param menuEntries: $menuEntries /}
-  {param headerVariant: $headerVariant /}
-  {param breadcrumbs: $breadcrumbs /}
-{/call}
+{if $readmeHtml}
+  {call .header data="all"}
+    {param title: $repositoryName /}
+    {param repositoryName: null /}
+    {param menuEntries: $menuEntries /}
+    {param headerVariant: $headerVariant /}
+    {param breadcrumbs: $breadcrumbs /}
+    {param css: [gitiles.DOC_CSS_URL] /}
+  {/call}
+{else}
+  {call .header}
+    {param title: $repositoryName /}
+    {param repositoryName: null /}
+    {param menuEntries: $menuEntries /}
+    {param headerVariant: $headerVariant /}
+    {param breadcrumbs: $breadcrumbs /}
+  {/call}
+{/if}
 
 {if $description or $mirroredFromUrl}
   <div class="repository-description">
@@ -62,6 +74,9 @@
     <div class="repository-shortlog">
       {call .streamingPlaceholder /}
     </div>
+    {if $readmeHtml}
+      <div class="doc repository-index-doc">{$readmeHtml}</div>
+    {/if}
   </div>
 
   <div class="repository-refs">