James Moger
2015-11-22 ed552ba47c02779c270ffd62841d6d1048dade70
src/main/java/com/gitblit/utils/GitBlitDiffFormatter.java
@@ -1,236 +1,535 @@
/*
 * Copyright 2011 gitblit.com.
 *
 * 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.gitblit.utils;
import static org.eclipse.jgit.lib.Constants.encode;
import static org.eclipse.jgit.lib.Constants.encodeASCII;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.text.MessageFormat;
import org.eclipse.jgit.diff.DiffEntry;
import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.diff.RawText;
import org.eclipse.jgit.util.RawParseUtils;
import com.gitblit.models.PathModel.PathChangeModel;
import com.gitblit.utils.DiffUtils.DiffStat;
/**
 * Generates an html snippet of a diff in Gitblit's style, tracks changed paths,
 * and calculates diff stats.
 *
 * @author James Moger
 *
 */
public class GitBlitDiffFormatter extends DiffFormatter {
   private final OutputStream os;
   private final DiffStat diffStat;
   private PathChangeModel currentPath;
   private int left, right;
   public GitBlitDiffFormatter(OutputStream os, String commitId) {
      super(os);
      this.os = os;
      this.diffStat = new DiffStat(commitId);
   }
   @Override
   public void format(DiffEntry ent) throws IOException {
      currentPath = diffStat.addPath(ent);
      super.format(ent);
   }
   /**
    * Output a hunk header
    *
    * @param aStartLine
    *            within first source
    * @param aEndLine
    *            within first source
    * @param bStartLine
    *            within second source
    * @param bEndLine
    *            within second source
    * @throws IOException
    */
   @Override
   protected void writeHunkHeader(int aStartLine, int aEndLine, int bStartLine, int bEndLine)
         throws IOException {
      os.write("<tr><th>..</th><th>..</th><td class='hunk_header'>".getBytes());
      os.write('@');
      os.write('@');
      writeRange('-', aStartLine + 1, aEndLine - aStartLine);
      writeRange('+', bStartLine + 1, bEndLine - bStartLine);
      os.write(' ');
      os.write('@');
      os.write('@');
      os.write("</td></tr>\n".getBytes());
      left = aStartLine + 1;
      right = bStartLine + 1;
   }
   protected void writeRange(final char prefix, final int begin, final int cnt) throws IOException {
      os.write(' ');
      os.write(prefix);
      switch (cnt) {
      case 0:
         // If the range is empty, its beginning number must
         // be the
         // line just before the range, or 0 if the range is
         // at the
         // start of the file stream. Here, begin is always 1
         // based,
         // so an empty file would produce "0,0".
         //
         os.write(encodeASCII(begin - 1));
         os.write(',');
         os.write('0');
         break;
      case 1:
         // If the range is exactly one line, produce only
         // the number.
         //
         os.write(encodeASCII(begin));
         break;
      default:
         os.write(encodeASCII(begin));
         os.write(',');
         os.write(encodeASCII(cnt));
         break;
      }
   }
   @Override
   protected void writeLine(final char prefix, final RawText text, final int cur)
         throws IOException {
      // update entry diffstat
      currentPath.update(prefix);
      // output diff
      os.write("<tr>".getBytes());
      switch (prefix) {
      case '+':
         os.write(("<th></th><th>" + (right++) + "</th>").getBytes());
         os.write("<td><div class=\"diff add2\">".getBytes());
         break;
      case '-':
         os.write(("<th>" + (left++) + "</th><th></th>").getBytes());
         os.write("<td><div class=\"diff remove2\">".getBytes());
         break;
      default:
         os.write(("<th>" + (left++) + "</th><th>" + (right++) + "</th>").getBytes());
         os.write("<td>".getBytes());
         break;
      }
      os.write(prefix);
      String line = text.getString(cur);
      line = StringUtils.escapeForHtml(line, false);
      os.write(encode(line));
      switch (prefix) {
      case '+':
      case '-':
         os.write("</div>".getBytes());
         break;
      default:
         os.write("</td>".getBytes());
      }
      os.write("</tr>\n".getBytes());
   }
   /**
    * Workaround function for complex private methods in DiffFormatter. This
    * sets the html for the diff headers.
    *
    * @return
    */
   public String getHtml() {
      ByteArrayOutputStream bos = (ByteArrayOutputStream) os;
      String html = RawParseUtils.decode(bos.toByteArray());
      String[] lines = html.split("\n");
      StringBuilder sb = new StringBuilder();
      boolean inFile = false;
      String oldnull = "a/dev/null";
      for (String line : lines) {
         if (line.startsWith("index")) {
            // skip index lines
         } else if (line.startsWith("new file")) {
            // skip new file lines
         } else if (line.startsWith("\\ No newline")) {
            // skip no new line
         } else if (line.startsWith("---") || line.startsWith("+++")) {
            // skip --- +++ lines
         } else if (line.startsWith("diff")) {
            line = StringUtils.convertOctal(line);
            if (line.indexOf(oldnull) > -1) {
               // a is null, use b
               line = line.substring(("diff --git " + oldnull).length()).trim();
               // trim b/
               line = line.substring(2).trim();
            } else {
               // use a
               line = line.substring("diff --git ".length()).trim();
               line = line.substring(line.startsWith("\"a/") ? 3 : 2);
               line = line.substring(0, line.indexOf(" b/") > -1 ? line.indexOf(" b/") : line.indexOf("\"b/")).trim();
            }
            if (line.charAt(0) == '"') {
               line = line.substring(1);
            }
            if (line.charAt(line.length() - 1) == '"') {
               line = line.substring(0, line.length() - 1);
            }
            if (inFile) {
               sb.append("</tbody></table></div>\n");
               inFile = false;
            }
            sb.append(MessageFormat.format("<div class='header'><div class=\"diffHeader\" id=\"{0}\"><i class=\"icon-file\"></i> ", line)).append(line).append("</div></div>");
            sb.append("<div class=\"diff\">");
            sb.append("<table><tbody>");
            inFile = true;
         } else {
            boolean gitLinkDiff = line.length() > 0 && line.substring(1).startsWith("Subproject commit");
            if (gitLinkDiff) {
               sb.append("<tr><th></th><th></th>");
               if (line.charAt(0) == '+') {
                  sb.append("<td><div class=\"diff add2\">");
               } else {
                  sb.append("<td><div class=\"diff remove2\">");
               }
            }
            sb.append(line);
            if (gitLinkDiff) {
               sb.append("</div></td></tr>");
            }
         }
      }
      sb.append("</table></div>");
      return sb.toString();
   }
   public DiffStat getDiffStat() {
      return diffStat;
   }
}
/*
 * Copyright 2011 gitblit.com.
 *
 * 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.gitblit.utils;
import static org.eclipse.jgit.lib.Constants.encode;
import static org.eclipse.jgit.lib.Constants.encodeASCII;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.wicket.Application;
import org.apache.wicket.Localizer;
import org.eclipse.jgit.diff.DiffEntry;
import org.eclipse.jgit.diff.DiffEntry.ChangeType;
import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.diff.RawText;
import org.eclipse.jgit.util.RawParseUtils;
import com.gitblit.models.PathModel.PathChangeModel;
import com.gitblit.utils.DiffUtils.BinaryDiffHandler;
import com.gitblit.utils.DiffUtils.DiffStat;
import com.gitblit.wicket.GitBlitWebApp;
/**
 * Generates an html snippet of a diff in Gitblit's style, tracks changed paths, and calculates diff stats.
 *
 * @author James Moger
 * @author Tom <tw201207@gmail.com>
 *
 */
public class GitBlitDiffFormatter extends DiffFormatter {
   /** Regex pattern identifying trailing whitespace. */
   private static final Pattern trailingWhitespace = Pattern.compile("(\\s+?)\r?\n?$");
   /**
    * gitblit.properties key for the per-file limit on the number of diff lines.
    */
   private static final String DIFF_LIMIT_PER_FILE_KEY = "web.maxDiffLinesPerFile";
   /**
    * gitblit.properties key for the global limit on the number of diff lines in a commitdiff.
    */
   private static final String GLOBAL_DIFF_LIMIT_KEY = "web.maxDiffLines";
   /**
    * Diffs with more lines are not shown in commitdiffs. (Similar to what GitHub does.) Can be reduced
    * (but not increased) through gitblit.properties key {@link #DIFF_LIMIT_PER_FILE_KEY}.
    */
   private static final int DIFF_LIMIT_PER_FILE = 4000;
   /**
    * Global diff limit. Commitdiffs with more lines are truncated. Can be reduced (but not increased)
    * through gitblit.properties key {@link #GLOBAL_DIFF_LIMIT_KEY}.
    */
   private static final int GLOBAL_DIFF_LIMIT = 20000;
   private static final boolean CONVERT_TABS = true;
   private final DiffOutputStream os;
   private final DiffStat diffStat;
   private PathChangeModel currentPath;
   private int left, right;
   /**
    * If a single file diff in a commitdiff produces more than this number of lines, we don't display
    * the diff. First, it's too taxing on the browser: it'll spend an awful lot of time applying the
    * CSS rules (despite my having optimized them). And second, no human can read a diff with thousands
    * of lines and make sense of it.
    * <p>
    * Set to {@link #DIFF_LIMIT_PER_FILE} for commitdiffs, and to -1 (switches off the limit) for
    * single-file diffs.
    * </p>
    */
   private final int maxDiffLinesPerFile;
   /**
    * Global limit on the number of diff lines. Set to {@link #GLOBAL_DIFF_LIMIT} for commitdiffs, and
    * to -1 (switched off the limit) for single-file diffs.
    */
   private final int globalDiffLimit;
   /** Number of lines for the current file diff. Set to zero when a new DiffEntry is started. */
   private int nofLinesCurrent;
   /**
    * Position in the stream when we try to write the first line. Used to rewind when we detect that
    * the diff is too large.
    */
   private int startCurrent;
   /** Flag set to true when we rewind. Reset to false when we start a new DiffEntry. */
   private boolean isOff;
   /** The current diff entry. */
   private DiffEntry entry;
   // Global limit stuff.
   /** Total number of lines written before the current diff entry. */
   private int totalNofLinesPrevious;
   /** Running total of the number of diff lines written. Updated until we exceed the global limit. */
   private int totalNofLinesCurrent;
   /** Stream position to reset to if we decided to truncate the commitdiff. */
   private int truncateTo;
   /** Whether we decided to truncate the commitdiff. */
   private boolean truncated;
   /** If {@link #truncated}, contains all entries skipped. */
   private final List<DiffEntry> skipped = new ArrayList<DiffEntry>();
   private int tabLength;
   /**
    * A {@link ResettableByteArrayOutputStream} that intercept the "Binary files differ" message produced
    * by the super implementation. Unfortunately the super implementation has far too many things private;
    * otherwise we'd just have re-implemented {@link GitBlitDiffFormatter#format(DiffEntry) format(DiffEntry)}
    * completely without ever calling the super implementation.
    */
   private static class DiffOutputStream extends ResettableByteArrayOutputStream {
      private static final String BINARY_DIFFERENCE = "Binary files differ\n";
      private GitBlitDiffFormatter formatter;
      private BinaryDiffHandler binaryDiffHandler;
      public void setFormatter(GitBlitDiffFormatter formatter, BinaryDiffHandler handler) {
         this.formatter = formatter;
         this.binaryDiffHandler = handler;
      }
      @Override
      public void write(byte[] b, int offset, int length) {
         if (binaryDiffHandler != null
               && RawParseUtils.decode(Arrays.copyOfRange(b, offset, offset + length)).contains(BINARY_DIFFERENCE))
         {
            String binaryDiff = binaryDiffHandler.renderBinaryDiff(formatter.entry);
            if (binaryDiff != null) {
               byte[] bb = ("<tr><td colspan='4' align='center'>" + binaryDiff + "</td></tr>").getBytes(StandardCharsets.UTF_8);
               super.write(bb, 0, bb.length);
               return;
            }
         }
         super.write(b, offset, length);
      }
   }
   public GitBlitDiffFormatter(String commitId, String path, BinaryDiffHandler handler, int tabLength) {
      super(new DiffOutputStream());
      this.os = (DiffOutputStream) getOutputStream();
      this.os.setFormatter(this, handler);
      this.diffStat = new DiffStat(commitId);
      this.tabLength = tabLength;
      // If we have a full commitdiff, install maxima to avoid generating a super-long diff listing that
      // will only tax the browser too much.
      maxDiffLinesPerFile = path != null ? -1 : getLimit(DIFF_LIMIT_PER_FILE_KEY, 500, DIFF_LIMIT_PER_FILE);
      globalDiffLimit = path != null ? -1 : getLimit(GLOBAL_DIFF_LIMIT_KEY, 1000, GLOBAL_DIFF_LIMIT);
   }
   /**
    * Determines a limit to use for HTML diff output.
    *
    * @param key
    *            to use to read the value from the GitBlit settings, if available.
    * @param minimum
    *            minimum value to enforce
    * @param maximum
    *            maximum (and default) value to enforce
    * @return the limit
    */
   private int getLimit(String key, int minimum, int maximum) {
      if (Application.exists()) {
         Application application = Application.get();
         if (application instanceof GitBlitWebApp) {
            GitBlitWebApp webApp = (GitBlitWebApp) application;
            int configValue = webApp.settings().getInteger(key, maximum);
            if (configValue < minimum) {
               return minimum;
            } else if (configValue < maximum) {
               return configValue;
            }
         }
      }
      return maximum;
   }
   /**
    * Returns a localized message string, if there is a localization; otherwise the given default value.
    *
    * @param key
    *            message key for the message
    * @param defaultValue
    *            to use if no localization for the message can be found
    * @return the possibly localized message
    */
   private String getMsg(String key, String defaultValue) {
      if (Application.exists()) {
         Localizer localizer = Application.get().getResourceSettings().getLocalizer();
         if (localizer != null) {
            // Use getStringIgnoreSettings because we don't want exceptions here if the key is missing!
            return localizer.getStringIgnoreSettings(key, null, null, defaultValue);
         }
      }
      return defaultValue;
   }
   @Override
   public void format(DiffEntry ent) throws IOException {
      currentPath = diffStat.addPath(ent);
      nofLinesCurrent = 0;
      isOff = false;
      entry = ent;
      if (!truncated) {
         totalNofLinesPrevious = totalNofLinesCurrent;
         if (globalDiffLimit > 0 && totalNofLinesPrevious > globalDiffLimit) {
            truncated = true;
            isOff = true;
         }
         truncateTo = os.size();
      } else {
         isOff = true;
      }
      if (truncated) {
         skipped.add(ent);
      } else {
         // Produce a header here and now
         String path;
         String id;
         if (ChangeType.DELETE.equals(ent.getChangeType())) {
            path = ent.getOldPath();
            id = ent.getOldId().name();
         } else {
            path = ent.getNewPath();
            id = ent.getNewId().name();
         }
         StringBuilder sb = new StringBuilder(MessageFormat.format("<div class='header'><div class=\"diffHeader\" id=\"n{0}\"><i class=\"icon-file\"></i> ", id));
         sb.append(StringUtils.escapeForHtml(path, false)).append("</div></div>");
         sb.append("<div class=\"diff\"><table cellpadding='0'><tbody>\n");
         os.write(sb.toString().getBytes());
      }
      // Keep formatting, but if off, don't produce anything anymore. We just keep on counting.
      super.format(ent);
      if (!truncated) {
         // Close the table
         os.write("</tbody></table></div>\n".getBytes());
      }
   }
   @Override
   public void flush() throws IOException {
      if (truncated) {
         os.resetTo(truncateTo);
      }
      super.flush();
   }
   /**
    * Rewind and issue a message that the diff is too large.
    */
   private void reset() {
      if (!isOff) {
         os.resetTo(startCurrent);
         writeFullWidthLine(getMsg("gb.diffFileDiffTooLarge", "Diff too large"));
         totalNofLinesCurrent = totalNofLinesPrevious;
         isOff = true;
      }
   }
   /**
    * Writes an initial table row containing information about added/removed/renamed/copied files. In case
    * of a deletion, we also suppress generating the diff; it's not interesting. (All lines removed.)
    */
   private void handleChange() {
      // XXX Would be nice if we could generate blob links for the cases handled here. Alas, we lack the repo
      // name, and cannot reliably determine it here. We could get the .git directory of a Repository, if we
      // passed in the repo, and then take the name of the parent directory, but that'd fail for repos nested
      // in GitBlit projects. And we don't know if the repo is inside a project or is a top-level repo.
      //
      // That's certainly solvable (just pass along more information), but would require a larger rewrite than
      // I'm prepared to do now.
      String message;
      switch (entry.getChangeType()) {
      case ADD:
         message = getMsg("gb.diffNewFile", "New file");
         break;
      case DELETE:
         message = getMsg("gb.diffDeletedFile", "File was deleted");
         isOff = true;
         break;
      case RENAME:
         message = MessageFormat.format(getMsg("gb.diffRenamedFile", "File was renamed from {0}"), entry.getOldPath());
         break;
      case COPY:
         message = MessageFormat.format(getMsg("gb.diffCopiedFile", "File was copied from {0}"), entry.getOldPath());
         break;
      default:
         return;
      }
      writeFullWidthLine(message);
   }
   /**
    * Output a hunk header
    *
    * @param aStartLine
    *            within first source
    * @param aEndLine
    *            within first source
    * @param bStartLine
    *            within second source
    * @param bEndLine
    *            within second source
    * @throws IOException
    */
   @Override
   protected void writeHunkHeader(int aStartLine, int aEndLine, int bStartLine, int bEndLine) throws IOException {
      if (nofLinesCurrent++ == 0) {
         handleChange();
         startCurrent = os.size();
      }
      if (!isOff) {
         totalNofLinesCurrent++;
         if (nofLinesCurrent > maxDiffLinesPerFile && maxDiffLinesPerFile > 0) {
            reset();
         } else {
            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'>"
                  .getBytes());
            os.write('@');
            os.write('@');
            writeRange('-', aStartLine + 1, aEndLine - aStartLine);
            writeRange('+', bStartLine + 1, bEndLine - bStartLine);
            os.write(' ');
            os.write('@');
            os.write('@');
            os.write("</td></tr>\n".getBytes());
         }
      }
      left = aStartLine + 1;
      right = bStartLine + 1;
   }
   protected void writeRange(final char prefix, final int begin, final int cnt) throws IOException {
      os.write(' ');
      os.write(prefix);
      switch (cnt) {
      case 0:
         // If the range is empty, its beginning number must be the
         // line just before the range, or 0 if the range is at the
         // start of the file stream. Here, begin is always 1 based,
         // so an empty file would produce "0,0".
         //
         os.write(encodeASCII(begin - 1));
         os.write(',');
         os.write('0');
         break;
      case 1:
         // If the range is exactly one line, produce only the number.
         //
         os.write(encodeASCII(begin));
         break;
      default:
         os.write(encodeASCII(begin));
         os.write(',');
         os.write(encodeASCII(cnt));
         break;
      }
   }
   /**
    * Writes a line spanning the full width of the code view, including the gutter.
    *
    * @param text
    *            to put on that line; will be HTML-escaped.
    */
   private void writeFullWidthLine(String text) {
      try {
         os.write("<tr><td class='diff-cell' colspan='4'>".getBytes());
         os.write(StringUtils.escapeForHtml(text, false).getBytes());
         os.write("</td></tr>\n".getBytes());
      } catch (IOException ex) {
         // Cannot happen with a ByteArrayOutputStream
      }
   }
   @Override
   protected void writeLine(final char prefix, final RawText text, final int cur) throws IOException {
      if (nofLinesCurrent++ == 0) {
         handleChange();
         startCurrent = os.size();
      }
      // update entry diffstat
      currentPath.update(prefix);
      if (isOff) {
         return;
      }
      totalNofLinesCurrent++;
      if (nofLinesCurrent > maxDiffLinesPerFile && maxDiffLinesPerFile > 0) {
         reset();
      } else {
         // output diff
         os.write("<tr>".getBytes());
         switch (prefix) {
         case '+':
            os.write(("<th class='diff-line'></th><th class='diff-line' data-lineno='" + (right++) + "'></th>").getBytes());
            os.write("<th class='diff-state diff-state-add'></th>".getBytes());
            os.write("<td class='diff-cell add2'>".getBytes());
            break;
         case '-':
            os.write(("<th class='diff-line' data-lineno='" + (left++) + "'></th><th class='diff-line'></th>").getBytes());
            os.write("<th class='diff-state diff-state-sub'></th>".getBytes());
            os.write("<td class='diff-cell remove2'>".getBytes());
            break;
         default:
            os.write(("<th class='diff-line' data-lineno='" + (left++) + "'></th><th class='diff-line' data-lineno='" + (right++) + "'></th>").getBytes());
            os.write("<th class='diff-state'></th>".getBytes());
            os.write("<td class='diff-cell context2'>".getBytes());
            break;
         }
         os.write(encode(codeLineToHtml(prefix, text.getString(cur))));
         os.write("</td></tr>\n".getBytes());
      }
   }
   /**
    * Convert the given code line to HTML.
    *
    * @param prefix
    *            the diff prefix (+/-) indicating whether the line was added or removed.
    * @param line
    *            the line to format as HTML
    * @return the HTML-formatted line, safe for inserting as is into HTML.
    */
   private String codeLineToHtml(final char prefix, final String line) {
      if ((prefix == '+' || prefix == '-')) {
         // Highlight trailing whitespace on deleted/added lines.
         Matcher matcher = trailingWhitespace.matcher(line);
         if (matcher.find()) {
            StringBuilder result = new StringBuilder(StringUtils.escapeForHtml(line.substring(0, matcher.start()), CONVERT_TABS, tabLength));
            result.append("<span class='trailingws-").append(prefix == '+' ? "add" : "sub").append("'>");
            result.append(StringUtils.escapeForHtml(matcher.group(1), false));
            result.append("</span>");
            return result.toString();
         }
      }
      return StringUtils.escapeForHtml(line, CONVERT_TABS, tabLength);
   }
   /**
    * Workaround function for complex private methods in DiffFormatter. This sets the html for the diff headers.
    *
    * @return
    */
   public String getHtml() {
      String html = RawParseUtils.decode(os.toByteArray());
      String[] lines = html.split("\n");
      StringBuilder sb = new StringBuilder();
      for (String line : lines) {
         if (line.startsWith("index") || line.startsWith("similarity")
               || line.startsWith("rename from ") || line.startsWith("rename to ")) {
            // skip index lines
         } else if (line.startsWith("new file") || line.startsWith("deleted file")) {
            // skip new file lines
         } else if (line.startsWith("\\ No newline")) {
            // skip no new line
         } else if (line.startsWith("---") || line.startsWith("+++")) {
            // skip --- +++ lines
         } else if (line.startsWith("diff")) {
            // skip diff lines
         } else {
            boolean gitLinkDiff = line.length() > 0 && line.substring(1).startsWith("Subproject commit");
            if (gitLinkDiff) {
               sb.append("<tr><th class='diff-line'></th><th class='diff-line'></th>");
               if (line.charAt(0) == '+') {
                  sb.append("<th class='diff-state diff-state-add'></th><td class=\"diff-cell add2\">");
               } else {
                  sb.append("<th class='diff-state diff-state-sub'></th><td class=\"diff-cell remove2\">");
               }
               line = StringUtils.escapeForHtml(line.substring(1), CONVERT_TABS, tabLength);
            }
            sb.append(line);
            if (gitLinkDiff) {
               sb.append("</td></tr>");
            }
            sb.append('\n');
         }
      }
      if (truncated) {
         sb.append(MessageFormat.format("<div class='header'><div class='diffHeader'>{0}</div></div>",
               StringUtils.escapeForHtml(getMsg("gb.diffTruncated", "Diff truncated after the above file"), false)));
         // List all files not shown. We can be sure we do have at least one path in skipped.
         sb.append("<div class='diff'><table cellpadding='0'><tbody><tr><td class='diff-cell' colspan='4'>");
         String deletedSuffix = StringUtils.escapeForHtml(getMsg("gb.diffDeletedFileSkipped", "(deleted)"), false);
         boolean first = true;
         for (DiffEntry entry : skipped) {
            if (!first) {
               sb.append('\n');
            }
            if (ChangeType.DELETE.equals(entry.getChangeType())) {
               sb.append("<span id=\"n" + entry.getOldId().name() + "\">" + StringUtils.escapeForHtml(entry.getOldPath(), false) + ' ' + deletedSuffix + "</span>");
            } else {
               sb.append("<span id=\"n" + entry.getNewId().name() + "\">" + StringUtils.escapeForHtml(entry.getNewPath(), false) + "</span>");
            }
            first = false;
         }
         skipped.clear();
         sb.append("</td></tr></tbody></table></div>");
      }
      return sb.toString();
   }
   public DiffStat getDiffStat() {
      return diffStat;
   }
}