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