View Javadoc
1   ///////////////////////////////////////////////////////////////////////////////////////////////
2   // checkstyle: Checks Java source code and other text files for adherence to a set of rules.
3   // Copyright (C) 2001-2025 the original author or authors.
4   //
5   // This library is free software; you can redistribute it and/or
6   // modify it under the terms of the GNU Lesser General Public
7   // License as published by the Free Software Foundation; either
8   // version 2.1 of the License, or (at your option) any later version.
9   //
10  // This library is distributed in the hope that it will be useful,
11  // but WITHOUT ANY WARRANTY; without even the implied warranty of
12  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
13  // Lesser General Public License for more details.
14  //
15  // You should have received a copy of the GNU Lesser General Public
16  // License along with this library; if not, write to the Free Software
17  // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
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   * Validate commit message has proper structure.
48   *
49   * <p>Commits to check are resolved from different places according
50   * to type of commit in current HEAD. If current HEAD commit is
51   * non-merge commit , previous commits are resolved due to current
52   * HEAD commit. Otherwise if it is a merge commit, it will invoke
53   * resolving previous commits due to commits which was merged.</p>
54   *
55   * <p>After calculating commits to start with ts resolves previous
56   * commits according to COMMITS_RESOLUTION_MODE variable.
57   * At default(BY_LAST_COMMIT_AUTHOR) it checks first commit author
58   * and return all consecutive commits with same author. Second
59   * mode(BY_COUNTER) makes returning first PREVIOUS_COMMITS_TO_CHECK_COUNT
60   * commits after starter commit.</p>
61   *
62   * <p>Resolved commits are filtered according to author. If commit author
63   * belong to list USERS_EXCLUDED_FROM_VALIDATION then this commit will
64   * not be validated.</p>
65   *
66   * <p>Filtered commit list is checked if their messages has proper structure.</p>
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             // revert commits are excluded from validation
233             result = 0;
234         }
235         else if (!ACCEPTED_COMMIT_MESSAGE_PATTERN.matcher(message).matches()) {
236             // improper prefix
237             result = 1;
238         }
239         else if (!trimRight.equals(message)) {
240             // single-line of text (multiple new lines are allowed on end because of
241             // git (1 new line) and GitHub's web ui (2 new lines))
242             result = 2;
243         }
244         else if (INVALID_POSTFIX_PATTERN.matcher(message).matches()) {
245             // improper postfix
246             result = 3;
247         }
248         else if (message.length() > 200) {
249             // commit message has more than 200 characters
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 }