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.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   * Validate commit message has proper structure.
46   *
47   * <p>Commits to check are resolved from different places according
48   * to type of commit in current HEAD. If current HEAD commit is
49   * non-merge commit , previous commits are resolved due to current
50   * HEAD commit. Otherwise if it is a merge commit, it will invoke
51   * resolving previous commits due to commits which was merged.</p>
52   *
53   * <p>After calculating commits to start with ts resolves previous
54   * commits according to COMMITS_RESOLUTION_MODE variable.
55   * At default(BY_LAST_COMMIT_AUTHOR) it checks first commit author
56   * and return all consecutive commits with same author. Second
57   * mode(BY_COUNTER) makes returning first PREVIOUS_COMMITS_TO_CHECK_COUNT
58   * commits after starter commit.</p>
59   *
60   * <p>Resolved commits are filtered according to author. If commit author
61   * belong to list USERS_EXCLUDED_FROM_VALIDATION then this commit will
62   * not be validated.</p>
63   *
64   * <p>Filtered commit list is checked if their messages has proper structure.</p>
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             // revert commits are excluded from validation
230             result = 0;
231         }
232         else if (!ACCEPTED_COMMIT_MESSAGE_PATTERN.matcher(message).matches()) {
233             // improper prefix
234             result = 1;
235         }
236         else if (!trimRight.equals(message)) {
237             // single-line of text (multiple new lines are allowed on end because of
238             // git (1 new line) and GitHub's web ui (2 new lines))
239             result = 2;
240         }
241         else if (INVALID_POSTFIX_PATTERN.matcher(message).matches()) {
242             // improper postfix
243             result = 3;
244         }
245         else if (message.length() > 200) {
246             // commit message has more than 200 characters
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 }