James Moger
2015-11-22 ed552ba47c02779c270ffd62841d6d1048dade70
commit | author | age
df0ba7 1 /*
T 2  * Copyright 2011 gitblit.com.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *     http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.gitblit.utils;
17
18 import static org.eclipse.jgit.lib.Constants.encode;
19 import static org.eclipse.jgit.lib.Constants.encodeASCII;
20
21 import java.io.IOException;
7dd99f 22 import java.nio.charset.StandardCharsets;
df0ba7 23 import java.text.MessageFormat;
f0ebfe 24 import java.util.ArrayList;
7dd99f 25 import java.util.Arrays;
f0ebfe 26 import java.util.List;
6235fa 27 import java.util.regex.Matcher;
T 28 import java.util.regex.Pattern;
df0ba7 29
T 30 import org.apache.wicket.Application;
31 import org.apache.wicket.Localizer;
32 import org.eclipse.jgit.diff.DiffEntry;
33 import org.eclipse.jgit.diff.DiffEntry.ChangeType;
34 import org.eclipse.jgit.diff.DiffFormatter;
35 import org.eclipse.jgit.diff.RawText;
36 import org.eclipse.jgit.util.RawParseUtils;
37
38 import com.gitblit.models.PathModel.PathChangeModel;
7dd99f 39 import com.gitblit.utils.DiffUtils.BinaryDiffHandler;
df0ba7 40 import com.gitblit.utils.DiffUtils.DiffStat;
T 41 import com.gitblit.wicket.GitBlitWebApp;
42
43 /**
44  * Generates an html snippet of a diff in Gitblit's style, tracks changed paths, and calculates diff stats.
6235fa 45  *
df0ba7 46  * @author James Moger
T 47  * @author Tom <tw201207@gmail.com>
6235fa 48  *
df0ba7 49  */
T 50 public class GitBlitDiffFormatter extends DiffFormatter {
6235fa 51
T 52     /** Regex pattern identifying trailing whitespace. */
53     private static final Pattern trailingWhitespace = Pattern.compile("(\\s+?)\r?\n?$");
df0ba7 54
T 55     /**
56      * gitblit.properties key for the per-file limit on the number of diff lines.
57      */
58     private static final String DIFF_LIMIT_PER_FILE_KEY = "web.maxDiffLinesPerFile";
59
60     /**
61      * gitblit.properties key for the global limit on the number of diff lines in a commitdiff.
62      */
63     private static final String GLOBAL_DIFF_LIMIT_KEY = "web.maxDiffLines";
64
65     /**
66      * Diffs with more lines are not shown in commitdiffs. (Similar to what GitHub does.) Can be reduced
67      * (but not increased) through gitblit.properties key {@link #DIFF_LIMIT_PER_FILE_KEY}.
68      */
69     private static final int DIFF_LIMIT_PER_FILE = 4000;
70
71     /**
72      * Global diff limit. Commitdiffs with more lines are truncated. Can be reduced (but not increased)
73      * through gitblit.properties key {@link #GLOBAL_DIFF_LIMIT_KEY}.
74      */
75     private static final int GLOBAL_DIFF_LIMIT = 20000;
76
8621be 77     private static final boolean CONVERT_TABS = true;
JM 78
7dd99f 79     private final DiffOutputStream os;
df0ba7 80
T 81     private final DiffStat diffStat;
82
83     private PathChangeModel currentPath;
84
85     private int left, right;
86
87     /**
88      * If a single file diff in a commitdiff produces more than this number of lines, we don't display
89      * the diff. First, it's too taxing on the browser: it'll spend an awful lot of time applying the
90      * CSS rules (despite my having optimized them). And second, no human can read a diff with thousands
91      * of lines and make sense of it.
92      * <p>
93      * Set to {@link #DIFF_LIMIT_PER_FILE} for commitdiffs, and to -1 (switches off the limit) for
94      * single-file diffs.
95      * </p>
96      */
97     private final int maxDiffLinesPerFile;
98
99     /**
100      * Global limit on the number of diff lines. Set to {@link #GLOBAL_DIFF_LIMIT} for commitdiffs, and
101      * to -1 (switched off the limit) for single-file diffs.
102      */
103     private final int globalDiffLimit;
104
105     /** Number of lines for the current file diff. Set to zero when a new DiffEntry is started. */
106     private int nofLinesCurrent;
107     /**
108      * Position in the stream when we try to write the first line. Used to rewind when we detect that
109      * the diff is too large.
110      */
111     private int startCurrent;
112     /** Flag set to true when we rewind. Reset to false when we start a new DiffEntry. */
113     private boolean isOff;
114     /** The current diff entry. */
115     private DiffEntry entry;
116
117     // Global limit stuff.
118
119     /** Total number of lines written before the current diff entry. */
120     private int totalNofLinesPrevious;
121     /** Running total of the number of diff lines written. Updated until we exceed the global limit. */
122     private int totalNofLinesCurrent;
123     /** Stream position to reset to if we decided to truncate the commitdiff. */
124     private int truncateTo;
125     /** Whether we decided to truncate the commitdiff. */
126     private boolean truncated;
f0ebfe 127     /** If {@link #truncated}, contains all entries skipped. */
T 128     private final List<DiffEntry> skipped = new ArrayList<DiffEntry>();
df0ba7 129
310a80 130     private int tabLength;
JM 131
7dd99f 132     /**
T 133      * A {@link ResettableByteArrayOutputStream} that intercept the "Binary files differ" message produced
134      * by the super implementation. Unfortunately the super implementation has far too many things private;
135      * otherwise we'd just have re-implemented {@link GitBlitDiffFormatter#format(DiffEntry) format(DiffEntry)}
136      * completely without ever calling the super implementation.
137      */
138     private static class DiffOutputStream extends ResettableByteArrayOutputStream {
139
140         private static final String BINARY_DIFFERENCE = "Binary files differ\n";
141
142         private GitBlitDiffFormatter formatter;
143         private BinaryDiffHandler binaryDiffHandler;
144
145         public void setFormatter(GitBlitDiffFormatter formatter, BinaryDiffHandler handler) {
146             this.formatter = formatter;
147             this.binaryDiffHandler = handler;
148         }
149
150         @Override
151         public void write(byte[] b, int offset, int length) {
152             if (binaryDiffHandler != null
153                     && RawParseUtils.decode(Arrays.copyOfRange(b, offset, offset + length)).contains(BINARY_DIFFERENCE))
154             {
155                 String binaryDiff = binaryDiffHandler.renderBinaryDiff(formatter.entry);
156                 if (binaryDiff != null) {
3e1336 157                     byte[] bb = ("<tr><td colspan='4' align='center'>" + binaryDiff + "</td></tr>").getBytes(StandardCharsets.UTF_8);
7dd99f 158                     super.write(bb, 0, bb.length);
T 159                     return;
160                 }
161             }
162             super.write(b, offset, length);
163         }
164
165     }
166
310a80 167     public GitBlitDiffFormatter(String commitId, String path, BinaryDiffHandler handler, int tabLength) {
7dd99f 168         super(new DiffOutputStream());
T 169         this.os = (DiffOutputStream) getOutputStream();
170         this.os.setFormatter(this, handler);
df0ba7 171         this.diffStat = new DiffStat(commitId);
310a80 172         this.tabLength = tabLength;
df0ba7 173         // If we have a full commitdiff, install maxima to avoid generating a super-long diff listing that
T 174         // will only tax the browser too much.
175         maxDiffLinesPerFile = path != null ? -1 : getLimit(DIFF_LIMIT_PER_FILE_KEY, 500, DIFF_LIMIT_PER_FILE);
176         globalDiffLimit = path != null ? -1 : getLimit(GLOBAL_DIFF_LIMIT_KEY, 1000, GLOBAL_DIFF_LIMIT);
177     }
178
179     /**
180      * Determines a limit to use for HTML diff output.
6235fa 181      *
df0ba7 182      * @param key
T 183      *            to use to read the value from the GitBlit settings, if available.
184      * @param minimum
185      *            minimum value to enforce
186      * @param maximum
187      *            maximum (and default) value to enforce
188      * @return the limit
189      */
190     private int getLimit(String key, int minimum, int maximum) {
191         if (Application.exists()) {
192             Application application = Application.get();
193             if (application instanceof GitBlitWebApp) {
194                 GitBlitWebApp webApp = (GitBlitWebApp) application;
195                 int configValue = webApp.settings().getInteger(key, maximum);
196                 if (configValue < minimum) {
197                     return minimum;
198                 } else if (configValue < maximum) {
199                     return configValue;
200                 }
201             }
202         }
203         return maximum;
204     }
205
206     /**
207      * Returns a localized message string, if there is a localization; otherwise the given default value.
6235fa 208      *
df0ba7 209      * @param key
T 210      *            message key for the message
211      * @param defaultValue
212      *            to use if no localization for the message can be found
213      * @return the possibly localized message
214      */
215     private String getMsg(String key, String defaultValue) {
216         if (Application.exists()) {
217             Localizer localizer = Application.get().getResourceSettings().getLocalizer();
218             if (localizer != null) {
219                 // Use getStringIgnoreSettings because we don't want exceptions here if the key is missing!
220                 return localizer.getStringIgnoreSettings(key, null, null, defaultValue);
221             }
222         }
223         return defaultValue;
224     }
225
226     @Override
227     public void format(DiffEntry ent) throws IOException {
228         currentPath = diffStat.addPath(ent);
229         nofLinesCurrent = 0;
230         isOff = false;
231         entry = ent;
232         if (!truncated) {
233             totalNofLinesPrevious = totalNofLinesCurrent;
234             if (globalDiffLimit > 0 && totalNofLinesPrevious > globalDiffLimit) {
235                 truncated = true;
236                 isOff = true;
237             }
238             truncateTo = os.size();
239         } else {
240             isOff = true;
241         }
f0ebfe 242         if (truncated) {
T 243             skipped.add(ent);
244         } else {
245             // Produce a header here and now
246             String path;
247             String id;
248             if (ChangeType.DELETE.equals(ent.getChangeType())) {
249                 path = ent.getOldPath();
250                 id = ent.getOldId().name();
df0ba7 251             } else {
f0ebfe 252                 path = ent.getNewPath();
T 253                 id = ent.getNewId().name();
df0ba7 254             }
f0ebfe 255             StringBuilder sb = new StringBuilder(MessageFormat.format("<div class='header'><div class=\"diffHeader\" id=\"n{0}\"><i class=\"icon-file\"></i> ", id));
T 256             sb.append(StringUtils.escapeForHtml(path, false)).append("</div></div>");
599827 257             sb.append("<div class=\"diff\"><table cellpadding='0'><tbody>\n");
f0ebfe 258             os.write(sb.toString().getBytes());
df0ba7 259         }
T 260         // Keep formatting, but if off, don't produce anything anymore. We just keep on counting.
261         super.format(ent);
f0ebfe 262         if (!truncated) {
T 263             // Close the table
7dd99f 264             os.write("</tbody></table></div>\n".getBytes());
f0ebfe 265         }
df0ba7 266     }
T 267
268     @Override
269     public void flush() throws IOException {
270         if (truncated) {
271             os.resetTo(truncateTo);
272         }
273         super.flush();
274     }
275
276     /**
277      * Rewind and issue a message that the diff is too large.
278      */
279     private void reset() {
280         if (!isOff) {
281             os.resetTo(startCurrent);
7dd99f 282             writeFullWidthLine(getMsg("gb.diffFileDiffTooLarge", "Diff too large"));
df0ba7 283             totalNofLinesCurrent = totalNofLinesPrevious;
T 284             isOff = true;
285         }
286     }
287
288     /**
289      * Writes an initial table row containing information about added/removed/renamed/copied files. In case
290      * of a deletion, we also suppress generating the diff; it's not interesting. (All lines removed.)
291      */
292     private void handleChange() {
293         // XXX Would be nice if we could generate blob links for the cases handled here. Alas, we lack the repo
294         // name, and cannot reliably determine it here. We could get the .git directory of a Repository, if we
295         // passed in the repo, and then take the name of the parent directory, but that'd fail for repos nested
296         // in GitBlit projects. And we don't know if the repo is inside a project or is a top-level repo.
297         //
298         // That's certainly solvable (just pass along more information), but would require a larger rewrite than
299         // I'm prepared to do now.
300         String message;
301         switch (entry.getChangeType()) {
302         case ADD:
303             message = getMsg("gb.diffNewFile", "New file");
304             break;
305         case DELETE:
306             message = getMsg("gb.diffDeletedFile", "File was deleted");
307             isOff = true;
308             break;
309         case RENAME:
310             message = MessageFormat.format(getMsg("gb.diffRenamedFile", "File was renamed from {0}"), entry.getOldPath());
311             break;
312         case COPY:
313             message = MessageFormat.format(getMsg("gb.diffCopiedFile", "File was copied from {0}"), entry.getOldPath());
314             break;
315         default:
316             return;
317         }
7dd99f 318         writeFullWidthLine(message);
df0ba7 319     }
T 320
321     /**
322      * Output a hunk header
6235fa 323      *
df0ba7 324      * @param aStartLine
T 325      *            within first source
326      * @param aEndLine
327      *            within first source
328      * @param bStartLine
329      *            within second source
330      * @param bEndLine
331      *            within second source
332      * @throws IOException
333      */
334     @Override
335     protected void writeHunkHeader(int aStartLine, int aEndLine, int bStartLine, int bEndLine) throws IOException {
336         if (nofLinesCurrent++ == 0) {
337             handleChange();
338             startCurrent = os.size();
339         }
340         if (!isOff) {
341             totalNofLinesCurrent++;
342             if (nofLinesCurrent > maxDiffLinesPerFile && maxDiffLinesPerFile > 0) {
343                 reset();
344             } else {
345                 os.write("<tr><th class='diff-line' data-lineno='..'></th><th class='diff-line' data-lineno='..'></th><th class='diff-state'></th><td class='hunk_header'>"
346                         .getBytes());
347                 os.write('@');
348                 os.write('@');
349                 writeRange('-', aStartLine + 1, aEndLine - aStartLine);
350                 writeRange('+', bStartLine + 1, bEndLine - bStartLine);
351                 os.write(' ');
352                 os.write('@');
353                 os.write('@');
354                 os.write("</td></tr>\n".getBytes());
355             }
356         }
357         left = aStartLine + 1;
358         right = bStartLine + 1;
359     }
360
361     protected void writeRange(final char prefix, final int begin, final int cnt) throws IOException {
362         os.write(' ');
363         os.write(prefix);
364         switch (cnt) {
365         case 0:
366             // If the range is empty, its beginning number must be the
367             // line just before the range, or 0 if the range is at the
368             // start of the file stream. Here, begin is always 1 based,
369             // so an empty file would produce "0,0".
370             //
371             os.write(encodeASCII(begin - 1));
372             os.write(',');
373             os.write('0');
374             break;
375
376         case 1:
377             // If the range is exactly one line, produce only the number.
378             //
379             os.write(encodeASCII(begin));
380             break;
381
382         default:
383             os.write(encodeASCII(begin));
384             os.write(',');
385             os.write(encodeASCII(cnt));
386             break;
7dd99f 387         }
T 388     }
389
390     /**
391      * Writes a line spanning the full width of the code view, including the gutter.
392      *
393      * @param text
394      *            to put on that line; will be HTML-escaped.
395      */
396     private void writeFullWidthLine(String text) {
397         try {
398             os.write("<tr><td class='diff-cell' colspan='4'>".getBytes());
399             os.write(StringUtils.escapeForHtml(text, false).getBytes());
400             os.write("</td></tr>\n".getBytes());
401         } catch (IOException ex) {
402             // Cannot happen with a ByteArrayOutputStream
df0ba7 403         }
T 404     }
405
406     @Override
407     protected void writeLine(final char prefix, final RawText text, final int cur) throws IOException {
408         if (nofLinesCurrent++ == 0) {
409             handleChange();
410             startCurrent = os.size();
411         }
412         // update entry diffstat
413         currentPath.update(prefix);
414         if (isOff) {
415             return;
416         }
417         totalNofLinesCurrent++;
418         if (nofLinesCurrent > maxDiffLinesPerFile && maxDiffLinesPerFile > 0) {
419             reset();
420         } else {
421             // output diff
422             os.write("<tr>".getBytes());
423             switch (prefix) {
424             case '+':
425                 os.write(("<th class='diff-line'></th><th class='diff-line' data-lineno='" + (right++) + "'></th>").getBytes());
426                 os.write("<th class='diff-state diff-state-add'></th>".getBytes());
427                 os.write("<td class='diff-cell add2'>".getBytes());
428                 break;
429             case '-':
430                 os.write(("<th class='diff-line' data-lineno='" + (left++) + "'></th><th class='diff-line'></th>").getBytes());
431                 os.write("<th class='diff-state diff-state-sub'></th>".getBytes());
432                 os.write("<td class='diff-cell remove2'>".getBytes());
433                 break;
434             default:
435                 os.write(("<th class='diff-line' data-lineno='" + (left++) + "'></th><th class='diff-line' data-lineno='" + (right++) + "'></th>").getBytes());
436                 os.write("<th class='diff-state'></th>".getBytes());
437                 os.write("<td class='diff-cell context2'>".getBytes());
438                 break;
439             }
6235fa 440             os.write(encode(codeLineToHtml(prefix, text.getString(cur))));
df0ba7 441             os.write("</td></tr>\n".getBytes());
T 442         }
443     }
444
445     /**
6235fa 446      * Convert the given code line to HTML.
T 447      *
448      * @param prefix
449      *            the diff prefix (+/-) indicating whether the line was added or removed.
450      * @param line
451      *            the line to format as HTML
452      * @return the HTML-formatted line, safe for inserting as is into HTML.
453      */
454     private String codeLineToHtml(final char prefix, final String line) {
455         if ((prefix == '+' || prefix == '-')) {
456             // Highlight trailing whitespace on deleted/added lines.
457             Matcher matcher = trailingWhitespace.matcher(line);
458             if (matcher.find()) {
310a80 459                 StringBuilder result = new StringBuilder(StringUtils.escapeForHtml(line.substring(0, matcher.start()), CONVERT_TABS, tabLength));
6235fa 460                 result.append("<span class='trailingws-").append(prefix == '+' ? "add" : "sub").append("'>");
T 461                 result.append(StringUtils.escapeForHtml(matcher.group(1), false));
462                 result.append("</span>");
463                 return result.toString();
464             }
465         }
310a80 466         return StringUtils.escapeForHtml(line, CONVERT_TABS, tabLength);
6235fa 467     }
T 468
469     /**
df0ba7 470      * Workaround function for complex private methods in DiffFormatter. This sets the html for the diff headers.
6235fa 471      *
df0ba7 472      * @return
T 473      */
474     public String getHtml() {
475         String html = RawParseUtils.decode(os.toByteArray());
476         String[] lines = html.split("\n");
477         StringBuilder sb = new StringBuilder();
478         for (String line : lines) {
864428 479             if (line.startsWith("index") || line.startsWith("similarity")
JM 480                     || line.startsWith("rename from ") || line.startsWith("rename to ")) {
df0ba7 481                 // skip index lines
T 482             } else if (line.startsWith("new file") || line.startsWith("deleted file")) {
483                 // skip new file lines
484             } else if (line.startsWith("\\ No newline")) {
485                 // skip no new line
486             } else if (line.startsWith("---") || line.startsWith("+++")) {
487                 // skip --- +++ lines
488             } else if (line.startsWith("diff")) {
f0ebfe 489                 // skip diff lines
df0ba7 490             } else {
T 491                 boolean gitLinkDiff = line.length() > 0 && line.substring(1).startsWith("Subproject commit");
492                 if (gitLinkDiff) {
493                     sb.append("<tr><th class='diff-line'></th><th class='diff-line'></th>");
494                     if (line.charAt(0) == '+') {
495                         sb.append("<th class='diff-state diff-state-add'></th><td class=\"diff-cell add2\">");
496                     } else {
497                         sb.append("<th class='diff-state diff-state-sub'></th><td class=\"diff-cell remove2\">");
498                     }
310a80 499                     line = StringUtils.escapeForHtml(line.substring(1), CONVERT_TABS, tabLength);
df0ba7 500                 }
T 501                 sb.append(line);
502                 if (gitLinkDiff) {
503                     sb.append("</td></tr>");
504                 }
7dd99f 505                 sb.append('\n');
df0ba7 506             }
T 507         }
508         if (truncated) {
509             sb.append(MessageFormat.format("<div class='header'><div class='diffHeader'>{0}</div></div>",
510                     StringUtils.escapeForHtml(getMsg("gb.diffTruncated", "Diff truncated after the above file"), false)));
511             // List all files not shown. We can be sure we do have at least one path in skipped.
599827 512             sb.append("<div class='diff'><table cellpadding='0'><tbody><tr><td class='diff-cell' colspan='4'>");
f0ebfe 513             String deletedSuffix = StringUtils.escapeForHtml(getMsg("gb.diffDeletedFileSkipped", "(deleted)"), false);
df0ba7 514             boolean first = true;
f0ebfe 515             for (DiffEntry entry : skipped) {
df0ba7 516                 if (!first) {
T 517                     sb.append('\n');
518                 }
f0ebfe 519                 if (ChangeType.DELETE.equals(entry.getChangeType())) {
T 520                     sb.append("<span id=\"n" + entry.getOldId().name() + "\">" + StringUtils.escapeForHtml(entry.getOldPath(), false) + ' ' + deletedSuffix + "</span>");
df0ba7 521                 } else {
f0ebfe 522                     sb.append("<span id=\"n" + entry.getNewId().name() + "\">" + StringUtils.escapeForHtml(entry.getNewPath(), false) + "</span>");
df0ba7 523                 }
T 524                 first = false;
525             }
f0ebfe 526             skipped.clear();
df0ba7 527             sb.append("</td></tr></tbody></table></div>");
T 528         }
529         return sb.toString();
530     }
531
532     public DiffStat getDiffStat() {
533         return diffStat;
534     }
535 }