1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 package com.puppycrawl.tools.checkstyle.internal;
21
22 import static com.google.common.truth.Truth.assertWithMessage;
23
24 import java.io.IOException;
25 import java.util.Collections;
26 import java.util.Iterator;
27 import java.util.LinkedList;
28 import java.util.List;
29 import java.util.Spliterator;
30 import java.util.Spliterators;
31 import java.util.regex.Pattern;
32 import java.util.stream.Collectors;
33 import java.util.stream.StreamSupport;
34
35 import org.eclipse.jgit.api.Git;
36 import org.eclipse.jgit.api.errors.GitAPIException;
37 import org.eclipse.jgit.lib.Constants;
38 import org.eclipse.jgit.lib.ObjectId;
39 import org.eclipse.jgit.lib.Repository;
40 import org.eclipse.jgit.revwalk.RevCommit;
41 import org.eclipse.jgit.revwalk.RevWalk;
42 import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
43 import org.junit.jupiter.api.Test;
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68 public class CommitValidationTest {
69
70 private static final List<String> USERS_EXCLUDED_FROM_VALIDATION =
71 Collections.singletonList("dependabot[bot]");
72
73 private static final String ISSUE_COMMIT_MESSAGE_REGEX_PATTERN = "^Issue #\\d+: .*$";
74 private static final String PR_COMMIT_MESSAGE_REGEX_PATTERN = "^Pull #\\d+: .*$";
75 private static final String RELEASE_COMMIT_MESSAGE_REGEX_PATTERN =
76 "^\\[maven-release-plugin] .*$";
77 private static final String REVERT_COMMIT_MESSAGE_REGEX_PATTERN =
78 "^Revert .*$";
79 private static final String OTHER_COMMIT_MESSAGE_REGEX_PATTERN =
80 "^(minor|config|infra|doc|spelling|dependency|supplemental): .*$";
81
82 private static final String ACCEPTED_COMMIT_MESSAGE_REGEX_PATTERN =
83 "(" + ISSUE_COMMIT_MESSAGE_REGEX_PATTERN + ")|"
84 + "(" + PR_COMMIT_MESSAGE_REGEX_PATTERN + ")|"
85 + "(" + RELEASE_COMMIT_MESSAGE_REGEX_PATTERN + ")|"
86 + "(" + OTHER_COMMIT_MESSAGE_REGEX_PATTERN + ")";
87
88 private static final Pattern ACCEPTED_COMMIT_MESSAGE_PATTERN =
89 Pattern.compile(ACCEPTED_COMMIT_MESSAGE_REGEX_PATTERN);
90
91 private static final Pattern INVALID_POSTFIX_PATTERN = Pattern.compile("^.*[. \\t]$");
92
93 private static final int PREVIOUS_COMMITS_TO_CHECK_COUNT = 10;
94
95 private static final CommitsResolutionMode COMMITS_RESOLUTION_MODE =
96 CommitsResolutionMode.BY_LAST_COMMIT_AUTHOR;
97
98 @Test
99 public void testHasCommits() throws Exception {
100 final List<RevCommit> lastCommits = getCommitsToCheck();
101
102 assertWithMessage("must have at least one commit to validate")
103 .that(lastCommits)
104 .isNotEmpty();
105 }
106
107 @Test
108 public void testCommitMessage() {
109 assertWithMessage("should not accept commit message with periods on end")
110 .that(validateCommitMessage("minor: Test. Test."))
111 .isEqualTo(3);
112 assertWithMessage("should not accept commit message with spaces on end")
113 .that(validateCommitMessage("minor: Test. "))
114 .isEqualTo(3);
115 assertWithMessage("should not accept commit message with tabs on end")
116 .that(validateCommitMessage("minor: Test.\t"))
117 .isEqualTo(3);
118 assertWithMessage("should not accept commit message with period on end, ignoring new line")
119 .that(validateCommitMessage("minor: Test.\n"))
120 .isEqualTo(3);
121 assertWithMessage("should not accept commit message with missing prefix")
122 .that(validateCommitMessage("Test. Test"))
123 .isEqualTo(1);
124 assertWithMessage("should not accept commit message with missing prefix")
125 .that(validateCommitMessage("Test. Test\n"))
126 .isEqualTo(1);
127 assertWithMessage("should not accept commit message with multiple lines with text")
128 .that(validateCommitMessage("minor: Test.\nTest"))
129 .isEqualTo(2);
130 assertWithMessage("should accept commit message with a new line on end")
131 .that(validateCommitMessage("minor: Test\n"))
132 .isEqualTo(0);
133 assertWithMessage("should accept commit message with multiple new lines on end")
134 .that(validateCommitMessage("minor: Test\n\n"))
135 .isEqualTo(0);
136 assertWithMessage("should accept commit message that ends properly")
137 .that(validateCommitMessage("minor: Test. Test"))
138 .isEqualTo(0);
139 assertWithMessage("should accept commit message with less than or equal to 200 characters")
140 .that(validateCommitMessage("minor: Test Test Test Test Test"
141 + "Test Test Test Test Test Test Test Test Test Test Test Test Test Test "
142 + "Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test "
143 + "Test Test Test Test Test Test Test Test Test Test Test Test Test"))
144 .isEqualTo(4);
145 }
146
147 @Test
148 public void testReleaseCommitMessage() {
149 assertWithMessage("should accept release commit message for preparing release")
150 .that(validateCommitMessage("[maven-release-plugin] "
151 + "prepare release checkstyle-10.8.0"))
152 .isEqualTo(0);
153 assertWithMessage("should accept release commit message for preparing for "
154 + "next development iteration")
155 .that(validateCommitMessage("[maven-release-plugin] prepare for next "
156 + "development iteration"))
157 .isEqualTo(0);
158 }
159
160 @Test
161 public void testRevertCommitMessage() {
162 assertWithMessage("should accept proper revert commit message")
163 .that(validateCommitMessage("Revert \"doc: release notes for 10.8.0\""
164 + "\nThis reverts commit ff873c3c22161656794c969bb28a8cb09595f.\n"))
165 .isEqualTo(0);
166 assertWithMessage("should accept proper revert commit message")
167 .that(validateCommitMessage("Revert \"doc: release notes for 10.8.0\""))
168 .isEqualTo(0);
169 assertWithMessage("should not accept revert commit message with invalid prefix")
170 .that(validateCommitMessage("This reverts commit "
171 + "ff873c3c22161656794c969bb28a8cb09595f.\n"))
172 .isEqualTo(1);
173 }
174
175 @Test
176 public void testSupplementalPrefix() {
177 assertWithMessage("should accept commit message with supplemental prefix")
178 .that(0)
179 .isEqualTo(validateCommitMessage("supplemental: Test message for supplemental for"
180 + " Issue #XXXX"));
181 assertWithMessage("should not accept commit message with periods on end")
182 .that(3)
183 .isEqualTo(validateCommitMessage("supplemental: Test. Test."));
184 assertWithMessage("should not accept commit message with spaces on end")
185 .that(3)
186 .isEqualTo(validateCommitMessage("supplemental: Test. "));
187 assertWithMessage("should not accept commit message with tabs on end")
188 .that(3)
189 .isEqualTo(validateCommitMessage("supplemental: Test.\t"));
190 assertWithMessage("should not accept commit message with period on end, ignoring new line")
191 .that(3)
192 .isEqualTo(validateCommitMessage("supplemental: Test.\n"));
193 assertWithMessage("should not accept commit message with multiple lines with text")
194 .that(2)
195 .isEqualTo(validateCommitMessage("supplemental: Test.\nTest"));
196 assertWithMessage("should accept commit message with a new line on end")
197 .that(0)
198 .isEqualTo(validateCommitMessage("supplemental: Test\n"));
199 assertWithMessage("should accept commit message with multiple new lines on end")
200 .that(0)
201 .isEqualTo(validateCommitMessage("supplemental: Test\n\n"));
202 }
203
204 @Test
205 public void testCommitMessageHasProperStructure() throws Exception {
206 final List<RevCommit> lastCommits = getCommitsToCheck();
207 for (RevCommit commit : filterValidCommits(lastCommits)) {
208 final String commitMessage = commit.getFullMessage();
209 final int error = validateCommitMessage(commitMessage);
210
211 if (error != 0) {
212 final String commitId = commit.getId().getName();
213
214 assertWithMessage(
215 getInvalidCommitMessageFormattingError(commitId, commitMessage) + error)
216 .fail();
217 }
218 }
219 }
220
221 private static int validateCommitMessage(String commitMessage) {
222 final String message = commitMessage.replace("\r", "").replace("\n", "");
223 final String trimRight = commitMessage.replaceAll("[\\r\\n]+$", "");
224 final int result;
225
226 if (message.matches(REVERT_COMMIT_MESSAGE_REGEX_PATTERN)) {
227
228 result = 0;
229 }
230 else if (!ACCEPTED_COMMIT_MESSAGE_PATTERN.matcher(message).matches()) {
231
232 result = 1;
233 }
234 else if (!trimRight.equals(message)) {
235
236
237 result = 2;
238 }
239 else if (INVALID_POSTFIX_PATTERN.matcher(message).matches()) {
240
241 result = 3;
242 }
243 else if (message.length() > 200) {
244
245 result = 4;
246 }
247 else {
248 result = 0;
249 }
250
251 return result;
252 }
253
254 private static List<RevCommit> getCommitsToCheck() throws Exception {
255 final List<RevCommit> commits;
256 try (Repository repo = new FileRepositoryBuilder().findGitDir().build()) {
257 final RevCommitsPair revCommitsPair = resolveRevCommitsPair(repo);
258 if (COMMITS_RESOLUTION_MODE == CommitsResolutionMode.BY_COUNTER) {
259 commits = getCommitsByCounter(revCommitsPair.getFirst());
260 commits.addAll(getCommitsByCounter(revCommitsPair.getSecond()));
261 }
262 else {
263 commits = getCommitsByLastCommitAuthor(revCommitsPair.getFirst());
264 commits.addAll(getCommitsByLastCommitAuthor(revCommitsPair.getSecond()));
265 }
266 }
267 return commits;
268 }
269
270 private static List<RevCommit> filterValidCommits(List<RevCommit> revCommits) {
271 final List<RevCommit> filteredCommits = new LinkedList<>();
272 for (RevCommit commit : revCommits) {
273 final String commitAuthor = commit.getAuthorIdent().getName();
274 if (!USERS_EXCLUDED_FROM_VALIDATION.contains(commitAuthor)) {
275 filteredCommits.add(commit);
276 }
277 }
278 return filteredCommits;
279 }
280
281 private static RevCommitsPair resolveRevCommitsPair(Repository repo) {
282 RevCommitsPair revCommitIteratorPair;
283
284 try (RevWalk revWalk = new RevWalk(repo);
285 Git git = new Git(repo)) {
286 final Iterator<RevCommit> first;
287 final Iterator<RevCommit> second;
288 final ObjectId headId = repo.resolve(Constants.HEAD);
289 final RevCommit headCommit = revWalk.parseCommit(headId);
290
291 if (isMergeCommit(headCommit)) {
292 final RevCommit firstParent = headCommit.getParent(0);
293 final RevCommit secondParent = headCommit.getParent(1);
294 first = git.log().add(firstParent).call().iterator();
295 second = git.log().add(secondParent).call().iterator();
296 }
297 else {
298 first = git.log().call().iterator();
299 second = Collections.emptyIterator();
300 }
301
302 revCommitIteratorPair =
303 new RevCommitsPair(new OmitMergeCommitsIterator(first),
304 new OmitMergeCommitsIterator(second));
305 }
306 catch (GitAPIException | IOException ignored) {
307 revCommitIteratorPair = new RevCommitsPair();
308 }
309
310 return revCommitIteratorPair;
311 }
312
313 private static boolean isMergeCommit(RevCommit currentCommit) {
314 return currentCommit.getParentCount() > 1;
315 }
316
317 private static List<RevCommit> getCommitsByCounter(
318 Iterator<RevCommit> previousCommitsIterator) {
319 final Spliterator<RevCommit> spliterator =
320 Spliterators.spliteratorUnknownSize(previousCommitsIterator, Spliterator.ORDERED);
321 return StreamSupport.stream(spliterator, false).limit(PREVIOUS_COMMITS_TO_CHECK_COUNT)
322 .collect(Collectors.toUnmodifiableList());
323 }
324
325 private static List<RevCommit> getCommitsByLastCommitAuthor(
326 Iterator<RevCommit> previousCommitsIterator) {
327 final List<RevCommit> commits = new LinkedList<>();
328
329 if (previousCommitsIterator.hasNext()) {
330 final RevCommit lastCommit = previousCommitsIterator.next();
331 final String lastCommitAuthor = lastCommit.getAuthorIdent().getName();
332 commits.add(lastCommit);
333
334 boolean wasLastCheckedCommitAuthorSameAsLastCommit = true;
335 while (wasLastCheckedCommitAuthorSameAsLastCommit
336 && previousCommitsIterator.hasNext()) {
337 final RevCommit currentCommit = previousCommitsIterator.next();
338 final String currentCommitAuthor = currentCommit.getAuthorIdent().getName();
339 if (currentCommitAuthor.equals(lastCommitAuthor)) {
340 commits.add(currentCommit);
341 }
342 else {
343 wasLastCheckedCommitAuthorSameAsLastCommit = false;
344 }
345 }
346 }
347
348 return commits;
349 }
350
351 private static String getRulesForCommitMessageFormatting() {
352 return "Proper commit message should adhere to the following rules:\n"
353 + " 1) Must match one of the following patterns:\n"
354 + " " + ISSUE_COMMIT_MESSAGE_REGEX_PATTERN + "\n"
355 + " " + PR_COMMIT_MESSAGE_REGEX_PATTERN + "\n"
356 + " " + OTHER_COMMIT_MESSAGE_REGEX_PATTERN + "\n"
357 + " 2) It contains only one line of text\n"
358 + " 3) Must not end with a period, space, or tab\n"
359 + " 4) Commit message should be less than or equal to 200 characters\n"
360 + "\n"
361 + "The rule broken was: ";
362 }
363
364 private static String getInvalidCommitMessageFormattingError(String commitId,
365 String commitMessage) {
366 return "Commit " + commitId + " message: \""
367 + commitMessage.replace("\r", "\\r").replace("\n", "\\n").replace("\t", "\\t")
368 + "\" is invalid\n" + getRulesForCommitMessageFormatting();
369 }
370
371 private enum CommitsResolutionMode {
372
373 BY_COUNTER,
374 BY_LAST_COMMIT_AUTHOR,
375
376 }
377
378 private static final class RevCommitsPair {
379
380 private final Iterator<RevCommit> first;
381 private final Iterator<RevCommit> second;
382
383 private RevCommitsPair() {
384 first = Collections.emptyIterator();
385 second = Collections.emptyIterator();
386 }
387
388 private RevCommitsPair(Iterator<RevCommit> first, Iterator<RevCommit> second) {
389 this.first = first;
390 this.second = second;
391 }
392
393 public Iterator<RevCommit> getFirst() {
394 return first;
395 }
396
397 public Iterator<RevCommit> getSecond() {
398 return second;
399 }
400
401 }
402
403 private static final class OmitMergeCommitsIterator implements Iterator<RevCommit> {
404
405 private final Iterator<RevCommit> revCommitIterator;
406
407 private OmitMergeCommitsIterator(Iterator<RevCommit> revCommitIterator) {
408 this.revCommitIterator = revCommitIterator;
409 }
410
411 @Override
412 public boolean hasNext() {
413 return revCommitIterator.hasNext();
414 }
415
416 @Override
417 public RevCommit next() {
418 RevCommit currentCommit = revCommitIterator.next();
419 while (isMergeCommit(currentCommit)) {
420 currentCommit = revCommitIterator.next();
421 }
422 return currentCommit;
423 }
424
425 @Override
426 public void remove() {
427 throw new UnsupportedOperationException("remove");
428 }
429
430 }
431
432 }