[git] IDEA-89347 Support cyclic renames in file history + test
authorKirill Likhodedov <Kirill.Likhodedov@jetbrains.com>
Mon, 30 Jul 2012 11:07:34 +0000 (15:07 +0400)
committerKirill Likhodedov <Kirill.Likhodedov@jetbrains.com>
Mon, 30 Jul 2012 11:31:02 +0000 (15:31 +0400)
* See http://youtrack.jetbrains.com/issue/IDEA-89347 for detailed explanation of the problem.
* If a file is renamed several times, and at once it get renamed to the name which it already have had previously (cyclic rename), git log -- <file path> will display the all history for this path.
* Improve the algorithm in GitHistoryUtils so that it tracks when the file was ADDED, and stops looking at the history if there is any. Then the traditional "git show --name-status -M" will capture that the file was renamed, and query for the history of the old path, etc.
* Write a test for cyclic renames in the history.

plugins/git4idea/src/git4idea/history/GitHistoryUtils.java
plugins/git4idea/src/git4idea/history/GitLogRecord.java
plugins/git4idea/tests/git4idea/history/GitHistoryUtilsTest.java

index 3f806e8af5ef1ab7105b5f1db465700781e9b59b..291c32116b785df693b83269ed5b2f8968b0bc1b 100644 (file)
@@ -55,6 +55,7 @@ import org.jetbrains.annotations.Nullable;
 import java.io.*;
 import java.nio.charset.Charset;
 import java.util.*;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
 
 import static git4idea.history.GitLogParser.GitLogOption.*;
@@ -321,7 +322,7 @@ public class GitHistoryUtils {
     // adjust path using change manager
     path = getLastCommitName(project, path);
     final VirtualFile finalRoot = (root == null ? GitUtil.getGitRoot(path) : root);
-    final GitLogParser logParser = new GitLogParser(project, GitLogParser.NameStatus.NAME,
+    final GitLogParser logParser = new GitLogParser(project, GitLogParser.NameStatus.STATUS,
                                                     HASH, COMMIT_TIME, AUTHOR_NAME, AUTHOR_EMAIL, COMMITTER_NAME, COMMITTER_EMAIL, PARENTS,
                                                     SUBJECT, BODY, RAW_BODY, AUTHOR_TIME);
 
@@ -329,9 +330,13 @@ public class GitHistoryUtils {
     final AtomicReference<String> firstCommitParent = new AtomicReference<String>("HEAD");
     final AtomicReference<FilePath> currentPath = new AtomicReference<FilePath>(path);
     final AtomicReference<GitLineHandler> logHandler = new AtomicReference<GitLineHandler>();
+    final AtomicBoolean skipFurtherOutput = new AtomicBoolean();
 
     final Consumer<GitLogRecord> resultAdapter = new Consumer<GitLogRecord>() {
       public void consume(GitLogRecord record) {
+        if (skipFurtherOutput.get()) {
+          return;
+        }
         if (record == null) {
           exceptionConsumer.consume(new VcsException("revision details are null."));
           return;
@@ -342,7 +347,8 @@ public class GitHistoryUtils {
         final String[] parentHashes = record.getParentsHashes();
         if (parentHashes == null || parentHashes.length < 1) {
           firstCommitParent.set(null);
-        } else {
+        }
+        else {
           firstCommitParent.set(parentHashes[0]);
         }
         final String message = record.getFullMessage();
@@ -352,17 +358,28 @@ public class GitHistoryUtils {
           final List<FilePath> paths = record.getFilePaths(finalRoot);
           if (paths.size() > 0) {
             revisionPath = paths.get(0);
-          } else {
+          }
+          else {
             // no paths are shown for merge commits, so we're using the saved path we're inspecting now
             revisionPath = currentPath.get();
           }
 
           final Pair<String, String> authorPair = Pair.create(record.getAuthorName(), record.getAuthorEmail());
-          final Pair<String, String> committerPair = record.getCommitterName() == null ? null : Pair.create(record.getCommitterName(), record.getCommitterEmail());
+          final Pair<String, String> committerPair =
+            record.getCommitterName() == null ? null : Pair.create(record.getCommitterName(), record.getCommitterEmail());
           Collection<String> parents = parentHashes == null ? Collections.<String>emptyList() : Arrays.asList(parentHashes);
           consumer.consume(new GitFileRevision(project, revisionPath, revision, Pair.create(authorPair, committerPair), message, null,
                                                new Date(record.getAuthorTimeStamp() * 1000), false, parents));
-        } catch (VcsException e) {
+          List<GitLogStatusInfo> statusInfos = record.getStatusInfos();
+          if (statusInfos.isEmpty()) {
+            LOG.error(String.format("No status information for the file. File path: %s, current revision: %s, log record: %s",
+                                    currentPath, revision, record));
+          }
+          if (statusInfos.get(0).getType() == GitChangeType.ADDED) {
+            skipFurtherOutput.set(true);
+          }
+        }
+        catch (VcsException e) {
           exceptionConsumer.consume(e);
         }
       }
@@ -410,6 +427,7 @@ public class GitHistoryUtils {
       semaphore.waitFor();
 
       currentPath.set(getFirstCommitRenamePath(project, finalRoot, firstCommit.get(), currentPath.get()));
+      skipFurtherOutput.set(false);
     }
 
   }
@@ -418,7 +436,7 @@ public class GitHistoryUtils {
     final GitLineHandler h = new GitLineHandler(project, root, GitCommand.LOG);
     h.setNoSSH(true);
     h.setStdoutSuppressed(true);
-    h.addParameters("--name-only", parser.getPretty(), "--encoding=UTF-8", lastCommit);
+    h.addParameters("--name-status", parser.getPretty(), "--encoding=UTF-8", lastCommit);
     if (parameters != null && parameters.length > 0) {
       h.addParameters(parameters);
     }
index 4436abf6769da5fae89c9768630c15521965e819..f1224cae23e704b1058df7e4875c2091635fc2d1 100644 (file)
@@ -62,6 +62,11 @@ class GitLogRecord {
     return myPaths;
   }
 
+  @NotNull
+  List<GitLogStatusInfo> getStatusInfos() {
+    return myStatusInfo;
+  }
+
   @NotNull
   public List<FilePath> getFilePaths(VirtualFile root) throws VcsException {
     List<FilePath> res = new ArrayList<FilePath>();
@@ -252,4 +257,10 @@ class GitLogRecord {
   public void setUsedHandler(GitHandler handler) {
     myHandler = handler;
   }
+
+  @Override
+  public String toString() {
+    return String.format("GitLogRecord{myOptions=%s, myPaths=%s, myStatusInfo=%s, mySupportsRawBody=%s, myHandler=%s}",
+                         myOptions, myPaths, myStatusInfo, mySupportsRawBody, myHandler);
+  }
 }
index 87831afd49e0693d99538800df66fe8b3e91a374..1d25344b61990d21726a703f02830f3e3a137187 100644 (file)
@@ -16,7 +16,9 @@
 package git4idea.history;
 
 import com.intellij.openapi.util.Pair;
+import com.intellij.openapi.util.io.FileUtil;
 import com.intellij.openapi.vcs.FilePath;
+import com.intellij.openapi.vcs.FilePathImpl;
 import com.intellij.openapi.vcs.VcsException;
 import com.intellij.openapi.vcs.diff.ItemLatestState;
 import com.intellij.openapi.vcs.history.VcsFileRevision;
@@ -24,6 +26,7 @@ import com.intellij.openapi.vfs.VirtualFile;
 import com.intellij.util.ArrayUtil;
 import com.intellij.util.AsynchConsumer;
 import com.intellij.util.Consumer;
+import com.intellij.util.Function;
 import com.intellij.vcsUtil.VcsUtil;
 import git4idea.GitFileRevision;
 import git4idea.GitRevisionNumber;
@@ -32,6 +35,7 @@ import git4idea.history.browser.SHAHash;
 import git4idea.history.wholeTree.AbstractHash;
 import git4idea.history.wholeTree.CommitHashPlusParents;
 import git4idea.tests.GitTest;
+import org.jetbrains.annotations.NotNull;
 import org.testng.annotations.BeforeMethod;
 import org.testng.annotations.Test;
 
@@ -40,6 +44,8 @@ import java.io.IOException;
 import java.lang.reflect.Method;
 import java.util.*;
 
+import static git4idea.GitUtil.getShortHash;
+import static git4idea.tests.GitTestRepository.createFile;
 import static org.testng.Assert.*;
 
 /**
@@ -137,6 +143,137 @@ public class GitHistoryUtilsTest extends GitTest {
     assertEquals(myRevisionsAfterRename.size(), 5);
   }
 
+  // Inspired by IDEA-89347
+  @Test
+  void testCyclicRename() throws Exception {
+    List<TestCommit> commits = new ArrayList<TestCommit>();
+
+    File source = myRepo.createDir("source");
+    File initialFile = createFile(source, "PostHighlightingPass.java", "Initial content");
+    String initMessage = "Created PostHighlightingPass.java in source";
+    String hash = myRepo.addCommit(initMessage);
+    commits.add(new TestCommit(hash, initMessage, initialFile.getPath()));
+
+    String filePath = initialFile.getPath();
+
+    commits.add(modify(filePath));
+
+    TestCommit commit = move(filePath, myRepo.createDir("codeInside-impl"), "Moved from source to codeInside-impl");
+    filePath = commit.myPath;
+    commits.add(commit);
+    commits.add(modify(filePath));
+
+    commit = move(filePath, myRepo.createDir("codeInside"), "Moved from codeInside-impl to codeInside");
+    filePath = commit.myPath;
+    commits.add(commit);
+    commits.add(modify(filePath));
+
+    commit = move(filePath, myRepo.createDir("lang-impl"), "Moved from codeInside to lang-impl");
+    filePath = commit.myPath;
+    commits.add(commit);
+    commits.add(modify(filePath));
+
+    commit = move(filePath, source, "Moved from lang-impl back to source");
+    filePath = commit.myPath;
+    commits.add(commit);
+    commits.add(modify(filePath));
+
+    commit = move(filePath, myRepo.createDir("java"), "Moved from source to java");
+    filePath = commit.myPath;
+    commits.add(commit);
+    commits.add(modify(filePath));
+
+    Collections.reverse(commits);
+    myRepo.refresh();
+    VirtualFile vFile = VcsUtil.getVirtualFile(filePath);
+    assertNotNull(vFile);
+    List<VcsFileRevision> history = GitHistoryUtils.history(myProject, new FilePathImpl(vFile));
+    assertEquals(history.size(), commits.size(), "History size doesn't match. Actual history: \n" + toReadable(history));
+    assertEquals(toReadable(history), toReadable(commits), "History is different.");
+  }
+
+  private static class TestCommit {
+    private final String myHash;
+    private final String myMessage;
+    private final String myPath;
+
+    public TestCommit(String hash, String message, String path) {
+      myHash = hash;
+      myMessage = message;
+      myPath = path;
+    }
+
+    public String getHash() {
+      return myHash;
+    }
+
+    public String getCommitMessage() {
+      return myMessage;
+    }
+  }
+
+  private TestCommit move(String file, File dir, String message) throws Exception {
+    final String NAME = "PostHighlightingPass.java";
+    myRepo.mv(file, dir.getPath());
+    file = new File(dir, NAME).getPath();
+    String hash = myRepo.addCommit(message);
+    return new TestCommit(hash, message, file);
+  }
+
+  private TestCommit modify(String file) throws IOException {
+    editAppend(file, "Modified");
+    String message = "Modified PostHighlightingPass";
+    String hash = myRepo.addCommit(message);
+    return new TestCommit(hash, message, file);
+  }
+
+  private static void editAppend(String file, String content) throws IOException {
+    FileUtil.appendToFile(new File(file), content);
+  }
+
+  @NotNull
+  private String toReadable(@NotNull Collection<VcsFileRevision> history) {
+    int maxSubjectLength = findMaxLength(history, new Function<VcsFileRevision, String>() {
+      @Override
+      public String fun(VcsFileRevision revision) {
+        return revision.getCommitMessage();
+      }
+    });
+    StringBuilder sb = new StringBuilder();
+    for (VcsFileRevision revision : history) {
+      GitFileRevision rev = (GitFileRevision)revision;
+      String relPath = FileUtil.getRelativePath(myRepo.getRootDir().getPath(), rev.getPath().getPath(), '/');
+      sb.append(String.format("%s  %-" + maxSubjectLength + "s  %s%n", getShortHash(rev.getHash()), rev.getCommitMessage(), relPath));
+    }
+    return sb.toString();
+  }
+
+  private String toReadable(List<TestCommit> commits) {
+    int maxSubjectLength = findMaxLength(commits, new Function<TestCommit, String>() {
+      @Override
+      public String fun(TestCommit revision) {
+        return revision.getCommitMessage();
+      }
+    });
+    StringBuilder sb = new StringBuilder();
+    for (TestCommit commit : commits) {
+      String relPath = FileUtil.getRelativePath(myRepo.getRootDir().getPath(), commit.myPath, '/');
+      sb.append(String.format("%s  %-" + maxSubjectLength + "s  %s%n", getShortHash(commit.getHash()), commit.getCommitMessage(), relPath));
+    }
+    return sb.toString();
+  }
+
+  private static <T> int findMaxLength(@NotNull Collection<T> list, @NotNull Function<T, String> convertor) {
+    int max = 0;
+    for (T element : list) {
+      int length = convertor.fun(element).length();
+      if (length > max) {
+        max = length;
+      }
+    }
+    return max;
+  }
+
   @Test
   public void testGetCurrentRevision() throws Exception {
     GitRevisionNumber revisionNumber = (GitRevisionNumber) GitHistoryUtils.getCurrentRevision(myProject, bfilePath, null);