| //////////////////////////////////////////////////////////////////////////////// |
| // checkstyle: Checks Java source code for adherence to a set of rules. |
| // Copyright (C) 2001-2017 the original author or authors. |
| // |
| // This library is free software; you can redistribute it and/or |
| // modify it under the terms of the GNU Lesser General Public |
| // License as published by the Free Software Foundation; either |
| // version 2.1 of the License, or (at your option) any later version. |
| // |
| // This library is distributed in the hope that it will be useful, |
| // but WITHOUT ANY WARRANTY; without even the implied warranty of |
| // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
| // Lesser General Public License for more details. |
| // |
| // You should have received a copy of the GNU Lesser General Public |
| // License along with this library; if not, write to the Free Software |
| // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA |
| //////////////////////////////////////////////////////////////////////////////// |
| |
| package com.puppycrawl.tools.checkstyle.internal; |
| |
| import static org.junit.Assert.assertEquals; |
| import static org.junit.Assert.assertTrue; |
| import static org.junit.Assert.fail; |
| |
| import java.io.IOException; |
| import java.util.Arrays; |
| import java.util.Collections; |
| import java.util.Iterator; |
| import java.util.LinkedList; |
| import java.util.List; |
| import java.util.Spliterator; |
| import java.util.Spliterators; |
| import java.util.regex.Pattern; |
| import java.util.stream.Collectors; |
| import java.util.stream.StreamSupport; |
| |
| import org.eclipse.jgit.api.Git; |
| import org.eclipse.jgit.api.errors.GitAPIException; |
| import org.eclipse.jgit.lib.Constants; |
| import org.eclipse.jgit.lib.ObjectId; |
| import org.eclipse.jgit.lib.Repository; |
| import org.eclipse.jgit.revwalk.RevCommit; |
| import org.eclipse.jgit.revwalk.RevWalk; |
| import org.eclipse.jgit.storage.file.FileRepositoryBuilder; |
| import org.junit.BeforeClass; |
| import org.junit.Test; |
| |
| /** |
| * Validate commit message has proper structure. |
| * |
| * Commits to check are resolved from different places according |
| * to type of commit in current HEAD. If current HEAD commit is |
| * non-merge commit , previous commits are resolved due to current |
| * HEAD commit. Otherwise if it is a merge commit, it will invoke |
| * resolving previous commits due to commits which was merged. |
| * |
| * After calculating commits to start with ts resolves previous |
| * commits according to COMMITS_RESOLUTION_MODE variable. |
| * At default(BY_LAST_COMMIT_AUTHOR) it checks first commit author |
| * and return all consecutive commits with same author. Second |
| * mode(BY_COUNTER) makes returning first PREVIOUS_COMMITS_TO_CHECK_COUNT |
| * commits after starter commit. |
| * |
| * Resolved commits are filtered according to author. If commit author |
| * belong to list USERS_EXCLUDED_FROM_VALIDATION then this commit will |
| * not be validated. |
| * |
| * Filtered commit list is checked if their messages has proper structure. |
| * |
| * @author <a href="mailto:piotr.listkiewicz@gmail.com">liscju</a> |
| */ |
| public class CommitValidationTest { |
| |
| private static final List<String> USERS_EXCLUDED_FROM_VALIDATION = |
| Arrays.asList("Roman Ivanov", "rnveach"); |
| |
| private static final String ISSUE_COMMIT_MESSAGE_REGEX_PATTERN = "^Issue #\\d+: .*$"; |
| private static final String PR_COMMIT_MESSAGE_REGEX_PATTERN = "^Pull #\\d+: .*$"; |
| private static final String OTHER_COMMIT_MESSAGE_REGEX_PATTERN = |
| "^(minor|config|infra|doc|spelling): .*$"; |
| |
| private static final String ACCEPTED_COMMIT_MESSAGE_REGEX_PATTERN = |
| "(" + ISSUE_COMMIT_MESSAGE_REGEX_PATTERN + ")|" |
| + "(" + PR_COMMIT_MESSAGE_REGEX_PATTERN + ")|" |
| + "(" + OTHER_COMMIT_MESSAGE_REGEX_PATTERN + ")"; |
| |
| private static final Pattern ACCEPTED_COMMIT_MESSAGE_PATTERN = |
| Pattern.compile(ACCEPTED_COMMIT_MESSAGE_REGEX_PATTERN); |
| |
| private static final Pattern INVALID_POSTFIX_PATTERN = Pattern.compile("^.*[. \\t]$"); |
| |
| private static final int PREVIOUS_COMMITS_TO_CHECK_COUNT = 10; |
| |
| private static final CommitsResolutionMode COMMITS_RESOLUTION_MODE = |
| CommitsResolutionMode.BY_LAST_COMMIT_AUTHOR; |
| |
| private static List<RevCommit> lastCommits; |
| |
| @BeforeClass |
| public static void setUp() throws Exception { |
| lastCommits = getCommitsToCheck(); |
| } |
| |
| @Test |
| public void testHasCommits() { |
| assertTrue("must have at least one commit to validate", |
| lastCommits != null && !lastCommits.isEmpty()); |
| } |
| |
| @Test |
| public void testCommitMessage() { |
| assertEquals("should not accept commit message with periods on end", 3, |
| validateCommitMessage("minor: Test. Test.")); |
| assertEquals("should not accept commit message with spaces on end", 3, |
| validateCommitMessage("minor: Test. ")); |
| assertEquals("should not accept commit message with tabs on end", 3, |
| validateCommitMessage("minor: Test.\t")); |
| assertEquals("should not accept commit message with period on end, ignoring new line", |
| 3, validateCommitMessage("minor: Test.\n")); |
| assertEquals("should not accept commit message with missing prefix", 1, |
| validateCommitMessage("Test. Test")); |
| assertEquals("should not accept commit message with missing prefix", 1, |
| validateCommitMessage("Test. Test\n")); |
| assertEquals("should not accept commit message with multiple lines with text", 2, |
| validateCommitMessage("minor: Test.\nTest")); |
| assertEquals("should accept commit message with a new line on end", 0, |
| validateCommitMessage("minor: Test\n")); |
| assertEquals("should accept commit message with multiple new lines on end", 0, |
| validateCommitMessage("minor: Test\n\n")); |
| assertEquals("should accept commit message that ends properly", 0, |
| validateCommitMessage("minor: Test. Test")); |
| assertEquals("should accept commit message with less than or equal to 200 characters", |
| 4, validateCommitMessage("minor: Test Test Test Test Test" |
| + "Test Test Test Test Test Test Test Test Test Test Test Test Test Test " |
| + "Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test " |
| + "Test Test Test Test Test Test Test Test Test Test Test Test Test")); |
| } |
| |
| @Test |
| public void testCommitMessageHasProperStructure() { |
| for (RevCommit commit : filterValidCommits(lastCommits)) { |
| final String commitMessage = commit.getFullMessage(); |
| final int error = validateCommitMessage(commitMessage); |
| |
| if (error != 0) { |
| final String commitId = commit.getId().getName(); |
| |
| fail(getInvalidCommitMessageFormattingError(commitId, commitMessage) + error); |
| } |
| } |
| } |
| |
| private static int validateCommitMessage(String commitMessage) { |
| final String message = commitMessage.replace("\r", "").replace("\n", ""); |
| final String trimRight = commitMessage.replaceAll("[\\r\\n]+$", ""); |
| final int result; |
| |
| if (!ACCEPTED_COMMIT_MESSAGE_PATTERN.matcher(message).matches()) { |
| // improper prefix |
| result = 1; |
| } |
| else if (!trimRight.equals(message)) { |
| // single line of text (multiple new lines are allowed on end because of |
| // git (1 new line) and github's web ui (2 new lines)) |
| result = 2; |
| } |
| else if (INVALID_POSTFIX_PATTERN.matcher(message).matches()) { |
| // improper postfix |
| result = 3; |
| } |
| else if (message.length() > 200) { |
| // commit message has more than 200 characters |
| result = 4; |
| } |
| else { |
| result = 0; |
| } |
| |
| return result; |
| } |
| |
| private static List<RevCommit> getCommitsToCheck() throws Exception { |
| final List<RevCommit> commits; |
| try (Repository repo = new FileRepositoryBuilder().findGitDir().build()) { |
| final RevCommitsPair revCommitsPair = resolveRevCommitsPair(repo); |
| if (COMMITS_RESOLUTION_MODE == CommitsResolutionMode.BY_COUNTER) { |
| commits = getCommitsByCounter(revCommitsPair.getFirst()); |
| commits.addAll(getCommitsByCounter(revCommitsPair.getSecond())); |
| } |
| else { |
| commits = getCommitsByLastCommitAuthor(revCommitsPair.getFirst()); |
| commits.addAll(getCommitsByLastCommitAuthor(revCommitsPair.getSecond())); |
| } |
| } |
| return commits; |
| } |
| |
| private static List<RevCommit> filterValidCommits(List<RevCommit> revCommits) { |
| final List<RevCommit> filteredCommits = new LinkedList<>(); |
| for (RevCommit commit : revCommits) { |
| final String commitAuthor = commit.getAuthorIdent().getName(); |
| if (!USERS_EXCLUDED_FROM_VALIDATION.contains(commitAuthor)) { |
| filteredCommits.add(commit); |
| } |
| } |
| return filteredCommits; |
| } |
| |
| private static RevCommitsPair resolveRevCommitsPair(Repository repo) { |
| RevCommitsPair revCommitIteratorPair; |
| |
| try (RevWalk revWalk = new RevWalk(repo); Git git = new Git(repo)) { |
| final Iterator<RevCommit> first; |
| final Iterator<RevCommit> second; |
| final ObjectId headId = repo.resolve(Constants.HEAD); |
| final RevCommit headCommit = revWalk.parseCommit(headId); |
| |
| if (isMergeCommit(headCommit)) { |
| final RevCommit firstParent = headCommit.getParent(0); |
| final RevCommit secondParent = headCommit.getParent(1); |
| first = git.log().add(firstParent).call().iterator(); |
| second = git.log().add(secondParent).call().iterator(); |
| } |
| else { |
| first = git.log().call().iterator(); |
| second = Collections.emptyIterator(); |
| } |
| |
| revCommitIteratorPair = |
| new RevCommitsPair(new OmitMergeCommitsIterator(first), |
| new OmitMergeCommitsIterator(second)); |
| } |
| catch (GitAPIException | IOException ignored) { |
| revCommitIteratorPair = new RevCommitsPair(); |
| } |
| |
| return revCommitIteratorPair; |
| } |
| |
| private static boolean isMergeCommit(RevCommit currentCommit) { |
| return currentCommit.getParentCount() > 1; |
| } |
| |
| private static List<RevCommit> getCommitsByCounter( |
| Iterator<RevCommit> previousCommitsIterator) { |
| final Spliterator<RevCommit> spliterator = |
| Spliterators.spliteratorUnknownSize(previousCommitsIterator, Spliterator.ORDERED); |
| return StreamSupport.stream(spliterator, false).limit(PREVIOUS_COMMITS_TO_CHECK_COUNT) |
| .collect(Collectors.toList()); |
| } |
| |
| private static List<RevCommit> getCommitsByLastCommitAuthor( |
| Iterator<RevCommit> previousCommitsIterator) { |
| final List<RevCommit> commits = new LinkedList<>(); |
| |
| if (previousCommitsIterator.hasNext()) { |
| final RevCommit lastCommit = previousCommitsIterator.next(); |
| final String lastCommitAuthor = lastCommit.getAuthorIdent().getName(); |
| commits.add(lastCommit); |
| |
| boolean wasLastCheckedCommitAuthorSameAsLastCommit = true; |
| while (wasLastCheckedCommitAuthorSameAsLastCommit |
| && previousCommitsIterator.hasNext()) { |
| final RevCommit currentCommit = previousCommitsIterator.next(); |
| final String currentCommitAuthor = currentCommit.getAuthorIdent().getName(); |
| if (currentCommitAuthor.equals(lastCommitAuthor)) { |
| commits.add(currentCommit); |
| } |
| else { |
| wasLastCheckedCommitAuthorSameAsLastCommit = false; |
| } |
| } |
| } |
| |
| return commits; |
| } |
| |
| private static String getRulesForCommitMessageFormatting() { |
| return "Proper commit message should adhere to the following rules:\n" |
| + " 1) Must match one of the following patterns:\n" |
| + " " + ISSUE_COMMIT_MESSAGE_REGEX_PATTERN + "\n" |
| + " " + PR_COMMIT_MESSAGE_REGEX_PATTERN + "\n" |
| + " " + OTHER_COMMIT_MESSAGE_REGEX_PATTERN + "\n" |
| + " 2) It contains only one line of text\n" |
| + " 3) Must not end with a period, space, or tab\n" |
| + " 4) Commit message should be less than or equal to 200 characters\n" |
| + "\n" |
| + "The rule broken was: "; |
| } |
| |
| private static String getInvalidCommitMessageFormattingError(String commitId, |
| String commitMessage) { |
| return "Commit " + commitId + " message: \"" |
| + commitMessage.replace("\r", "\\r").replace("\n", "\\n").replace("\t", "\\t") |
| + "\" is invalid\n" + getRulesForCommitMessageFormatting(); |
| } |
| |
| private enum CommitsResolutionMode { |
| BY_COUNTER, BY_LAST_COMMIT_AUTHOR |
| } |
| |
| private static class RevCommitsPair { |
| private final Iterator<RevCommit> first; |
| private final Iterator<RevCommit> second; |
| |
| RevCommitsPair() { |
| first = Collections.emptyIterator(); |
| second = Collections.emptyIterator(); |
| } |
| |
| RevCommitsPair(Iterator<RevCommit> first, Iterator<RevCommit> second) { |
| this.first = first; |
| this.second = second; |
| } |
| |
| public Iterator<RevCommit> getFirst() { |
| return first; |
| } |
| |
| public Iterator<RevCommit> getSecond() { |
| return second; |
| } |
| } |
| |
| private static class OmitMergeCommitsIterator implements Iterator<RevCommit> { |
| |
| private final Iterator<RevCommit> revCommitIterator; |
| |
| OmitMergeCommitsIterator(Iterator<RevCommit> revCommitIterator) { |
| this.revCommitIterator = revCommitIterator; |
| } |
| |
| @Override |
| public boolean hasNext() { |
| return revCommitIterator.hasNext(); |
| } |
| |
| @Override |
| public RevCommit next() { |
| RevCommit currentCommit = revCommitIterator.next(); |
| while (isMergeCommit(currentCommit)) { |
| currentCommit = revCommitIterator.next(); |
| } |
| return currentCommit; |
| } |
| |
| @Override |
| public void remove() { |
| throw new UnsupportedOperationException("remove"); |
| } |
| } |
| } |