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