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