diff --git a/plugins/git4idea/src/git4idea/history/GitChangeType.java b/plugins/git4idea/src/git4idea/history/GitChangeType.java new file mode 100644 index 000000000000..a0a1d000fcb7 --- /dev/null +++ b/plugins/git4idea/src/git4idea/history/GitChangeType.java @@ -0,0 +1,52 @@ +/* + * Copyright 2000-2011 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package git4idea.history; + +import git4idea.GitFormatException; +import org.jetbrains.annotations.NotNull; + +/** + * @author Kirill Likhodedov + */ +enum GitChangeType { + MODIFIED('M'), + ADDED('A'), + COPIED('C'), + DELETED('D'), + RENAMED('R'), + UNRESOLVED('U'), + TYPE_CHANGED('T') + ; + + private char myChar; + + GitChangeType(char c) { + myChar = c; + } + + @NotNull + static GitChangeType fromString(@NotNull String statusString) { + assert statusString.length() > 0; + char c = statusString.charAt(0); + for (GitChangeType changeType : values()) { + if (changeType.myChar == c) { + return changeType; + } + } + throw new GitFormatException("Unexpected status [" + statusString + "]"); + } + +} diff --git a/plugins/git4idea/src/git4idea/history/GitHistoryUtils.java b/plugins/git4idea/src/git4idea/history/GitHistoryUtils.java index 04fab6b20fdc..565063e5fd0a 100644 --- a/plugins/git4idea/src/git4idea/history/GitHistoryUtils.java +++ b/plugins/git4idea/src/git4idea/history/GitHistoryUtils.java @@ -157,7 +157,7 @@ public class GitHistoryUtils { return null; } GitLogRecord record = parser.parseOneRecord(result); - final List changes = record.coolChangesParser(project, root); + final List changes = record.parseChanges(project, root); boolean exists = ! FileStatus.DELETED.equals(changes.get(0).getFileStatus()); record.setUsedHandler(h); return new ItemLatestState(new GitRevisionNumber(record.getHash(), record.getDate()), exists, false); @@ -402,7 +402,7 @@ public class GitHistoryUtils { if (records.isEmpty()) return null; // we have information about all changed files of the commit. Extracting information about the file we need. - final List changes = records.get(0).coolChangesParser(project, root); + final List changes = records.get(0).parseChanges(project, root); for (Change change : changes) { if ((change.isMoved() || change.isRenamed()) && filePath.equals(change.getAfterRevision().getFile())) { return change.getBeforeRevision().getFile(); @@ -646,7 +646,7 @@ public class GitHistoryUtils { new HashSet(Arrays.asList(record.getParentsShortHashes())), record.getFilePaths(root), record.getAuthorEmail(), record.getCommitterEmail(), tags, locals, remotes, - record.coolChangesParser(project, root), record.getAuthorTimeStamp() * 1000 + record.parseChanges(project, root), record.getAuthorTimeStamp() * 1000 ); gitCommit.setCurrentBranch(s); /*final String current = refs.getCurrent().getName(); diff --git a/plugins/git4idea/src/git4idea/history/GitLogParser.java b/plugins/git4idea/src/git4idea/history/GitLogParser.java index ed88fe493536..4381c1e40ece 100644 --- a/plugins/git4idea/src/git4idea/history/GitLogParser.java +++ b/plugins/git4idea/src/git4idea/history/GitLogParser.java @@ -18,12 +18,17 @@ package git4idea.history; import com.intellij.openapi.project.Project; import com.intellij.openapi.util.text.StringUtil; import com.intellij.util.Function; +import git4idea.GitFormatException; import git4idea.GitVcs; import git4idea.config.GitVersionSpecialty; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import java.util.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** *

Parses the 'git log' output basing on the given number of options. @@ -53,12 +58,11 @@ import java.util.*; class GitLogParser { // Single records begin with %x01, end with %03. Items of commit information (hash, committer, subject, etc.) are separated by %x02. // each character is declared twice - for Git pattern format and for actual character in the output. - // separators are declared as String instead of char, because String#split() is heavily used in parsing. public static final String RECORD_START = "\u0001"; - public static final String RECORD_START_GIT = "%x01"; public static final String ITEMS_SEPARATOR = "\u0002"; - private static final String ITEMS_SEPARATOR_GIT = "%x02"; public static final String RECORD_END = "\u0003"; + private static final String RECORD_START_GIT = "%x01"; + private static final String ITEMS_SEPARATOR_GIT = "%x02"; private static final String RECORD_END_GIT = "%x03"; private final String myFormat; // pretty custom format generated in the constructor @@ -66,6 +70,30 @@ class GitLogParser { private final boolean mySupportsRawBody; private final NameStatus myNameStatusOption; + /** + * Record format: + * + * One git log record. + * RECORD_START - optional: it is split out when calling parse() but it is not when calling parseOneRecord() directly. + * commit information separated by ITEMS_SEPARATOR. + * RECORD_END + * Optionally: changed paths or paths with statuses (if --name-only or --name-status options are given). + * + * Example: + * 2c815939f45fbcfda9583f84b14fe9d393ada790sample commit + * D a.txt + */ + private static final Pattern ONE_RECORD = Pattern.compile(RECORD_START + "?(.*)" + RECORD_END + "\n*(.*)", Pattern.DOTALL); + private static final String SINGLE_PATH = "([^\t\r\n]+)"; // something not empty, not a tab or newline. + private static final String EOL = "\\s*(?:\r|\n|\r\n)"; + private static final String PATHS = + SINGLE_PATH + // First path - required. + "(?:\t" + SINGLE_PATH + ")?" + // Second path - optional. Paths are separated by tab. + EOL; // Path(s) information ends with a line terminator. + + private static Pattern NAME_ONLY = Pattern.compile(PATHS); + private static Pattern NAME_STATUS = Pattern.compile("([\\S]+)\t" + PATHS); + // --name-only, --name-status or no flag enum NameStatus { /** No flag. */ @@ -151,72 +179,67 @@ class GitLogParser { * flags --name-only or name-status were provided). * @param line record to be parsed. * @return GitLogRecord with information about the revision or {@code null} if the given line is empty. + * @throws GitFormatException if the line is given in unexpected format. */ - @Nullable + @NotNull GitLogRecord parseOneRecord(@NotNull String line) { - // record format: - // - // - this may be splitted out in parse(). But if one calls this method directly, the char will be in place. - // - // - // - // \t\t - status is output in the case of NameStatus.STATUS, - // paths are output if NameStatus.NAME or NameStatus.STATUS. Two paths is the rename case. - // - // Blank line before name-status usually appears, but it also can absent (e.g. in --pretty=oneline format), so we shouldn't rely on this. - // Example: - // 2c815939f45fbcfda9583f84b14fe9d393ada790sample commit - // - // D a.txt - - if (line.isEmpty()) { return null; } - line = removeRecordStartIndicator(line); - + Matcher matcher = ONE_RECORD.matcher(line); + if (!matcher.matches()) { + throwGFE("ONE_RECORD didn't match", line); + } + String commitInfo = matcher.group(1); + if (commitInfo == null) { + throwGFE("No match for group#1 in", line); + } + + final Map res = parseCommitInfo(commitInfo); // parsing status and path (if given) final List paths = new ArrayList(1); - final boolean includeStatus = myNameStatusOption == NameStatus.STATUS; - final List> parts = includeStatus ? new ArrayList>() : null; - + final List statuses = new ArrayList(); + if (myNameStatusOption != NameStatus.NONE) { - final String[] infoAndPath = line.split(RECORD_END); - line = infoAndPath[0]; - if (infoAndPath.length > 1) { - // separator is \n for paths, space for paths and status - final List nameAndPathSplit = new ArrayList(Arrays.asList(infoAndPath[infoAndPath.length - 1].split("\n"))); - for (Iterator it = nameAndPathSplit.iterator(); it.hasNext();) { - if (it.next().trim().isEmpty()) { - it.remove(); + String pathsAndStatuses = matcher.group(2); + if (pathsAndStatuses == null) { + throwGFE("No match for group#2 in", line); + } + + if (myNameStatusOption == NameStatus.NAME) { + Matcher pathsMatcher = NAME_ONLY.matcher(pathsAndStatuses); + while (pathsMatcher.find()) { + String path1 = pathsMatcher.group(1); + String path2 = pathsMatcher.group(2); + assertNotNull(path1, "path", pathsAndStatuses); + paths.add(path1); + if (path2 != null) { // null is perfectly legal here: second path is given only in case of rename + paths.add(path2); } } - - for (String pathLine : nameAndPathSplit) { - String[] partsArr; - if (includeStatus) { - final int idx = pathLine.indexOf("\t"); - if (idx != -1) { - final String whatLeft = pathLine.substring(idx).trim(); - partsArr = whatLeft.split("\\t"); - final List strings = new ArrayList(partsArr.length + 1); - strings.add(pathLine.substring(0, 1)); - strings.addAll(Arrays.asList(partsArr)); - parts.add(strings); - } else { - partsArr = pathLine.split("\\t"); // should not - } - } else { - partsArr = pathLine.split("\\t"); + } else { + Matcher nameStatusMatcher = NAME_STATUS.matcher(pathsAndStatuses); + while (nameStatusMatcher.find()) { + String status = nameStatusMatcher.group(1); + String path1 = nameStatusMatcher.group(2); + String path2 = nameStatusMatcher.group(3); + assertNotNull(status, "status", pathsAndStatuses); + assertNotNull(path1, "path1", pathsAndStatuses); + paths.add(path1); + if (path2 != null) { + paths.add(path2); } - paths.addAll(Arrays.asList(partsArr)); + statuses.add(new GitLogStatusInfo(GitChangeType.fromString(status), path1, path2)); } } - } else { - line = line.substring(0, line.length()-1); // removing the last character which is RECORD_END } + return new GitLogRecord(res, paths, statuses, mySupportsRawBody); + } + + @NotNull + private Map parseCommitInfo(@NotNull String commitInfo) { // parsing revision information // we rely on the order of options - final String[] values = line.split(ITEMS_SEPARATOR); + final String[] values = commitInfo.split(ITEMS_SEPARATOR); final Map res = new HashMap(values.length); int i = 0; for (; i < values.length && i < myOptions.length; i++) { // fill valid values @@ -225,16 +248,17 @@ class GitLogParser { for (; i < myOptions.length; i++) { // options which were not returned are set to blank string, extra options are ignored. res.put(myOptions[i], ""); } - return new GitLogRecord(res, paths, parts, mySupportsRawBody); + return res; } - @NotNull - private static String removeRecordStartIndicator(@NotNull String line) { - // We may have indicator at the beginning of the line (if we called parseOneLine directly), may not (if we called parse()). - // If we have, get rid of it. - if (line.charAt(0) == RECORD_START.charAt(0)) { - line = line.substring(RECORD_START.length()); + private static void assertNotNull(String value, String valueName, String line) { + if (value == null) { + throwGFE("Unexpectedly null " + valueName + " in ", line); } - return line; } + + private static void throwGFE(String message, String line) { + throw new GitFormatException(message + " [" + StringUtil.escapeStringCharacters(line) + "]"); + } + } diff --git a/plugins/git4idea/src/git4idea/history/GitLogRecord.java b/plugins/git4idea/src/git4idea/history/GitLogRecord.java index 7ece2f0e99e3..4436abf6769d 100644 --- a/plugins/git4idea/src/git4idea/history/GitLogRecord.java +++ b/plugins/git4idea/src/git4idea/history/GitLogRecord.java @@ -43,16 +43,18 @@ import static git4idea.history.GitLogParser.GitLogOption.*; * @see git4idea.history.GitLogParser */ class GitLogRecord { + private final Map myOptions; private final List myPaths; - private final List> myParts; + private final List myStatusInfo; private final boolean mySupportsRawBody; + private GitHandler myHandler; - GitLogRecord(Map options, List paths, List> parts, boolean supportsRawBody) { + GitLogRecord(@NotNull Map options, @NotNull List paths, @NotNull List statusInfo, boolean supportsRawBody) { myOptions = options; myPaths = paths; - myParts = parts; + myStatusInfo = statusInfo; mySupportsRawBody = supportsRawBody; } @@ -60,10 +62,6 @@ class GitLogRecord { return myPaths; } - private List> getParts() { - return myParts; - } - @NotNull public List getFilePaths(VirtualFile root) throws VcsException { List res = new ArrayList(); @@ -175,44 +173,48 @@ class GitLogRecord { return new String(raw); } - public List coolChangesParser(Project project, VirtualFile vcsRoot) throws VcsException { - final List result = new ArrayList(); - final GitRevisionNumber thisRevision = new GitRevisionNumber(getHash(), getDate()); + public List parseChanges(Project project, VirtualFile vcsRoot) throws VcsException { + GitRevisionNumber thisRevision = new GitRevisionNumber(getHash(), getDate()); + List parentRevisions = prepareParentRevisions(); + + List result = new ArrayList(); + for (GitLogStatusInfo statusInfo: myStatusInfo) { + result.add(parseChange(project, vcsRoot, parentRevisions, statusInfo, thisRevision)); + } + return result; + } + + private List prepareParentRevisions() { final String[] parentsShortHashes = getParentsShortHashes(); final List parents = new ArrayList(parentsShortHashes.length); for (String parentsShortHash : parentsShortHashes) { parents.add(AbstractHash.create(parentsShortHash)); } - final List> parts = getParts(); - if (parts != null) { - for (List partsPart: parts) { - result.add(parseChange(project, vcsRoot, parents, partsPart, thisRevision)); - } - } - return result; - } - private Change parseChange(final Project project, final VirtualFile vcsRoot, final List parents, - final List parts, final VcsRevisionNumber thisRevision) throws VcsException { - final ContentRevision before; - final ContentRevision after; - FileStatus status = null; - final String path = parts.get(1); final List parentRevisions = new ArrayList(parents.size()); for (AbstractHash parent : parents) { parentRevisions.add(new GitRevisionNumber(parent.getString())); } + return parentRevisions; + } - switch (parts.get(0).charAt(0)) { - case 'C': - case 'A': + private static Change parseChange(final Project project, final VirtualFile vcsRoot, final List parentRevisions, + final GitLogStatusInfo statusInfo, final VcsRevisionNumber thisRevision) throws VcsException { + final ContentRevision before; + final ContentRevision after; + FileStatus status = null; + final String path = statusInfo.getFirstPath(); + + switch (statusInfo.getType()) { + case ADDED: + case COPIED: before = null; status = FileStatus.ADDED; after = GitContentRevision.createRevision(vcsRoot, path, thisRevision, project, false, false, true); break; - case 'U': + case UNRESOLVED: status = FileStatus.MERGED_WITH_CONFLICTS; - case 'M': + case MODIFIED: if (status == null) { status = FileStatus.MODIFIED; } @@ -220,26 +222,26 @@ class GitLogRecord { before = GitContentRevision.createMultipleParentsRevision(project, filePath, parentRevisions); after = GitContentRevision.createRevision(vcsRoot, path, thisRevision, project, false, false, true); break; - case 'D': + case DELETED: status = FileStatus.DELETED; final FilePath filePathDeleted = GitContentRevision.createPath(vcsRoot, path, true, true, true); before = GitContentRevision.createMultipleParentsRevision(project, filePathDeleted, parentRevisions); after = null; break; - case 'R': + case RENAMED: status = FileStatus.MODIFIED; - final FilePath filePathAfterRename = GitContentRevision.createPath(vcsRoot, parts.get(2), false, false, true); + final FilePath filePathAfterRename = GitContentRevision.createPath(vcsRoot, statusInfo.getSecondPath(), false, false, true); after = GitContentRevision.createMultipleParentsRevision(project, filePathAfterRename, parentRevisions); before = GitContentRevision.createRevision(vcsRoot, path, thisRevision, project, true, true, true); break; - case 'T': + case TYPE_CHANGED: status = FileStatus.MODIFIED; final FilePath filePath2 = GitContentRevision.createPath(vcsRoot, path, false, true, true); before = GitContentRevision.createMultipleParentsRevision(project, filePath2, parentRevisions); after = GitContentRevision.createRevision(vcsRoot, path, thisRevision, project, false, false, true); break; default: - throw new VcsException("Unknown file status: " + Arrays.asList(parts)); + throw new AssertionError("Unknown file status: " + statusInfo); } return new Change(before, after, status); } diff --git a/plugins/git4idea/src/git4idea/history/GitLogStatusInfo.java b/plugins/git4idea/src/git4idea/history/GitLogStatusInfo.java new file mode 100644 index 000000000000..ba1fef8bb894 --- /dev/null +++ b/plugins/git4idea/src/git4idea/history/GitLogStatusInfo.java @@ -0,0 +1,64 @@ +/* + * Copyright 2000-2011 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package git4idea.history; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Information about a single file change as returned by {@code git log status --name-status}. + * + * @author Kirill Likhodedov + */ +class GitLogStatusInfo { + private final GitChangeType myType; + private final String myPath; + private final String mySecondPath; + + GitLogStatusInfo(@NotNull GitChangeType type, @NotNull String path, @Nullable String secondPath) { + verifyParameters(type, path, secondPath); + mySecondPath = secondPath; + myPath = path; + myType = type; + } + + /** + * SecondPath == null iff it the type is rename + */ + private static void verifyParameters(@NotNull GitChangeType type, @NotNull String path, @Nullable String secondPath) { + if (type == GitChangeType.RENAMED) { + assert secondPath != null; + } else { + assert secondPath == null; + } + } + + @NotNull + public String getFirstPath() { + return myPath; + } + + @NotNull + public GitChangeType getType() { + return myType; + } + + @Nullable + public String getSecondPath() { + return mySecondPath; + } + +} diff --git a/plugins/git4idea/tests/git4idea/history/GitLogParserTest.java b/plugins/git4idea/tests/git4idea/history/GitLogParserTest.java index d90ed168bdfe..6c048bca9e8b 100644 --- a/plugins/git4idea/tests/git4idea/history/GitLogParserTest.java +++ b/plugins/git4idea/tests/git4idea/history/GitLogParserTest.java @@ -211,7 +211,7 @@ public class GitLogParserTest extends GitTest { assertPaths(actual.getFilePaths(myRoot), expected.paths()); } else if (option == GitTestLogRecord.NameStatusOption.STATUS) { assertPaths(actual.getFilePaths(myRoot), expected.paths()); - assertChanges(actual.coolChangesParser(myProject, myRoot), expected.changes()); + assertChanges(actual.parseChanges(myProject, myRoot), expected.changes()); } }