View Javadoc
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  
12  import java.util.regex.Pattern;
13  
14  import org.eclipse.jgit.lib.Constants;
15  import org.eclipse.jgit.lib.ObjectId;
16  import org.eclipse.jgit.lib.ObjectInserter;
17  import org.eclipse.jgit.lib.PersonIdent;
18  
19  /**
20   * Utilities for creating and working with Change-Id's, like the one used by
21   * Gerrit Code Review.
22   * <p>
23   * A Change-Id is a SHA-1 computed from the content of a commit, in a similar
24   * fashion to how the commit id is computed. Unlike the commit id a Change-Id is
25   * retained in the commit and subsequent revised commits in the footer of the
26   * commit text.
27   */
28  public class ChangeIdUtil {
29  
30  	static final String CHANGE_ID = "Change-Id:"; //$NON-NLS-1$
31  
32  	// package-private so the unit test can test this part only
33  	@SuppressWarnings("nls")
34  	static String clean(String msg) {
35  		return msg.//
36  				replaceAll("(?i)(?m)^Signed-off-by:.*$\n?", "").// //$NON-NLS-1$
37  				replaceAll("(?m)^#.*$\n?", "").// //$NON-NLS-1$
38  				replaceAll("(?m)\n\n\n+", "\\\n").// //$NON-NLS-1$
39  				replaceAll("\\n*$", "").// //$NON-NLS-1$
40  				replaceAll("(?s)\ndiff --git.*", "").// //$NON-NLS-1$
41  				trim();
42  	}
43  
44  	/**
45  	 * Compute a Change-Id.
46  	 *
47  	 * @param treeId
48  	 *            The id of the tree that would be committed
49  	 * @param firstParentId
50  	 *            parent id of previous commit or null
51  	 * @param author
52  	 *            the {@link org.eclipse.jgit.lib.PersonIdent} for the presumed
53  	 *            author and time
54  	 * @param committer
55  	 *            the {@link org.eclipse.jgit.lib.PersonIdent} for the presumed
56  	 *            committer and time
57  	 * @param message
58  	 *            The commit message
59  	 * @return the change id SHA1 string (without the 'I') or null if the
60  	 *         message is not complete enough
61  	 */
62  	public static ObjectId computeChangeId(final ObjectId treeId,
63  			final ObjectId firstParentId, final PersonIdent author,
64  			final PersonIdent committer, final String message) {
65  		String cleanMessage = clean(message);
66  		if (cleanMessage.length() == 0)
67  			return null;
68  		StringBuilder b = new StringBuilder();
69  		b.append("tree "); //$NON-NLS-1$
70  		b.append(ObjectId.toString(treeId));
71  		b.append("\n"); //$NON-NLS-1$
72  		if (firstParentId != null) {
73  			b.append("parent "); //$NON-NLS-1$
74  			b.append(ObjectId.toString(firstParentId));
75  			b.append("\n"); //$NON-NLS-1$
76  		}
77  		b.append("author "); //$NON-NLS-1$
78  		b.append(author.toExternalString());
79  		b.append("\n"); //$NON-NLS-1$
80  		b.append("committer "); //$NON-NLS-1$
81  		b.append(committer.toExternalString());
82  		b.append("\n\n"); //$NON-NLS-1$
83  		b.append(cleanMessage);
84  		try (ObjectInserter f = new ObjectInserter.Formatter()) {
85  			return f.idFor(Constants.OBJ_COMMIT, Constants.encode(b.toString()));
86  		}
87  	}
88  
89  	private static final Pattern issuePattern = Pattern
90  			.compile("^(Bug|Issue)[a-zA-Z0-9-]*:.*$"); //$NON-NLS-1$
91  
92  	private static final Pattern footerPattern = Pattern
93  			.compile("(^[a-zA-Z0-9-]+:(?!//).*$)"); //$NON-NLS-1$
94  
95  	private static final Pattern changeIdPattern = Pattern
96  			.compile("(^" + CHANGE_ID + " *I[a-f0-9]{40}$)"); //$NON-NLS-1$ //$NON-NLS-2$
97  
98  	private static final Pattern includeInFooterPattern = Pattern
99  			.compile("^[ \\[].*$"); //$NON-NLS-1$
100 
101 	private static final Pattern trailingWhitespace = Pattern.compile("\\s+$"); //$NON-NLS-1$
102 
103 	/**
104 	 * Find the right place to insert a Change-Id and return it.
105 	 * <p>
106 	 * The Change-Id is inserted before the first footer line but after a Bug
107 	 * line.
108 	 *
109 	 * @param message
110 	 *            a message.
111 	 * @param changeId
112 	 *            a Change-Id.
113 	 * @return a commit message with an inserted Change-Id line
114 	 */
115 	public static String insertId(String message, ObjectId changeId) {
116 		return insertId(message, changeId, false);
117 	}
118 
119 	/**
120 	 * Find the right place to insert a Change-Id and return it.
121 	 * <p>
122 	 * If no Change-Id is found the Change-Id is inserted before the first
123 	 * footer line but after a Bug line.
124 	 *
125 	 * If Change-Id is found and replaceExisting is set to false, the message is
126 	 * unchanged.
127 	 *
128 	 * If Change-Id is found and replaceExisting is set to true, the Change-Id
129 	 * is replaced with {@code changeId}.
130 	 *
131 	 * @param message
132 	 *            a message.
133 	 * @param changeId
134 	 *            a Change-Id.
135 	 * @param replaceExisting
136 	 *            a boolean.
137 	 * @return a commit message with an inserted Change-Id line
138 	 */
139 	public static String insertId(String message, ObjectId changeId,
140 			boolean replaceExisting) {
141 		int indexOfChangeId = indexOfChangeId(message, "\n"); //$NON-NLS-1$
142 		if (indexOfChangeId > 0) {
143 			if (!replaceExisting) {
144 				return message;
145 			}
146 			StringBuilder ret = new StringBuilder(
147 					message.substring(0, indexOfChangeId));
148 			ret.append(CHANGE_ID);
149 			ret.append(" I"); //$NON-NLS-1$
150 			ret.append(ObjectId.toString(changeId));
151 			int indexOfNextLineBreak = message.indexOf('\n',
152 					indexOfChangeId);
153 			if (indexOfNextLineBreak > 0)
154 				ret.append(message.substring(indexOfNextLineBreak));
155 			return ret.toString();
156 		}
157 
158 		String[] lines = message.split("\n"); //$NON-NLS-1$
159 		int footerFirstLine = indexOfFirstFooterLine(lines);
160 		int insertAfter = footerFirstLine;
161 		for (int i = footerFirstLine; i < lines.length; ++i) {
162 			if (issuePattern.matcher(lines[i]).matches()) {
163 				insertAfter = i + 1;
164 				continue;
165 			}
166 			break;
167 		}
168 		StringBuilder ret = new StringBuilder();
169 		int i = 0;
170 		for (; i < insertAfter; ++i) {
171 			ret.append(lines[i]);
172 			ret.append("\n"); //$NON-NLS-1$
173 		}
174 		if (insertAfter == lines.length && insertAfter == footerFirstLine)
175 			ret.append("\n"); //$NON-NLS-1$
176 		ret.append(CHANGE_ID);
177 		ret.append(" I"); //$NON-NLS-1$
178 		ret.append(ObjectId.toString(changeId));
179 		ret.append("\n"); //$NON-NLS-1$
180 		for (; i < lines.length; ++i) {
181 			ret.append(lines[i]);
182 			ret.append("\n"); //$NON-NLS-1$
183 		}
184 		return ret.toString();
185 	}
186 
187 	/**
188 	 * Return the index in the String {@code message} where the Change-Id entry
189 	 * in the footer begins. If there are more than one entries matching the
190 	 * pattern, return the index of the last one in the last section. Because of
191 	 * Bug: 400818 we release the constraint here that a footer must contain
192 	 * only lines matching {@code footerPattern}.
193 	 *
194 	 * @param message
195 	 *            a message.
196 	 * @param delimiter
197 	 *            the line delimiter, like "\n" or "\r\n", needed to find the
198 	 *            footer
199 	 * @return the index of the ChangeId footer in the message, or -1 if no
200 	 *         ChangeId footer available
201 	 */
202 	public static int indexOfChangeId(String message, String delimiter) {
203 		String[] lines = message.split(delimiter);
204 		if (lines.length == 0)
205 			return -1;
206 		int indexOfChangeIdLine = 0;
207 		boolean inFooter = false;
208 		for (int i = lines.length - 1; i >= 0; --i) {
209 			if (!inFooter && isEmptyLine(lines[i]))
210 				continue;
211 			inFooter = true;
212 			if (changeIdPattern.matcher(trimRight(lines[i])).matches()) {
213 				indexOfChangeIdLine = i;
214 				break;
215 			} else if (isEmptyLine(lines[i]) || i == 0)
216 				return -1;
217 		}
218 		int indexOfChangeIdLineinString = 0;
219 		for (int i = 0; i < indexOfChangeIdLine; ++i)
220 			indexOfChangeIdLineinString += lines[i].length()
221 					+ delimiter.length();
222 		return indexOfChangeIdLineinString
223 				+ lines[indexOfChangeIdLine].indexOf(CHANGE_ID);
224 	}
225 
226 	private static boolean isEmptyLine(String line) {
227 		return line.trim().length() == 0;
228 	}
229 
230 	private static String trimRight(String s) {
231 		return trailingWhitespace.matcher(s).replaceAll(""); //$NON-NLS-1$
232 	}
233 
234 	/**
235 	 * Find the index of the first line of the footer paragraph in an array of
236 	 * the lines, or lines.length if no footer is available
237 	 *
238 	 * @param lines
239 	 *            the commit message split into lines and the line delimiters
240 	 *            stripped off
241 	 * @return the index of the first line of the footer paragraph, or
242 	 *         lines.length if no footer is available
243 	 */
244 	public static int indexOfFirstFooterLine(String[] lines) {
245 		int footerFirstLine = lines.length;
246 		for (int i = lines.length - 1; i > 1; --i) {
247 			if (footerPattern.matcher(lines[i]).matches()) {
248 				footerFirstLine = i;
249 				continue;
250 			}
251 			if (footerFirstLine != lines.length && lines[i].length() == 0)
252 				break;
253 			if (footerFirstLine != lines.length
254 					&& includeInFooterPattern.matcher(lines[i]).matches()) {
255 				footerFirstLine = i + 1;
256 				continue;
257 			}
258 			footerFirstLine = lines.length;
259 			break;
260 		}
261 		return footerFirstLine;
262 	}
263 }