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