[git] do not loose commit which messages do not contain \n while reading details...
[idea/community.git] / plugins / git4idea / tests / git4idea / history / GitHistoryUtilsTest.java
1 /*
2  * Copyright 2000-2010 JetBrains s.r.o.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  * http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package git4idea.history;
17
18 import com.intellij.openapi.util.Pair;
19 import com.intellij.openapi.util.io.FileUtil;
20 import com.intellij.openapi.vcs.FilePath;
21 import com.intellij.openapi.vcs.VcsException;
22 import com.intellij.openapi.vcs.diff.ItemLatestState;
23 import com.intellij.openapi.vcs.history.VcsFileRevision;
24 import com.intellij.openapi.vfs.VirtualFile;
25 import com.intellij.util.ArrayUtil;
26 import com.intellij.util.Consumer;
27 import com.intellij.util.ExceptionUtil;
28 import com.intellij.util.Function;
29 import com.intellij.util.containers.ContainerUtil;
30 import com.intellij.vcs.log.VcsFullCommitDetails;
31 import com.intellij.vcsUtil.VcsUtil;
32 import git4idea.GitFileRevision;
33 import git4idea.GitRevisionNumber;
34 import git4idea.history.browser.SHAHash;
35 import git4idea.test.GitSingleRepoTest;
36 import org.jetbrains.annotations.NotNull;
37 import org.testng.annotations.Test;
38
39 import java.io.File;
40 import java.io.IOException;
41 import java.util.*;
42
43 import static com.intellij.dvcs.DvcsUtil.getShortHash;
44 import static com.intellij.openapi.vcs.Executor.overwrite;
45 import static com.intellij.openapi.vcs.Executor.touch;
46 import static git4idea.test.GitExecutor.*;
47 import static git4idea.test.GitTestUtil.USER_EMAIL;
48 import static git4idea.test.GitTestUtil.USER_NAME;
49
50 /**
51  * Tests for low-level history methods in GitHistoryUtils.
52  * There are some known problems with newlines and whitespaces in commit messages, these are ignored by the tests for now.
53  * (see #convertWhitespacesToSpacesAndRemoveDoubles).
54  */
55 public class GitHistoryUtilsTest extends GitSingleRepoTest {
56
57   private File bfile;
58   private List<GitTestRevision> myRevisions;
59   private List<GitTestRevision> myRevisionsAfterRename;
60
61   @Override
62   protected void setUp() throws Exception {
63     super.setUp();
64     try {
65       initTest();
66     }
67     catch (Exception e) {
68       super.tearDown();
69       throw e;
70     }
71   }
72
73   private void initTest() throws IOException {
74     myRevisions = new ArrayList<>(7);
75     myRevisionsAfterRename = new ArrayList<>(4);
76
77     // 1. create a file
78     // 2. simple edit with a simple commit message
79     // 3. move & rename
80     // 4. make 4 edits with commit messages of different complexity
81     // (note: after rename, because some GitHistoryUtils methods don't follow renames).
82
83     final String[] commitMessages = {
84       "initial commit",
85       "simple commit",
86       "moved a.txt to dir/b.txt",
87       "simple commit after rename",
88       "commit with {%n} some [%ct] special <format:%H%at> characters including --pretty=tformat:%x00%x01%x00%H%x00%ct%x00%an%x20%x3C%ae%x3E%x00%cn%x20%x3C%ce%x3E%x00%x02%x00%s%x00%b%x00%x02%x01",
89       "commit subject\n\ncommit body which is \n multilined.",
90       "first line\nsecond line\nthird line\n\nfifth line\n\nseventh line & the end.",
91     };
92     final String[] contents = {
93       "initial content",
94       "second content",
95       "second content", // content is the same after rename
96       "fourth content",
97       "fifth content",
98       "sixth content",
99       "seventh content",
100     };
101
102     // initial
103     int commitIndex = 0;
104     File afile = touch("a.txt", contents[commitIndex]);
105     addCommit(commitMessages[commitIndex]);
106     commitIndex++;
107
108     // modify
109     overwrite(afile, contents[commitIndex]);
110     addCommit(commitMessages[commitIndex]);
111     int RENAME_COMMIT_INDEX = commitIndex;
112     commitIndex++;
113
114     // mv to dir
115     File dir = mkdir("dir");
116     bfile = new File(dir.getPath(), "b.txt");
117     assertFalse("File " + bfile + " shouldn't have existed", bfile.exists());
118     mv(afile, bfile);
119     assertTrue("File " + bfile + " was not created by mv command", bfile.exists());
120     commit(commitMessages[commitIndex]);
121     commitIndex++;
122
123     // modifications
124     for (int i = 0; i < 4; i++) {
125       overwrite(bfile, contents[commitIndex]);
126       addCommit(commitMessages[commitIndex]);
127       commitIndex++;
128     }
129
130     // Retrieve hashes and timestamps
131     String[] revisions = log("--pretty=format:%H#%at#%P", "-M").split("\n");
132     assertEquals("Incorrect number of revisions", commitMessages.length, revisions.length);
133     // newer revisions go first in the log output
134     for (int i = revisions.length - 1, j = 0; i >= 0; i--, j++) {
135       String[] details = revisions[j].trim().split("#");
136       String[] parents;
137       if (details.length > 2) {
138         parents = details[2].split(" ");
139       }
140       else {
141         parents = ArrayUtil.EMPTY_STRING_ARRAY;
142       }
143       final GitTestRevision revision = new GitTestRevision(details[0], details[1], parents, commitMessages[i],
144                                                            USER_NAME, USER_EMAIL, USER_NAME, USER_EMAIL, null,
145                                                            contents[i]);
146       myRevisions.add(revision);
147       if (i > RENAME_COMMIT_INDEX) {
148         myRevisionsAfterRename.add(revision);
149       }
150     }
151
152     assertEquals(myRevisionsAfterRename.size(), 5);
153     cd(myProjectPath);
154   }
155
156   @Override
157   protected boolean makeInitialCommit() {
158     return false;
159   }
160
161   // Inspired by IDEA-89347
162   @Test
163   public void testCyclicRename() throws Exception {
164     List<TestCommit> commits = new ArrayList<>();
165
166     File source = mkdir("source");
167     File initialFile = touch("source/PostHighlightingPass.java", "Initial content");
168     String initMessage = "Created PostHighlightingPass.java in source";
169     addCommit(initMessage);
170     String hash = last();
171     commits.add(new TestCommit(hash, initMessage, initialFile.getPath()));
172
173     String filePath = initialFile.getPath();
174
175     commits.add(modify(filePath));
176
177     TestCommit commit = move(filePath, mkdir("codeInside-impl"), "Moved from source to codeInside-impl");
178     filePath = commit.myPath;
179     commits.add(commit);
180     commits.add(modify(filePath));
181
182     commit = move(filePath, mkdir("codeInside"), "Moved from codeInside-impl to codeInside");
183     filePath = commit.myPath;
184     commits.add(commit);
185     commits.add(modify(filePath));
186
187     commit = move(filePath, mkdir("lang-impl"), "Moved from codeInside to lang-impl");
188     filePath = commit.myPath;
189     commits.add(commit);
190     commits.add(modify(filePath));
191
192     commit = move(filePath, source, "Moved from lang-impl back to source");
193     filePath = commit.myPath;
194     commits.add(commit);
195     commits.add(modify(filePath));
196
197     commit = move(filePath, mkdir("java"), "Moved from source to java");
198     filePath = commit.myPath;
199     commits.add(commit);
200     commits.add(modify(filePath));
201
202     Collections.reverse(commits);
203     VirtualFile vFile = VcsUtil.getVirtualFileWithRefresh(new File(filePath));
204     assertNotNull(vFile);
205     List<VcsFileRevision> history = GitHistoryUtils.history(myProject, VcsUtil.getFilePath(vFile));
206     assertEquals("History size doesn't match. Actual history: \n" + toReadable(history), commits.size(), history.size());
207     assertEquals("History is different.", toReadable(commits), toReadable(history));
208   }
209
210   private static class TestCommit {
211     private final String myHash;
212     private final String myMessage;
213     private final String myPath;
214
215     public TestCommit(String hash, String message, String path) {
216       myHash = hash;
217       myMessage = message;
218       myPath = path;
219     }
220
221     public String getHash() {
222       return myHash;
223     }
224
225     public String getCommitMessage() {
226       return myMessage;
227     }
228   }
229
230   private static TestCommit move(String file, File dir, String message) throws Exception {
231     final String NAME = "PostHighlightingPass.java";
232     mv(file, dir.getPath());
233     file = new File(dir, NAME).getPath();
234     addCommit(message);
235     String hash = last();
236     return new TestCommit(hash, message, file);
237   }
238
239   private static TestCommit modify(String file) throws IOException {
240     editAppend(file, "Modified");
241     String message = "Modified PostHighlightingPass";
242     addCommit(message);
243     String hash = last();
244     return new TestCommit(hash, message, file);
245   }
246
247   private static void editAppend(String file, String content) throws IOException {
248     FileUtil.appendToFile(new File(file), content);
249   }
250
251   @NotNull
252   private String toReadable(@NotNull Collection<VcsFileRevision> history) {
253     int maxSubjectLength = findMaxLength(history, new Function<VcsFileRevision, String>() {
254       @Override
255       public String fun(VcsFileRevision revision) {
256         return revision.getCommitMessage();
257       }
258     });
259     StringBuilder sb = new StringBuilder();
260     for (VcsFileRevision revision : history) {
261       GitFileRevision rev = (GitFileRevision)revision;
262       String relPath = FileUtil.getRelativePath(new File(myProjectPath), rev.getPath().getIOFile());
263       sb.append(String.format("%s  %-" + maxSubjectLength + "s  %s%n", getShortHash(rev.getHash()), rev.getCommitMessage(), relPath));
264     }
265     return sb.toString();
266   }
267
268   private String toReadable(List<TestCommit> commits) {
269     int maxSubjectLength = findMaxLength(commits, new Function<TestCommit, String>() {
270       @Override
271       public String fun(TestCommit revision) {
272         return revision.getCommitMessage();
273       }
274     });
275     StringBuilder sb = new StringBuilder();
276     for (TestCommit commit : commits) {
277       String relPath = FileUtil.getRelativePath(new File(myProjectPath), new File(commit.myPath));
278       sb.append(String.format("%s  %-" + maxSubjectLength + "s  %s%n", getShortHash(commit.getHash()), commit.getCommitMessage(), relPath));
279     }
280     return sb.toString();
281   }
282
283   private static <T> int findMaxLength(@NotNull Collection<T> list, @NotNull Function<T, String> convertor) {
284     int max = 0;
285     for (T element : list) {
286       int length = convertor.fun(element).length();
287       if (length > max) {
288         max = length;
289       }
290     }
291     return max;
292   }
293
294   @Test
295   public void testGetCurrentRevision() throws Exception {
296     GitRevisionNumber revisionNumber = (GitRevisionNumber)GitHistoryUtils.getCurrentRevision(myProject, toFilePath(bfile), null);
297     assertEquals(revisionNumber.getRev(), myRevisions.get(0).myHash);
298     assertEquals(revisionNumber.getTimestamp(), myRevisions.get(0).myDate);
299   }
300
301   @Test
302   public void testGetCurrentRevisionInMasterBranch() throws Exception {
303     GitRevisionNumber revisionNumber = (GitRevisionNumber)GitHistoryUtils.getCurrentRevision(myProject, toFilePath(bfile), "master");
304     assertEquals(revisionNumber.getRev(), myRevisions.get(0).myHash);
305     assertEquals(revisionNumber.getTimestamp(), myRevisions.get(0).myDate);
306   }
307
308   @Test
309   public void testGetCurrentRevisionInOtherBranch() throws Exception {
310     checkout("-b feature");
311     overwrite(bfile, "new content");
312     addCommit("new content");
313     final String[] output = log("master --pretty=%H#%at", "-n1").trim().split("#");
314
315     GitRevisionNumber revisionNumber = (GitRevisionNumber)GitHistoryUtils.getCurrentRevision(myProject, toFilePath(bfile), "master");
316     assertEquals(revisionNumber.getRev(), output[0]);
317     assertEquals(revisionNumber.getTimestamp(), GitTestRevision.gitTimeStampToDate(output[1]));
318   }
319
320   @NotNull
321   private static FilePath toFilePath(@NotNull File file) {
322     return VcsUtil.getFilePath(file);
323   }
324
325   @Test(enabled = false)
326   public void testGetLastRevisionForExistingFile() throws Exception {
327     final ItemLatestState state = GitHistoryUtils.getLastRevision(myProject, toFilePath(bfile));
328     assertTrue(state.isItemExists());
329     final GitRevisionNumber revisionNumber = (GitRevisionNumber)state.getNumber();
330     assertEquals(revisionNumber.getRev(), myRevisions.get(0).myHash);
331     assertEquals(revisionNumber.getTimestamp(), myRevisions.get(0).myDate);
332   }
333
334   public void testGetLastRevisionForNonExistingFile() throws Exception {
335     git("remote add origin git://example.com/repo.git");
336     git("config branch.master.remote origin");
337     git("config branch.master.merge refs/heads/master");
338
339     git("rm " + bfile.getPath());
340     commit("removed bfile");
341     String[] hashAndDate = log("--pretty=format:%H#%ct", "-n1").split("#");
342     git("update-ref refs/remotes/origin/master HEAD"); // to avoid pushing to this fake origin
343
344     touch("dir/b.txt", "content");
345     addCommit("recreated bfile");
346
347     refresh();
348     myRepo.update();
349
350     final ItemLatestState state = GitHistoryUtils.getLastRevision(myProject, toFilePath(bfile));
351     assertTrue(!state.isItemExists());
352     final GitRevisionNumber revisionNumber = (GitRevisionNumber)state.getNumber();
353     assertEquals(revisionNumber.getRev(), hashAndDate[0]);
354     assertEquals(revisionNumber.getTimestamp(), GitTestRevision.gitTimeStampToDate(hashAndDate[1]));
355   }
356
357   @Test
358   public void testHistory() throws Exception {
359     List<VcsFileRevision> revisions = GitHistoryUtils.history(myProject, toFilePath(bfile));
360     assertHistory(revisions);
361   }
362
363   @Test
364   public void testAppendableHistory() throws Exception {
365     final List<GitFileRevision> revisions = new ArrayList<>(3);
366     Consumer<GitFileRevision> consumer = new Consumer<GitFileRevision>() {
367       @Override
368       public void consume(GitFileRevision gitFileRevision) {
369         revisions.add(gitFileRevision);
370       }
371     };
372     Consumer<VcsException> exceptionConsumer = new Consumer<VcsException>() {
373       @Override
374       public void consume(VcsException exception) {
375         fail("No exception expected " + ExceptionUtil.getThrowableText(exception));
376       }
377     };
378     GitHistoryUtils.history(myProject, toFilePath(bfile), null, consumer, exceptionConsumer);
379     assertHistory(revisions);
380   }
381
382   @Test
383   public void testOnlyHashesHistory() throws Exception {
384     final List<Pair<SHAHash, Date>> history = GitHistoryUtils.onlyHashesHistory(myProject, toFilePath(bfile), myProjectRoot);
385     assertEquals(history.size(), myRevisionsAfterRename.size());
386     Iterator<GitTestRevision> itAfterRename = myRevisionsAfterRename.iterator();
387     for (Pair<SHAHash, Date> pair : history) {
388       GitTestRevision revision = itAfterRename.next();
389       assertEquals(pair.first.toString(), revision.myHash);
390       assertEquals(pair.second, revision.myDate);
391     }
392   }
393
394   @Test
395   public void testLoadingDetailsWithU0001Character() throws Exception {
396     List<VcsFullCommitDetails> details = ContainerUtil.newArrayList();
397
398     String message = "subject containing \u0001 symbol in it\n\ncommit body containing \u0001 symbol in it";
399     touch("file.txt", "content");
400     addCommit(message);
401
402     GitHistoryUtils.loadAllDetails(myProject, myRepo.getRoot(), details::add);
403
404     VcsFullCommitDetails lastCommit = ContainerUtil.getFirstItem(details);
405     assertNotNull(lastCommit);
406     assertEquals(message, lastCommit.getFullMessage());
407   }
408
409   @Test
410   public void testLoadingDetailsWithoutChanges() throws Exception {
411     List<String> expected = ContainerUtil.newArrayList();
412
413     String messageFile = "message.txt";
414     touch(messageFile, "");
415
416     int commitCount = 100;
417     for (int i = 0; i < commitCount; i++) {
418       touch("file.txt", "content number " + i);
419       add();
420       git("commit --allow-empty-message -F " + messageFile);
421       expected.add(last());
422     }
423     expected = ContainerUtil.reverse(expected);
424
425     List<String> actualMessages =
426       GitHistoryUtils.loadDetails(myProject, myRepo.getRoot(), true, false, GitLogRecord::getHash, "--max-count=" + commitCount);
427
428     assertEquals(expected, actualMessages);
429   }
430
431   private void assertHistory(@NotNull List<? extends VcsFileRevision> actualRevisions) throws IOException, VcsException {
432     assertEquals("Incorrect number of commits in history", myRevisions.size(), actualRevisions.size());
433     for (int i = 0; i < actualRevisions.size(); i++) {
434       assertEqualRevisions((GitFileRevision)actualRevisions.get(i), myRevisions.get(i));
435     }
436   }
437
438   private static void assertEqualRevisions(GitFileRevision actual, GitTestRevision expected) throws IOException, VcsException {
439     String actualRev = ((GitRevisionNumber)actual.getRevisionNumber()).getRev();
440     assertEquals(expected.myHash, actualRev);
441     assertEquals(expected.myDate, ((GitRevisionNumber)actual.getRevisionNumber()).getTimestamp());
442     // TODO: whitespaces problem is known, remove convertWhitespaces... when it's fixed
443     assertEquals(convertWhitespacesToSpacesAndRemoveDoubles(expected.myCommitMessage),
444                  convertWhitespacesToSpacesAndRemoveDoubles(actual.getCommitMessage()));
445     assertEquals(expected.myAuthorName, actual.getAuthor());
446     assertEquals(expected.myBranchName, actual.getBranchName());
447     assertNotNull("No content in revision " + actualRev, actual.getContent());
448     assertEquals(new String(expected.myContent), new String(actual.getContent()));
449   }
450
451   private static String convertWhitespacesToSpacesAndRemoveDoubles(String s) {
452     return s.replaceAll("[\\s^ ]", " ").replaceAll(" +", " ");
453   }
454
455   private static class GitTestRevision {
456     final String myHash;
457     final Date myDate;
458     final String myCommitMessage;
459     final String myAuthorName;
460     final String myAuthorEmail;
461     final String myCommitterName;
462     final String myCommitterEmail;
463     final String myBranchName;
464     final byte[] myContent;
465     private String[] myParents;
466
467     public GitTestRevision(String hash,
468                            String gitTimestamp,
469                            String[] parents,
470                            String commitMessage,
471                            String authorName,
472                            String authorEmail,
473                            String committerName,
474                            String committerEmail,
475                            String branch,
476                            String content) {
477       myHash = hash;
478       myDate = gitTimeStampToDate(gitTimestamp);
479       myParents = parents;
480       myCommitMessage = commitMessage;
481       myAuthorName = authorName;
482       myAuthorEmail = authorEmail;
483       myCommitterName = committerName;
484       myCommitterEmail = committerEmail;
485       myBranchName = branch;
486       myContent = content.getBytes();
487     }
488
489     @Override
490     public String toString() {
491       return myHash;
492     }
493
494     public static Date gitTimeStampToDate(String gitTimestamp) {
495       return new Date(Long.parseLong(gitTimestamp) * 1000);
496     }
497   }
498 }