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 org.junit.Assert.assertEquals;
23 import static org.junit.Assert.assertTrue;
24 import static org.junit.Assert.fail;
25
26 import java.io.IOException;
27 import java.util.Arrays;
28 import java.util.Collections;
29 import java.util.Iterator;
30 import java.util.LinkedList;
31 import java.util.List;
32 import java.util.Spliterator;
33 import java.util.Spliterators;
34 import java.util.regex.Pattern;
35 import java.util.stream.Collectors;
36 import java.util.stream.StreamSupport;
37
38 import org.eclipse.jgit.api.Git;
39 import org.eclipse.jgit.api.errors.GitAPIException;
40 import org.eclipse.jgit.lib.Constants;
41 import org.eclipse.jgit.lib.ObjectId;
42 import org.eclipse.jgit.lib.Repository;
43 import org.eclipse.jgit.revwalk.RevCommit;
44 import org.eclipse.jgit.revwalk.RevWalk;
45 import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
46 import org.junit.BeforeClass;
47 import org.junit.Test;
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72 public class CommitValidationTest {
73
74 private static final List<String> USERS_EXCLUDED_FROM_VALIDATION =
75 Arrays.asList("Roman Ivanov", "rnveach");
76
77 private static final String ISSUE_COMMIT_MESSAGE_REGEX_PATTERN = "^Issue #\\d+: .*$";
78 private static final String PR_COMMIT_MESSAGE_REGEX_PATTERN = "^Pull #\\d+: .*$";
79 private static final String OTHER_COMMIT_MESSAGE_REGEX_PATTERN =
80 "^(minor|config|infra|doc|spelling): .*$";
81
82 private static final String ACCEPTED_COMMIT_MESSAGE_REGEX_PATTERN =
83 "(" + ISSUE_COMMIT_MESSAGE_REGEX_PATTERN + ")|"
84 + "(" + PR_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 private static List<RevCommit> lastCommits;
98
99 @BeforeClass
100 public static void setUp() throws Exception {
101 lastCommits = getCommitsToCheck();
102 }
103
104 @Test
105 public void testHasCommits() {
106 assertTrue("must have at least one commit to validate",
107 lastCommits != null && !lastCommits.isEmpty());
108 }
109
110 @Test
111 public void testCommitMessage() {
112 assertEquals("should not accept commit message with periods on end", 3,
113 validateCommitMessage("minor: Test. Test."));
114 assertEquals("should not accept commit message with spaces on end", 3,
115 validateCommitMessage("minor: Test. "));
116 assertEquals("should not accept commit message with tabs on end", 3,
117 validateCommitMessage("minor: Test.\t"));
118 assertEquals("should not accept commit message with period on end, ignoring new line",
119 3, validateCommitMessage("minor: Test.\n"));
120 assertEquals("should not accept commit message with missing prefix", 1,
121 validateCommitMessage("Test. Test"));
122 assertEquals("should not accept commit message with missing prefix", 1,
123 validateCommitMessage("Test. Test\n"));
124 assertEquals("should not accept commit message with multiple lines with text", 2,
125 validateCommitMessage("minor: Test.\nTest"));
126 assertEquals("should accept commit message with a new line on end", 0,
127 validateCommitMessage("minor: Test\n"));
128 assertEquals("should accept commit message with multiple new lines on end", 0,
129 validateCommitMessage("minor: Test\n\n"));
130 assertEquals("should accept commit message that ends properly", 0,
131 validateCommitMessage("minor: Test. Test"));
132 assertEquals("should accept commit message with less than or equal to 200 characters",
133 4, validateCommitMessage("minor: Test Test Test Test Test"
134 + "Test Test Test Test Test Test Test Test Test Test Test Test Test Test "
135 + "Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test "
136 + "Test Test Test Test Test Test Test Test Test Test Test Test Test"));
137 }
138
139 @Test
140 public void testCommitMessageHasProperStructure() {
141 for (RevCommit commit : filterValidCommits(lastCommits)) {
142 final String commitMessage = commit.getFullMessage();
143 final int error = validateCommitMessage(commitMessage);
144
145 if (error != 0) {
146 final String commitId = commit.getId().getName();
147
148 fail(getInvalidCommitMessageFormattingError(commitId, commitMessage) + error);
149 }
150 }
151 }
152
153 private static int validateCommitMessage(String commitMessage) {
154 final String message = commitMessage.replace("\r", "").replace("\n", "");
155 final String trimRight = commitMessage.replaceAll("[\\r\\n]+$", "");
156 final int result;
157
158 if (!ACCEPTED_COMMIT_MESSAGE_PATTERN.matcher(message).matches()) {
159
160 result = 1;
161 }
162 else if (!trimRight.equals(message)) {
163
164
165 result = 2;
166 }
167 else if (INVALID_POSTFIX_PATTERN.matcher(message).matches()) {
168
169 result = 3;
170 }
171 else if (message.length() > 200) {
172
173 result = 4;
174 }
175 else {
176 result = 0;
177 }
178
179 return result;
180 }
181
182 private static List<RevCommit> getCommitsToCheck() throws Exception {
183 final List<RevCommit> commits;
184 try (Repository repo = new FileRepositoryBuilder().findGitDir().build()) {
185 final RevCommitsPair revCommitsPair = resolveRevCommitsPair(repo);
186 if (COMMITS_RESOLUTION_MODE == CommitsResolutionMode.BY_COUNTER) {
187 commits = getCommitsByCounter(revCommitsPair.getFirst());
188 commits.addAll(getCommitsByCounter(revCommitsPair.getSecond()));
189 }
190 else {
191 commits = getCommitsByLastCommitAuthor(revCommitsPair.getFirst());
192 commits.addAll(getCommitsByLastCommitAuthor(revCommitsPair.getSecond()));
193 }
194 }
195 return commits;
196 }
197
198 private static List<RevCommit> filterValidCommits(List<RevCommit> revCommits) {
199 final List<RevCommit> filteredCommits = new LinkedList<>();
200 for (RevCommit commit : revCommits) {
201 final String commitAuthor = commit.getAuthorIdent().getName();
202 if (!USERS_EXCLUDED_FROM_VALIDATION.contains(commitAuthor)) {
203 filteredCommits.add(commit);
204 }
205 }
206 return filteredCommits;
207 }
208
209 private static RevCommitsPair resolveRevCommitsPair(Repository repo) {
210 RevCommitsPair revCommitIteratorPair;
211
212 try (RevWalk revWalk = new RevWalk(repo); Git git = new Git(repo)) {
213 final Iterator<RevCommit> first;
214 final Iterator<RevCommit> second;
215 final ObjectId headId = repo.resolve(Constants.HEAD);
216 final RevCommit headCommit = revWalk.parseCommit(headId);
217
218 if (isMergeCommit(headCommit)) {
219 final RevCommit firstParent = headCommit.getParent(0);
220 final RevCommit secondParent = headCommit.getParent(1);
221 first = git.log().add(firstParent).call().iterator();
222 second = git.log().add(secondParent).call().iterator();
223 }
224 else {
225 first = git.log().call().iterator();
226 second = Collections.emptyIterator();
227 }
228
229 revCommitIteratorPair =
230 new RevCommitsPair(new OmitMergeCommitsIterator(first),
231 new OmitMergeCommitsIterator(second));
232 }
233 catch (GitAPIException | IOException ignored) {
234 revCommitIteratorPair = new RevCommitsPair();
235 }
236
237 return revCommitIteratorPair;
238 }
239
240 private static boolean isMergeCommit(RevCommit currentCommit) {
241 return currentCommit.getParentCount() > 1;
242 }
243
244 private static List<RevCommit> getCommitsByCounter(
245 Iterator<RevCommit> previousCommitsIterator) {
246 final Spliterator<RevCommit> spliterator =
247 Spliterators.spliteratorUnknownSize(previousCommitsIterator, Spliterator.ORDERED);
248 return StreamSupport.stream(spliterator, false).limit(PREVIOUS_COMMITS_TO_CHECK_COUNT)
249 .collect(Collectors.toList());
250 }
251
252 private static List<RevCommit> getCommitsByLastCommitAuthor(
253 Iterator<RevCommit> previousCommitsIterator) {
254 final List<RevCommit> commits = new LinkedList<>();
255
256 if (previousCommitsIterator.hasNext()) {
257 final RevCommit lastCommit = previousCommitsIterator.next();
258 final String lastCommitAuthor = lastCommit.getAuthorIdent().getName();
259 commits.add(lastCommit);
260
261 boolean wasLastCheckedCommitAuthorSameAsLastCommit = true;
262 while (wasLastCheckedCommitAuthorSameAsLastCommit
263 && previousCommitsIterator.hasNext()) {
264 final RevCommit currentCommit = previousCommitsIterator.next();
265 final String currentCommitAuthor = currentCommit.getAuthorIdent().getName();
266 if (currentCommitAuthor.equals(lastCommitAuthor)) {
267 commits.add(currentCommit);
268 }
269 else {
270 wasLastCheckedCommitAuthorSameAsLastCommit = false;
271 }
272 }
273 }
274
275 return commits;
276 }
277
278 private static String getRulesForCommitMessageFormatting() {
279 return "Proper commit message should adhere to the following rules:\n"
280 + " 1) Must match one of the following patterns:\n"
281 + " " + ISSUE_COMMIT_MESSAGE_REGEX_PATTERN + "\n"
282 + " " + PR_COMMIT_MESSAGE_REGEX_PATTERN + "\n"
283 + " " + OTHER_COMMIT_MESSAGE_REGEX_PATTERN + "\n"
284 + " 2) It contains only one line of text\n"
285 + " 3) Must not end with a period, space, or tab\n"
286 + " 4) Commit message should be less than or equal to 200 characters\n"
287 + "\n"
288 + "The rule broken was: ";
289 }
290
291 private static String getInvalidCommitMessageFormattingError(String commitId,
292 String commitMessage) {
293 return "Commit " + commitId + " message: \""
294 + commitMessage.replace("\r", "\\r").replace("\n", "\\n").replace("\t", "\\t")
295 + "\" is invalid\n" + getRulesForCommitMessageFormatting();
296 }
297
298 private enum CommitsResolutionMode {
299
300 BY_COUNTER,
301 BY_LAST_COMMIT_AUTHOR,
302
303 }
304
305 private static class RevCommitsPair {
306
307 private final Iterator<RevCommit> first;
308 private final Iterator<RevCommit> second;
309
310 RevCommitsPair() {
311 first = Collections.emptyIterator();
312 second = Collections.emptyIterator();
313 }
314
315 RevCommitsPair(Iterator<RevCommit> first, Iterator<RevCommit> second) {
316 this.first = first;
317 this.second = second;
318 }
319
320 public Iterator<RevCommit> getFirst() {
321 return first;
322 }
323
324 public Iterator<RevCommit> getSecond() {
325 return second;
326 }
327
328 }
329
330 private static class OmitMergeCommitsIterator implements Iterator<RevCommit> {
331
332 private final Iterator<RevCommit> revCommitIterator;
333
334 OmitMergeCommitsIterator(Iterator<RevCommit> revCommitIterator) {
335 this.revCommitIterator = revCommitIterator;
336 }
337
338 @Override
339 public boolean hasNext() {
340 return revCommitIterator.hasNext();
341 }
342
343 @Override
344 public RevCommit next() {
345 RevCommit currentCommit = revCommitIterator.next();
346 while (isMergeCommit(currentCommit)) {
347 currentCommit = revCommitIterator.next();
348 }
349 return currentCommit;
350 }
351
352 @Override
353 public void remove() {
354 throw new UnsupportedOperationException("remove");
355 }
356
357 }
358
359 }