GitLogParser refactoring & optimization

1. GitLogParser.parseOneRecord: reimplemented via regexp; throw a RuntimeException if format doesn't match.
2. Use GitLogStatusInfo instead of List<List<String>> to save information about a single status line for the further parsing in GitLogRecord.parseChanges().
This commit is contained in:
Kirill Likhodedov
2011-09-28 18:12:02 +04:00
parent 625b8802c8
commit 9c08420281
6 changed files with 244 additions and 102 deletions
@@ -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 + "]");
}
}
@@ -157,7 +157,7 @@ public class GitHistoryUtils {
return null;
}
GitLogRecord record = parser.parseOneRecord(result);
final List<Change> changes = record.coolChangesParser(project, root);
final List<Change> 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<Change> changes = records.get(0).coolChangesParser(project, root);
final List<Change> 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<String>(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();
@@ -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;
/**
* <p>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:
* 2c815939f45fbcfda9583f84b14fe9d393ada790<ITEM_SEPARATOR>sample commit<RECORD_END>
* 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:
//
// <record start> - this may be splitted out in parse(). But if one calls this method directly, the char will be in place.
// <commit info, possibly multilined - if body is multilined>
// <record end mark>
// <blank line (optional)>
// <name status (optional)>\t<path (optional)>\t<second path (optional)> - 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:
// 2c815939f45fbcfda9583f84b14fe9d393ada790<ITEM_SEPARATOR>sample commit<RECORD_END>
//
// 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<GitLogOption, String> res = parseCommitInfo(commitInfo);
// parsing status and path (if given)
final List<String> paths = new ArrayList<String>(1);
final boolean includeStatus = myNameStatusOption == NameStatus.STATUS;
final List<List<String>> parts = includeStatus ? new ArrayList<List<String>>() : null;
final List<GitLogStatusInfo> statuses = new ArrayList<GitLogStatusInfo>();
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<String> nameAndPathSplit = new ArrayList<String>(Arrays.asList(infoAndPath[infoAndPath.length - 1].split("\n")));
for (Iterator<String> 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<String> strings = new ArrayList<String>(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<GitLogOption, String> 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<GitLogOption, String> res = new HashMap<GitLogOption, String>(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 <RECORD_START> 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) + "]");
}
}
@@ -43,16 +43,18 @@ import static git4idea.history.GitLogParser.GitLogOption.*;
* @see git4idea.history.GitLogParser
*/
class GitLogRecord {
private final Map<GitLogParser.GitLogOption, String> myOptions;
private final List<String> myPaths;
private final List<List<String>> myParts;
private final List<GitLogStatusInfo> myStatusInfo;
private final boolean mySupportsRawBody;
private GitHandler myHandler;
GitLogRecord(Map<GitLogParser.GitLogOption, String> options, List<String> paths, List<List<String>> parts, boolean supportsRawBody) {
GitLogRecord(@NotNull Map<GitLogParser.GitLogOption, String> options, @NotNull List<String> paths, @NotNull List<GitLogStatusInfo> statusInfo, boolean supportsRawBody) {
myOptions = options;
myPaths = paths;
myParts = parts;
myStatusInfo = statusInfo;
mySupportsRawBody = supportsRawBody;
}
@@ -60,10 +62,6 @@ class GitLogRecord {
return myPaths;
}
private List<List<String>> getParts() {
return myParts;
}
@NotNull
public List<FilePath> getFilePaths(VirtualFile root) throws VcsException {
List<FilePath> res = new ArrayList<FilePath>();
@@ -175,44 +173,48 @@ class GitLogRecord {
return new String(raw);
}
public List<Change> coolChangesParser(Project project, VirtualFile vcsRoot) throws VcsException {
final List<Change> result = new ArrayList<Change>();
final GitRevisionNumber thisRevision = new GitRevisionNumber(getHash(), getDate());
public List<Change> parseChanges(Project project, VirtualFile vcsRoot) throws VcsException {
GitRevisionNumber thisRevision = new GitRevisionNumber(getHash(), getDate());
List<GitRevisionNumber> parentRevisions = prepareParentRevisions();
List<Change> result = new ArrayList<Change>();
for (GitLogStatusInfo statusInfo: myStatusInfo) {
result.add(parseChange(project, vcsRoot, parentRevisions, statusInfo, thisRevision));
}
return result;
}
private List<GitRevisionNumber> prepareParentRevisions() {
final String[] parentsShortHashes = getParentsShortHashes();
final List<AbstractHash> parents = new ArrayList<AbstractHash>(parentsShortHashes.length);
for (String parentsShortHash : parentsShortHashes) {
parents.add(AbstractHash.create(parentsShortHash));
}
final List<List<String>> parts = getParts();
if (parts != null) {
for (List<String> partsPart: parts) {
result.add(parseChange(project, vcsRoot, parents, partsPart, thisRevision));
}
}
return result;
}
private Change parseChange(final Project project, final VirtualFile vcsRoot, final List<AbstractHash> parents,
final List<String> parts, final VcsRevisionNumber thisRevision) throws VcsException {
final ContentRevision before;
final ContentRevision after;
FileStatus status = null;
final String path = parts.get(1);
final List<GitRevisionNumber> parentRevisions = new ArrayList<GitRevisionNumber>(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<GitRevisionNumber> 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);
}
@@ -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;
}
}
@@ -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());
}
}