Add basic blame support Blame is rendered as a two-cell table, with blame information in the left cell and the normal blob <pre> in the right. Currently implemented in a blocking fashion, rendering the entire blame before sending to the client. Additionally, no state is shared between requests, since JGit's blame API does not support blaming of a sequence of history in a way that would make this easier. Change-Id: I639f1795a9ba6cc0c9677740e41ac34d82c8eb6e
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/BlameServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/BlameServlet.java index 53f2213..c73f7c0 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/BlameServlet.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/BlameServlet.java
@@ -14,7 +14,34 @@ package com.google.gitiles; +import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; + +import com.google.common.base.Objects; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; + +import org.eclipse.jgit.blame.BlameGenerator; +import org.eclipse.jgit.blame.BlameResult; +import org.eclipse.jgit.errors.IncorrectObjectTypeException; +import org.eclipse.jgit.http.server.ServletUtils; import org.eclipse.jgit.lib.Config; +import org.eclipse.jgit.lib.FileMode; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectReader; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.treewalk.TreeWalk; +import org.eclipse.jgit.util.GitDateFormatter; +import org.eclipse.jgit.util.GitDateFormatter.Format; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; /** Serves an HTML page with blame data for a commit. */ public class BlameServlet extends BaseServlet { @@ -23,4 +50,134 @@ public BlameServlet(Config cfg, Renderer renderer) { super(cfg, renderer); } + + @Override + protected void doGetHtml(HttpServletRequest req, HttpServletResponse res) + throws IOException { + GitilesView view = ViewFilter.getView(req); + Repository repo = ServletUtils.getRepository(req); + + RevWalk rw = new RevWalk(repo); + try { + ObjectId blobId = resolveBlob(view, rw); + if (blobId == null) { + res.setStatus(SC_NOT_FOUND); + return; + } + + String title = "Blame - " + view.getPathPart(); + Map<String, ?> blobData = new BlobSoyData(rw, view).toSoyData(view.getPathPart(), blobId); + if (blobData.get("data") != null) { + BlameResult blame = doBlame(repo, view); + if (blame == null) { + res.setStatus(SC_NOT_FOUND); + return; + } + GitDateFormatter df = new GitDateFormatter(Format.DEFAULT); + int lineCount = blame.getResultContents().size(); + blame.discardResultContents(); + renderHtml(req, res, "gitiles.blameDetail", ImmutableMap.of( + "title", title, + "breadcrumbs", view.getBreadcrumbs(), + "data", blobData, + "regions", toRegionData(view, rw.getObjectReader(), blame, lineCount, df))); + } else { + renderHtml(req, res, "gitiles.blameDetail", ImmutableMap.of( + "title", title, + "breadcrumbs", view.getBreadcrumbs(), + "data", blobData)); + } + } finally { + rw.release(); + } + } + + private static ObjectId resolveBlob(GitilesView view, RevWalk rw) throws IOException { + try { + TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), view.getPathPart(), + rw.parseTree(view.getRevision().getId())); + if ((tw.getRawMode(0) & FileMode.TYPE_FILE) == 0) { + return null; + } + return tw.getObjectId(0); + } catch (IncorrectObjectTypeException e) { + return null; + } + } + + private static BlameResult doBlame(Repository repo, GitilesView view) throws IOException { + BlameGenerator gen = new BlameGenerator(repo, view.getPathPart()); + BlameResult blame; + try { + // TODO: works on annotated tag? + gen.push(null, view.getRevision().getId()); + blame = gen.computeBlameResult(); + } finally { + gen.release(); + } + return blame; + } + + private List<Map<String, ?>> toRegionData(GitilesView view, ObjectReader reader, + BlameResult blame, int lineCount, GitDateFormatter df) throws IOException { + List<Region> regions = Lists.newArrayList(); + for (int i = 0; i < lineCount; i++) { + if (regions.isEmpty() || !regions.get(regions.size() - 1).growFrom(blame, i)) { + regions.add(new Region(blame, i)); + } + } + + Map<ObjectId, String> abbrevShas = Maps.newHashMap(); + List<Map<String, ?>> result = Lists.newArrayListWithCapacity(regions.size()); + for (Region r : regions) { + result.add(r.toSoyData(view, reader, abbrevShas, df)); + } + return result; + } + + private class Region { + private final String sourcePath; + private final RevCommit sourceCommit; + private int count; + + private Region(BlameResult blame, int start) { + this.sourcePath = blame.getSourcePath(start); + this.sourceCommit = blame.getSourceCommit(start); + this.count = 1; + } + + private boolean growFrom(BlameResult blame, int i) { + // Don't compare line numbers, so we collapse regions from the same source + // but with deleted lines into one. + if (Objects.equal(blame.getSourcePath(i), sourcePath) + && Objects.equal(blame.getSourceCommit(i), sourceCommit)) { + count++; + return true; + } else { + return false; + } + } + + private Map<String, ?> toSoyData(GitilesView view, ObjectReader reader, + Map<ObjectId, String> abbrevShas, GitDateFormatter df) throws IOException { + if (sourceCommit == null) { + // JGit bug may fail to blame some regions. We should fix this + // upstream, but handle it for now. + return ImmutableMap.of("count", count); + } + String abbrevSha = abbrevShas.get(sourceCommit); + if (abbrevSha == null) { + abbrevSha = reader.abbreviate(sourceCommit).name(); + abbrevShas.put(sourceCommit, abbrevSha); + } + return ImmutableMap.of( + "abbrevSha", abbrevSha, + "url", GitilesView.blame().copyFrom(view) + .setRevision(sourceCommit.name()) + .setPathPart(sourcePath) + .toUrl(), + "author", CommitSoyData.toSoyData(sourceCommit.getAuthorIdent(), df), + "count", count); + } + } }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/BlobSoyData.java b/gitiles-servlet/src/main/java/com/google/gitiles/BlobSoyData.java index 6b57cbc..5c8d65f 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/BlobSoyData.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/BlobSoyData.java
@@ -77,6 +77,7 @@ } if (path != null && view.getRevision().getPeeledType() == OBJ_COMMIT) { data.put("logUrl", GitilesView.log().copyFrom(view).toUrl()); + data.put("blameUrl", GitilesView.blame().copyFrom(view).toUrl()); } return data; }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java b/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java index aa12869..1ab26c4 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java
@@ -36,6 +36,7 @@ /** Renderer for Soy templates used by Gitiles. */ public abstract class Renderer { private static final List<String> SOY_FILENAMES = ImmutableList.of( + "BlameDetail.soy", "Common.soy", "DiffDetail.soy", "HostIndex.soy",
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 0f1f67b..9d37f51 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
@@ -333,6 +333,43 @@ } +/* Styles for the blame detail template. */ + +#blame { + margin: 0; + padding: 0; +} +#blame td { + padding: 0; +} +#regions { + padding-top: 2px; + padding-right: 2px; + padding-bottom: 5px; + padding-left: 2px; + + /* Matching pre.git-blob below. */ + font-family: monospace; + font-size: 8pt; + border-bottom: #ddd solid 1px; /* BORDER */ +} +#regions ul { + list-style-type: none; + margin-top: 0; + margin-bottom: 0; + padding-left: 0; +} +#blame .time, #blame .sha1 { + /* Smaller than SHORTLOG_SMALL_FONT_SIZE to match pre. */ + font-size: 8pt; +} +#blame .author { + padding-left: 0px; +} +#blame pre.git-blob { + border-top: 0; +} + /* Override some styles from the default prettify.css. */ /* Line numbers on all lines. */
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/BlameDetail.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/BlameDetail.soy new file mode 100644 index 0000000..109a442 --- /dev/null +++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/BlameDetail.soy
@@ -0,0 +1,83 @@ +// Copyright 2014 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. +{namespace gitiles autoescape="contextual"} + +/** + * Detail page showing blame info for a file. + * + * @param title human-readable revision name. + * @param repositoryName name of this repository. + * @param? menuEntries menu entries. + * @param breadcrumbs breadcrumbs for this page. + * @param data blob data, matching the params for .blobBox. + * @param? regions for non-binary files, list of blame regions with the + * following keys: + * abbrevSha: abbreviated SHA-1 of revision for this line; if missing, + * assume blame info is missing. + * url: URL for detail about the revision + * author: author information with at least "name" and "relativeTime" + * keys. + * relativeTime: relative time of the revision + * count: line count + */ +{template .blameDetail} +{if $regions} + {call .header} + {param title: $title /} + {param repositoryName: $repositoryName /} + {param menuEntries: $menuEntries /} + {param breadcrumbs: $breadcrumbs /} + {param css: [gitiles.PRETTIFY_CSS_URL] /} + {param js: [gitiles.PRETTIFY_JS_URL] /} + {param onLoad: 'prettyPrint()' /} + {/call} + + <table id="blame"> + <tr> + <td> + <pre id="regions"> + <ul> + {foreach $region in $regions} + <li> + {if $region.abbrevSha} + <a href="{$region.url}"> + <span class="sha1">{$region.abbrevSha}</span> + {sp}<span class="author">{$region.author.name}</span> + {sp}<span class="time">- {$region.author.relativeTime}</span> + </a> + {else} + + {/if} + </li> + {for $i in range($region.count - 1)} + <li> </li> + {/for} + {/foreach} + </ul> + </pre> + </td> + <td> + {call .blobBox data="$data" /} + </td> + </tr> + </table> + + </div> +{else} + {call .header data="all" /} + {call .blobDetail data="all" /} +{/if} + +{call .footer /} +{/template}
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/ObjectDetail.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/ObjectDetail.soy index 07dfc87..78ea913 100644 --- a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/ObjectDetail.soy +++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/ObjectDetail.soy
@@ -209,6 +209,7 @@ * * @param sha SHA of this file's blob. * @param? logUrl optional URL to a log for this file. + * @param? blameUrl optional URL to a blame for this file. * @param data file data (may be empty), or null for a binary file. * @param? lang prettyprint language extension for text file. * @param? size for binary files only, size in bytes. @@ -217,23 +218,35 @@ <div class="sha1"> {msg desc="SHA-1 for the file's blob"}blob: {$sha}{/msg} {if $logUrl}{sp}[<a href="{$logUrl}">{msg desc="history for a file"}file history{/msg}</a>]{/if} + {if $blameUrl}{sp}[<a href="{$blameUrl}">{msg desc="blame for a file"}blame{/msg}</a>]{/if} </div> -{if $data != null} - {if $data} - {if $lang != null} - <pre class="git-blob prettyprint linenums lang-{$lang}">{$data}</pre> +{call .blobBox data="all" /} +{/template} + +/** + * Preformatted box containing blob contents. + * + * @param data file data (may be empty), or null for a binary file. + * @param? lang prettyprint language extension for text file. + * @param? size for binary files only, size in bytes. + */ +{template .blobBox} + {if $data != null} + {if $data} + {if $lang != null} + <pre class="git-blob prettyprint linenums lang-{$lang}">{$data}</pre> + {else} + <pre class="git-blob">{$data}</pre> + {/if} {else} - <pre class="git-blob">{$data}</pre> + <div class="file-empty">Empty file</div> {/if} {else} - <div class="file-empty">Empty file</div> + <div class="file-binary"> + {msg desc="size of binary file in bytes"}{$size}-byte binary file{/msg} + </div> {/if} -{else} - <div class="file-binary"> - {msg desc="size of binary file in bytes"}{$size}-byte binary file{/msg} - </div> -{/if} {/template} /**