ChangeIdUtil.java

  1. /*
  2.  * Copyright (C) 2010, Robin Rosenberg and others
  3.  *
  4.  * This program and the accompanying materials are made available under the
  5.  * terms of the Eclipse Distribution License v. 1.0 which is available at
  6.  * https://www.eclipse.org/org/documents/edl-v10.php.
  7.  *
  8.  * SPDX-License-Identifier: BSD-3-Clause
  9.  */
  10. package org.eclipse.jgit.util;

  11. import java.util.regex.Pattern;

  12. import org.eclipse.jgit.lib.Constants;
  13. import org.eclipse.jgit.lib.ObjectId;
  14. import org.eclipse.jgit.lib.ObjectInserter;
  15. import org.eclipse.jgit.lib.PersonIdent;

  16. /**
  17.  * Utilities for creating and working with Change-Id's, like the one used by
  18.  * Gerrit Code Review.
  19.  * <p>
  20.  * A Change-Id is a SHA-1 computed from the content of a commit, in a similar
  21.  * fashion to how the commit id is computed. Unlike the commit id a Change-Id is
  22.  * retained in the commit and subsequent revised commits in the footer of the
  23.  * commit text.
  24.  */
  25. public class ChangeIdUtil {

  26.     static final String CHANGE_ID = "Change-Id:"; //$NON-NLS-1$

  27.     // package-private so the unit test can test this part only
  28.     @SuppressWarnings("nls")
  29.     static String clean(String msg) {
  30.         return msg.//
  31.                 replaceAll("(?i)(?m)^Signed-off-by:.*$\n?", "").// //$NON-NLS-1$
  32.                 replaceAll("(?m)^#.*$\n?", "").// //$NON-NLS-1$
  33.                 replaceAll("(?m)\n\n\n+", "\\\n").// //$NON-NLS-1$
  34.                 replaceAll("\\n*$", "").// //$NON-NLS-1$
  35.                 replaceAll("(?s)\ndiff --git.*", "").// //$NON-NLS-1$
  36.                 trim();
  37.     }

  38.     /**
  39.      * Compute a Change-Id.
  40.      *
  41.      * @param treeId
  42.      *            The id of the tree that would be committed
  43.      * @param firstParentId
  44.      *            parent id of previous commit or null
  45.      * @param author
  46.      *            the {@link org.eclipse.jgit.lib.PersonIdent} for the presumed
  47.      *            author and time
  48.      * @param committer
  49.      *            the {@link org.eclipse.jgit.lib.PersonIdent} for the presumed
  50.      *            committer and time
  51.      * @param message
  52.      *            The commit message
  53.      * @return the change id SHA1 string (without the 'I') or null if the
  54.      *         message is not complete enough
  55.      */
  56.     public static ObjectId computeChangeId(final ObjectId treeId,
  57.             final ObjectId firstParentId, final PersonIdent author,
  58.             final PersonIdent committer, final String message) {
  59.         String cleanMessage = clean(message);
  60.         if (cleanMessage.length() == 0)
  61.             return null;
  62.         StringBuilder b = new StringBuilder();
  63.         b.append("tree "); //$NON-NLS-1$
  64.         b.append(ObjectId.toString(treeId));
  65.         b.append("\n"); //$NON-NLS-1$
  66.         if (firstParentId != null) {
  67.             b.append("parent "); //$NON-NLS-1$
  68.             b.append(ObjectId.toString(firstParentId));
  69.             b.append("\n"); //$NON-NLS-1$
  70.         }
  71.         b.append("author "); //$NON-NLS-1$
  72.         b.append(author.toExternalString());
  73.         b.append("\n"); //$NON-NLS-1$
  74.         b.append("committer "); //$NON-NLS-1$
  75.         b.append(committer.toExternalString());
  76.         b.append("\n\n"); //$NON-NLS-1$
  77.         b.append(cleanMessage);
  78.         try (ObjectInserter f = new ObjectInserter.Formatter()) {
  79.             return f.idFor(Constants.OBJ_COMMIT, Constants.encode(b.toString()));
  80.         }
  81.     }

  82.     private static final Pattern issuePattern = Pattern
  83.             .compile("^(Bug|Issue)[a-zA-Z0-9-]*:.*$"); //$NON-NLS-1$

  84.     private static final Pattern footerPattern = Pattern
  85.             .compile("(^[a-zA-Z0-9-]+:(?!//).*$)"); //$NON-NLS-1$

  86.     private static final Pattern changeIdPattern = Pattern
  87.             .compile("(^" + CHANGE_ID + " *I[a-f0-9]{40}$)"); //$NON-NLS-1$ //$NON-NLS-2$

  88.     private static final Pattern includeInFooterPattern = Pattern
  89.             .compile("^[ \\[].*$"); //$NON-NLS-1$

  90.     private static final Pattern trailingWhitespace = Pattern.compile("\\s+$"); //$NON-NLS-1$

  91.     /**
  92.      * Find the right place to insert a Change-Id and return it.
  93.      * <p>
  94.      * The Change-Id is inserted before the first footer line but after a Bug
  95.      * line.
  96.      *
  97.      * @param message
  98.      *            a message.
  99.      * @param changeId
  100.      *            a Change-Id.
  101.      * @return a commit message with an inserted Change-Id line
  102.      */
  103.     public static String insertId(String message, ObjectId changeId) {
  104.         return insertId(message, changeId, false);
  105.     }

  106.     /**
  107.      * Find the right place to insert a Change-Id and return it.
  108.      * <p>
  109.      * If no Change-Id is found the Change-Id is inserted before the first
  110.      * footer line but after a Bug line.
  111.      *
  112.      * If Change-Id is found and replaceExisting is set to false, the message is
  113.      * unchanged.
  114.      *
  115.      * If Change-Id is found and replaceExisting is set to true, the Change-Id
  116.      * is replaced with {@code changeId}.
  117.      *
  118.      * @param message
  119.      *            a message.
  120.      * @param changeId
  121.      *            a Change-Id.
  122.      * @param replaceExisting
  123.      *            a boolean.
  124.      * @return a commit message with an inserted Change-Id line
  125.      */
  126.     public static String insertId(String message, ObjectId changeId,
  127.             boolean replaceExisting) {
  128.         int indexOfChangeId = indexOfChangeId(message, "\n"); //$NON-NLS-1$
  129.         if (indexOfChangeId > 0) {
  130.             if (!replaceExisting) {
  131.                 return message;
  132.             }
  133.             StringBuilder ret = new StringBuilder(
  134.                     message.substring(0, indexOfChangeId));
  135.             ret.append(CHANGE_ID);
  136.             ret.append(" I"); //$NON-NLS-1$
  137.             ret.append(ObjectId.toString(changeId));
  138.             int indexOfNextLineBreak = message.indexOf('\n',
  139.                     indexOfChangeId);
  140.             if (indexOfNextLineBreak > 0)
  141.                 ret.append(message.substring(indexOfNextLineBreak));
  142.             return ret.toString();
  143.         }

  144.         String[] lines = message.split("\n"); //$NON-NLS-1$
  145.         int footerFirstLine = indexOfFirstFooterLine(lines);
  146.         int insertAfter = footerFirstLine;
  147.         for (int i = footerFirstLine; i < lines.length; ++i) {
  148.             if (issuePattern.matcher(lines[i]).matches()) {
  149.                 insertAfter = i + 1;
  150.                 continue;
  151.             }
  152.             break;
  153.         }
  154.         StringBuilder ret = new StringBuilder();
  155.         int i = 0;
  156.         for (; i < insertAfter; ++i) {
  157.             ret.append(lines[i]);
  158.             ret.append("\n"); //$NON-NLS-1$
  159.         }
  160.         if (insertAfter == lines.length && insertAfter == footerFirstLine)
  161.             ret.append("\n"); //$NON-NLS-1$
  162.         ret.append(CHANGE_ID);
  163.         ret.append(" I"); //$NON-NLS-1$
  164.         ret.append(ObjectId.toString(changeId));
  165.         ret.append("\n"); //$NON-NLS-1$
  166.         for (; i < lines.length; ++i) {
  167.             ret.append(lines[i]);
  168.             ret.append("\n"); //$NON-NLS-1$
  169.         }
  170.         return ret.toString();
  171.     }

  172.     /**
  173.      * Return the index in the String {@code message} where the Change-Id entry
  174.      * in the footer begins. If there are more than one entries matching the
  175.      * pattern, return the index of the last one in the last section. Because of
  176.      * Bug: 400818 we release the constraint here that a footer must contain
  177.      * only lines matching {@code footerPattern}.
  178.      *
  179.      * @param message
  180.      *            a message.
  181.      * @param delimiter
  182.      *            the line delimiter, like "\n" or "\r\n", needed to find the
  183.      *            footer
  184.      * @return the index of the ChangeId footer in the message, or -1 if no
  185.      *         ChangeId footer available
  186.      */
  187.     public static int indexOfChangeId(String message, String delimiter) {
  188.         String[] lines = message.split(delimiter);
  189.         if (lines.length == 0)
  190.             return -1;
  191.         int indexOfChangeIdLine = 0;
  192.         boolean inFooter = false;
  193.         for (int i = lines.length - 1; i >= 0; --i) {
  194.             if (!inFooter && isEmptyLine(lines[i]))
  195.                 continue;
  196.             inFooter = true;
  197.             if (changeIdPattern.matcher(trimRight(lines[i])).matches()) {
  198.                 indexOfChangeIdLine = i;
  199.                 break;
  200.             } else if (isEmptyLine(lines[i]) || i == 0)
  201.                 return -1;
  202.         }
  203.         int indexOfChangeIdLineinString = 0;
  204.         for (int i = 0; i < indexOfChangeIdLine; ++i)
  205.             indexOfChangeIdLineinString += lines[i].length()
  206.                     + delimiter.length();
  207.         return indexOfChangeIdLineinString
  208.                 + lines[indexOfChangeIdLine].indexOf(CHANGE_ID);
  209.     }

  210.     private static boolean isEmptyLine(String line) {
  211.         return line.trim().length() == 0;
  212.     }

  213.     private static String trimRight(String s) {
  214.         return trailingWhitespace.matcher(s).replaceAll(""); //$NON-NLS-1$
  215.     }

  216.     /**
  217.      * Find the index of the first line of the footer paragraph in an array of
  218.      * the lines, or lines.length if no footer is available
  219.      *
  220.      * @param lines
  221.      *            the commit message split into lines and the line delimiters
  222.      *            stripped off
  223.      * @return the index of the first line of the footer paragraph, or
  224.      *         lines.length if no footer is available
  225.      */
  226.     public static int indexOfFirstFooterLine(String[] lines) {
  227.         int footerFirstLine = lines.length;
  228.         for (int i = lines.length - 1; i > 1; --i) {
  229.             if (footerPattern.matcher(lines[i]).matches()) {
  230.                 footerFirstLine = i;
  231.                 continue;
  232.             }
  233.             if (footerFirstLine != lines.length && lines[i].length() == 0)
  234.                 break;
  235.             if (footerFirstLine != lines.length
  236.                     && includeInFooterPattern.matcher(lines[i]).matches()) {
  237.                 footerFirstLine = i + 1;
  238.                 continue;
  239.             }
  240.             footerFirstLine = lines.length;
  241.             break;
  242.         }
  243.         return footerFirstLine;
  244.     }
  245. }