ChangeIdUtil.java
/*
* Copyright (C) 2010, Robin Rosenberg and others
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Distribution License v. 1.0 which is available at
* https://www.eclipse.org/org/documents/edl-v10.php.
*
* SPDX-License-Identifier: BSD-3-Clause
*/
package org.eclipse.jgit.util;
import java.util.regex.Pattern;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.PersonIdent;
/**
* Utilities for creating and working with Change-Id's, like the one used by
* Gerrit Code Review.
* <p>
* A Change-Id is a SHA-1 computed from the content of a commit, in a similar
* fashion to how the commit id is computed. Unlike the commit id a Change-Id is
* retained in the commit and subsequent revised commits in the footer of the
* commit text.
*/
public class ChangeIdUtil {
static final String CHANGE_ID = "Change-Id:"; //$NON-NLS-1$
// package-private so the unit test can test this part only
@SuppressWarnings("nls")
static String clean(String msg) {
return msg.//
replaceAll("(?i)(?m)^Signed-off-by:.*$\n?", "").// //$NON-NLS-1$
replaceAll("(?m)^#.*$\n?", "").// //$NON-NLS-1$
replaceAll("(?m)\n\n\n+", "\\\n").// //$NON-NLS-1$
replaceAll("\\n*$", "").// //$NON-NLS-1$
replaceAll("(?s)\ndiff --git.*", "").// //$NON-NLS-1$
trim();
}
/**
* Compute a Change-Id.
*
* @param treeId
* The id of the tree that would be committed
* @param firstParentId
* parent id of previous commit or null
* @param author
* the {@link org.eclipse.jgit.lib.PersonIdent} for the presumed
* author and time
* @param committer
* the {@link org.eclipse.jgit.lib.PersonIdent} for the presumed
* committer and time
* @param message
* The commit message
* @return the change id SHA1 string (without the 'I') or null if the
* message is not complete enough
*/
public static ObjectId computeChangeId(final ObjectId treeId,
final ObjectId firstParentId, final PersonIdent author,
final PersonIdent committer, final String message) {
String cleanMessage = clean(message);
if (cleanMessage.length() == 0)
return null;
StringBuilder b = new StringBuilder();
b.append("tree "); //$NON-NLS-1$
b.append(ObjectId.toString(treeId));
b.append("\n"); //$NON-NLS-1$
if (firstParentId != null) {
b.append("parent "); //$NON-NLS-1$
b.append(ObjectId.toString(firstParentId));
b.append("\n"); //$NON-NLS-1$
}
b.append("author "); //$NON-NLS-1$
b.append(author.toExternalString());
b.append("\n"); //$NON-NLS-1$
b.append("committer "); //$NON-NLS-1$
b.append(committer.toExternalString());
b.append("\n\n"); //$NON-NLS-1$
b.append(cleanMessage);
try (ObjectInserter f = new ObjectInserter.Formatter()) {
return f.idFor(Constants.OBJ_COMMIT, Constants.encode(b.toString()));
}
}
private static final Pattern issuePattern = Pattern
.compile("^(Bug|Issue)[a-zA-Z0-9-]*:.*$"); //$NON-NLS-1$
private static final Pattern footerPattern = Pattern
.compile("(^[a-zA-Z0-9-]+:(?!//).*$)"); //$NON-NLS-1$
private static final Pattern changeIdPattern = Pattern
.compile("(^" + CHANGE_ID + " *I[a-f0-9]{40}$)"); //$NON-NLS-1$ //$NON-NLS-2$
private static final Pattern includeInFooterPattern = Pattern
.compile("^[ \\[].*$"); //$NON-NLS-1$
private static final Pattern trailingWhitespace = Pattern.compile("\\s+$"); //$NON-NLS-1$
/**
* Find the right place to insert a Change-Id and return it.
* <p>
* The Change-Id is inserted before the first footer line but after a Bug
* line.
*
* @param message
* a message.
* @param changeId
* a Change-Id.
* @return a commit message with an inserted Change-Id line
*/
public static String insertId(String message, ObjectId changeId) {
return insertId(message, changeId, false);
}
/**
* Find the right place to insert a Change-Id and return it.
* <p>
* If no Change-Id is found the Change-Id is inserted before the first
* footer line but after a Bug line.
*
* If Change-Id is found and replaceExisting is set to false, the message is
* unchanged.
*
* If Change-Id is found and replaceExisting is set to true, the Change-Id
* is replaced with {@code changeId}.
*
* @param message
* a message.
* @param changeId
* a Change-Id.
* @param replaceExisting
* a boolean.
* @return a commit message with an inserted Change-Id line
*/
public static String insertId(String message, ObjectId changeId,
boolean replaceExisting) {
int indexOfChangeId = indexOfChangeId(message, "\n"); //$NON-NLS-1$
if (indexOfChangeId > 0) {
if (!replaceExisting) {
return message;
}
StringBuilder ret = new StringBuilder(
message.substring(0, indexOfChangeId));
ret.append(CHANGE_ID);
ret.append(" I"); //$NON-NLS-1$
ret.append(ObjectId.toString(changeId));
int indexOfNextLineBreak = message.indexOf('\n',
indexOfChangeId);
if (indexOfNextLineBreak > 0)
ret.append(message.substring(indexOfNextLineBreak));
return ret.toString();
}
String[] lines = message.split("\n"); //$NON-NLS-1$
int footerFirstLine = indexOfFirstFooterLine(lines);
int insertAfter = footerFirstLine;
for (int i = footerFirstLine; i < lines.length; ++i) {
if (issuePattern.matcher(lines[i]).matches()) {
insertAfter = i + 1;
continue;
}
break;
}
StringBuilder ret = new StringBuilder();
int i = 0;
for (; i < insertAfter; ++i) {
ret.append(lines[i]);
ret.append("\n"); //$NON-NLS-1$
}
if (insertAfter == lines.length && insertAfter == footerFirstLine)
ret.append("\n"); //$NON-NLS-1$
ret.append(CHANGE_ID);
ret.append(" I"); //$NON-NLS-1$
ret.append(ObjectId.toString(changeId));
ret.append("\n"); //$NON-NLS-1$
for (; i < lines.length; ++i) {
ret.append(lines[i]);
ret.append("\n"); //$NON-NLS-1$
}
return ret.toString();
}
/**
* Return the index in the String {@code message} where the Change-Id entry
* in the footer begins. If there are more than one entries matching the
* pattern, return the index of the last one in the last section. Because of
* Bug: 400818 we release the constraint here that a footer must contain
* only lines matching {@code footerPattern}.
*
* @param message
* a message.
* @param delimiter
* the line delimiter, like "\n" or "\r\n", needed to find the
* footer
* @return the index of the ChangeId footer in the message, or -1 if no
* ChangeId footer available
*/
public static int indexOfChangeId(String message, String delimiter) {
String[] lines = message.split(delimiter);
if (lines.length == 0)
return -1;
int indexOfChangeIdLine = 0;
boolean inFooter = false;
for (int i = lines.length - 1; i >= 0; --i) {
if (!inFooter && isEmptyLine(lines[i]))
continue;
inFooter = true;
if (changeIdPattern.matcher(trimRight(lines[i])).matches()) {
indexOfChangeIdLine = i;
break;
} else if (isEmptyLine(lines[i]) || i == 0)
return -1;
}
int indexOfChangeIdLineinString = 0;
for (int i = 0; i < indexOfChangeIdLine; ++i)
indexOfChangeIdLineinString += lines[i].length()
+ delimiter.length();
return indexOfChangeIdLineinString
+ lines[indexOfChangeIdLine].indexOf(CHANGE_ID);
}
private static boolean isEmptyLine(String line) {
return line.trim().length() == 0;
}
private static String trimRight(String s) {
return trailingWhitespace.matcher(s).replaceAll(""); //$NON-NLS-1$
}
/**
* Find the index of the first line of the footer paragraph in an array of
* the lines, or lines.length if no footer is available
*
* @param lines
* the commit message split into lines and the line delimiters
* stripped off
* @return the index of the first line of the footer paragraph, or
* lines.length if no footer is available
*/
public static int indexOfFirstFooterLine(String[] lines) {
int footerFirstLine = lines.length;
for (int i = lines.length - 1; i > 1; --i) {
if (footerPattern.matcher(lines[i]).matches()) {
footerFirstLine = i;
continue;
}
if (footerFirstLine != lines.length && lines[i].length() == 0)
break;
if (footerFirstLine != lines.length
&& includeInFooterPattern.matcher(lines[i]).matches()) {
footerFirstLine = i + 1;
continue;
}
footerFirstLine = lines.length;
break;
}
return footerFirstLine;
}
}