package ru.batrdmi.svnplugin.logic;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.progress.ProgressIndicator;
import com.intellij.openapi.vfs.VirtualFile;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.idea.svn.SvnVcs;
import org.jetbrains.idea.svn.api.NodeKind;
import org.jetbrains.idea.svn.info.Info;
import org.tmatesoft.svn.core.*;
import org.tmatesoft.svn.core.internal.util.SVNDate;
import org.tmatesoft.svn.core.io.SVNCapability;
import org.tmatesoft.svn.core.io.SVNRepository;
import org.tmatesoft.svn.core.wc.SVNRevision;
import java.util.*;
import static org.tmatesoft.svn.core.io.SVNRepository.INVALID_REVISION;
public class FileHistoryRetriever {
private static final Logger log = Logger.getInstance("ru.batrdmi.svnplugin.logic.FileHistoryRetriever");
private final SvnVcs svn;
private final VirtualFile file;
public FileHistoryRetriever(SvnVcs svn, VirtualFile file) {
this.svn = svn;
this.file = file;
}
public FileRevisionHistory getFileHistory(ScanMode scanMode, final ProgressIndicator progressIndicator)
throws SVNException {
boolean retrievedWithErrors = false;
final CollectedRevisions revisions = new CollectedRevisions();
Info info = svn.getInfo(file);
if (info == null) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.UNKNOWN, "Error retrieving SVN information for file"));
}
SVNRevision committedRevision = info.getRevision();
if (committedRevision == null) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.UNKNOWN, "Error determining current file revision"));
}
boolean isDirectory = info.getKind() != NodeKind.FILE;
final long currentRevision = committedRevision.getNumber();
String repoRoot = info.getRepositoryRootURL().toString();
final String relPath = info.getURL().toString().substring(repoRoot.length());
SVNRepository repo = svn.getSvnKitManager().createRepository(repoRoot);
boolean mergeInfoAvailable = repo.hasCapability(SVNCapability.MERGE_INFO);
showProgressInfo(progressIndicator, "Getting latest repository revision");
long latestRevision = repo.getLatestRevision();
ExtendedScanManager esm = new ExtendedScanManager(repo, latestRevision, progressIndicator);
final Deque<Task> pathsToProcess = new LinkedList<Task>();
boolean retryWithoutMergeInfo = false;
pathsToProcess.add(new Task(relPath, currentRevision, true));
while (!pathsToProcess.isEmpty()) {
checkCancelled(progressIndicator);
final Task task = pathsToProcess.remove();
if (revisions.contain(task.relPath, task.revision)) {
continue;
}
if (scanMode != ScanMode.ONLY_IMPACTING_PATHS) {
pathsToProcess.addAll(esm.getPotentialTargets(task.relPath, scanMode == ScanMode.INCLUDE_CURRENT_BRANCHES_AND_TAGS));
checkCancelled(progressIndicator);
}
try {
showProgressInfo(progressIndicator, "Obtaining log info for " + task.relPath);
final List<Revision> revisionChain = new ArrayList<Revision>();
final List<Task> newTasks = new ArrayList<Task>();
// find deleted revision for current scan point to determine limits of log request
long deletedRevision = (task.revision == latestRevision) ? INVALID_REVISION
: repo.getDeletedRevision(task.relPath, task.revision, latestRevision);
if (deletedRevision >= 0) {
checkCancelled(progressIndicator);
SVNProperties p = repo.getRevisionProperties(deletedRevision, null);
revisionChain.add(new Revision(task.relPath, deletedRevision,
p.getStringValue(SVNRevisionProperty.AUTHOR),
SVNDate.parseDateString(p.getStringValue(SVNRevisionProperty.DATE)),
p.getStringValue(SVNRevisionProperty.LOG),
Collections.<Revision>emptyList(), null, SVNLogEntryPath.TYPE_DELETED));
}
// request log for current scan point
MergeInfoProcessor handler = new MergeInfoProcessor(task.relPath, new LogEntryWithMergeInfoHandler() {
@Override
public void handleLogEntry(SVNLogEntry logEntry, @NotNull List<Revision> mergedRevisions)
throws SVNCancelException {
checkCancelled(progressIndicator);
Revision.LinkType linkType = null;
// check copy sources
Revision copyFromRevision = getCopyFromRevision(logEntry, task.relPath);
if (copyFromRevision != null) {
linkType = Revision.LinkType.COPY;
mergedRevisions = Arrays.asList(copyFromRevision);
} else if (!mergedRevisions.isEmpty()) {
linkType = Revision.LinkType.MERGE;
}
long revision = logEntry.getRevision();
char changeType = getChangeType(logEntry, task.relPath);
Revision rgRev = new Revision(task.relPath, revision,
logEntry.getAuthor(), logEntry.getDate(), logEntry.getMessage(),
mergedRevisions, linkType, changeType);
revisionChain.add(rgRev);
if (!mergedRevisions.isEmpty()) {
Revision linkRev = mergedRevisions.get(0);
newTasks.add(new Task(linkRev.getRelPath(), linkRev.getRevisionNumber(), true));
}
if (changeType == SVNLogEntryPath.TYPE_REPLACED) {
newTasks.add(new Task(task.relPath, revision - 1, true));
}
showProgressInfo(progressIndicator, rgRev + " processed");
}
});
repo.log(new String[]{task.relPath}, deletedRevision < 0 ? INVALID_REVISION : (deletedRevision - 1), 0,
true, true, 0, mergeInfoAvailable && !retryWithoutMergeInfo , null, handler);
handler.checkFinalState();
revisions.addRevisionChain(revisionChain);
pathsToProcess.addAll(newTasks);
retryWithoutMergeInfo = false;
} catch (SVNCancelException e) {
throw e;
} catch (MalformedSVNResponseException e) {
if (retryWithoutMergeInfo) {
throw e; // should not happen
}
log.warn("Malformed mergeinfo returned for " + task + ". Requesting history for it again, without mergeinfo");
retrievedWithErrors = true;
retryWithoutMergeInfo = true;
pathsToProcess.addFirst(task);
} catch (SVNException e) {
if (!task.errorIfAbsent && e.getErrorMessage().getErrorCode() == SVNErrorCode.FS_NOT_FOUND) {
log.info(task + " not found. Probably the target is absent in corresponding branch or tg");
} else {
log.error("Error processing revisions for " + task, e);
retrievedWithErrors = true;
retryWithoutMergeInfo = false;
}
}
}
// determining actual current revision
Revision currentRev = revisions.getActualRevision(relPath, currentRevision);
return new FileRevisionHistory(revisions.getRevisions(), currentRev, repoRoot, relPath, isDirectory,
new FileRevisionHistory.RetrievalStatus(retrievedWithErrors, !mergeInfoAvailable));
}
private static boolean isAffectingPath(String targetPath, String changePath) {
return targetPath.startsWith(changePath);
}
@SuppressWarnings("unchecked")
private static Revision getCopyFromRevision(SVNLogEntry le, String targetPath) {
Map<String, SVNLogEntryPath> changedPaths = le.getChangedPaths();
String longestAffectingPath = "";
Revision result = null;
for (SVNLogEntryPath logEntryPath : changedPaths.values()) {
String path = logEntryPath.getPath();
String copyPath = logEntryPath.getCopyPath();
if (copyPath != null && isAffectingPath(targetPath, path)
&& path.length() >= longestAffectingPath.length()) {
String copyFromPath = copyPath + targetPath.substring(path.length());
long copyFromRevision = logEntryPath.getCopyRevision();
result = new Revision(copyFromPath, copyFromRevision);
longestAffectingPath = path;
}
}
return result;
}
@SuppressWarnings("unchecked")
private static char getChangeType(SVNLogEntry le, String targetPath) {
String longestAffectingPath = "";
boolean targetPathWasAdded = false;
char changeType = SVNLogEntryPath.TYPE_MODIFIED;
// finding change type for most 'precise' entry path (target path itself or nearest enclosing folder)
Map<String, SVNLogEntryPath> changedPaths = le.getChangedPaths();
for (SVNLogEntryPath logEntryPath : changedPaths.values()) {
String entryPath = logEntryPath.getPath();
char entryChangeType = logEntryPath.getType();
if (targetPath.startsWith(entryPath)) {
if (entryChangeType == SVNLogEntryPath.TYPE_ADDED) {
targetPathWasAdded = true;
}
if (entryPath.length() >= longestAffectingPath.length()) {
changeType = entryChangeType;
longestAffectingPath = entryPath;
}
}
}
// correction for SVN 'feature': R type doesn't always indicate replacement
if (changeType == SVNLogEntryPath.TYPE_REPLACED && targetPathWasAdded) {
changeType = SVNLogEntryPath.TYPE_ADDED;
}
return changeType;
}
@SuppressWarnings("unchecked")
private static Revision guessMergedRevision(String targetPath, SVNLogEntry mergedRevision) {
Map<String, SVNLogEntryPath> changedPaths = mergedRevision.getChangedPaths();
for (SVNLogEntryPath lep : changedPaths.values()) {
String[] targetSplit = FileNameUtil.splitPath(targetPath);
String[] mergeSplit = FileNameUtil.splitPath(lep.getPath());
if (targetSplit != null && mergeSplit != null && !targetSplit[1].equals(mergeSplit[1])) {
return new Revision(mergeSplit[0] + mergeSplit[1] + targetSplit[2], mergedRevision.getRevision());
}
}
return null;
}
private static void showProgressInfo(ProgressIndicator progressIndicator, String message) {
if (progressIndicator != null) {
progressIndicator.setText2(message);
}
}
private static void checkCancelled(ProgressIndicator progressIndicator) throws SVNCancelException {
if (progressIndicator.isCanceled()) {
throw new SVNCancelException();
}
}
public SVNProperties getFileProperties(Revision revision) throws SVNException {
Info info = svn.getInfo(file);
if (info == null) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.UNKNOWN, "Error retrieving SVN information for file"));
}
String repoRoot = info.getRepositoryRootURL().toString();
SVNRepository repo = svn.getSvnKitManager().createRepository(repoRoot);
SVNProperties properties = new SVNProperties();
if (info.getKind() == NodeKind.FILE) {
repo.getFile(revision.getRelPath(), revision.getRevisionNumber(), properties, null);
} else {
repo.getDir(revision.getRelPath(), revision.getRevisionNumber(), properties, (Collection) null);
}
return properties.getRegularProperties();
}
private static class Task {
public final String relPath;
public final long revision;
public final boolean errorIfAbsent;
private Task(String relPath, long revision, boolean errorIfAbsent) {
this.relPath = relPath;
this.revision = revision;
this.errorIfAbsent = errorIfAbsent;
}
@Override
public String toString() {
return relPath + "@" + revision;
}
}
private static class CollectedRevisions {
final Set<Revision> revisions = new HashSet<Revision>();
final Map<String, List<List<Revision>>> chainsByPath = new HashMap<String, List<List<Revision>>>();
public void addRevisionChain(List<Revision> revisionChain) throws SVNException {
if (revisionChain.isEmpty()) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.UNKNOWN, "No revisions found"));
}
String relPath = revisionChain.get(0).getRelPath();
List<List<Revision>> chainList = chainsByPath.get(relPath);
if (chainList == null) {
chainList = new LinkedList<List<Revision>>();
chainsByPath.put(relPath, chainList);
}
ListIterator<List<Revision>> it = chainList.listIterator();
long lastRev = revisionChain.get(revisionChain.size() - 1).getRevisionNumber();
while (it.hasNext()) {
List<Revision> chain = it.next();
if (lastRev >= chain.get(0).getRevisionNumber()) {
it.previous();
addChain(revisionChain, it);
return;
}
}
addChain(revisionChain, it);
}
private void addChain(List<Revision> revisionChain, ListIterator<List<Revision>> it) {
it.add(revisionChain);
for (Revision r : revisionChain) {
if (revisions.contains(r)) {
if (r.getChangeType() == SVNLogEntryPath.TYPE_REPLACED) {
revisions.remove(r);
} else {
continue;
}
}
revisions.add(r);
}
}
public boolean contain(String relPath, long revision) {
return getActualRevision(relPath, revision) != null;
}
public Revision getActualRevision(String relPath, long revision) {
List<List<Revision>> chainList = chainsByPath.get(relPath);
if (chainList == null) {
return null;
}
for (List<Revision> chain : chainList) {
Revision firstRev = chain.get(chain.size() - 1);
if (revision < firstRev.getRevisionNumber()) {
continue;
}
for (Revision r : chain) {
if (revision >= r.getRevisionNumber()) {
if (r.getChangeType() == SVNLogEntryPath.TYPE_DELETED) {
return null;
} else {
return r;
}
}
}
}
return null;
}
public Collection<Revision> getRevisions() {
// fix copy links
List<Revision> result = new ArrayList<Revision>(revisions.size());
for(Revision r : revisions) {
Revision resultRevision = r;
if (r.getLinkType() == Revision.LinkType.COPY) {
Revision actualRevision = getActualRevision(r.getLinkedRelPath(), r.getLinkedRevisionNumber());
if (actualRevision != null) {
resultRevision = new Revision(r.getRelPath(), r.getRevisionNumber(),
r.getAuthor(), r.getDate(), r.getMessage(),
Arrays.asList(actualRevision),
Revision.LinkType.COPY, r.getChangeType());
} else {
// this can happen when there was an error retrieving log for linked path
log.warn("Couldn't find actual copy-from revision for " + r);
}
}
result.add(resultRevision);
}
return result;
}
}
private static interface LogEntryWithMergeInfoHandler {
void handleLogEntry(SVNLogEntry logEntry, @NotNull List<Revision> mergedRevision) throws SVNCancelException;
}
private class MergeInfoProcessor implements ISVNLogEntryHandler {
private final LogEntryWithMergeInfoHandler consumer;
private final String relativePath;
private int depth = 0;
private SVNLogEntry savedEntry = null;
private List<Revision> mergedRevisions;
public MergeInfoProcessor(String relativePath, LogEntryWithMergeInfoHandler consumer) {
this.relativePath = relativePath;
this.consumer = consumer;
mergedRevisions = new ArrayList<Revision>();
}
@Override
@SuppressWarnings("unchecked")
public void handleLogEntry(SVNLogEntry logEntry) throws SVNException {
if (depth == 0) {
savedEntry = logEntry;
} else if (depth == 1 && logEntry.getRevision() != INVALID_REVISION) {
Revision mergedRevision = guessMergedRevision(relativePath, logEntry);
if (mergedRevision != null) {
mergedRevisions.add(mergedRevision);
}
}
if (logEntry.hasChildren()) {
depth++;
} else if (logEntry.getRevision() == INVALID_REVISION) {
if (--depth < 0) {
throw new MalformedSVNResponseException();
}
}
if (depth == 0) {
consumer.handleLogEntry(savedEntry, mergedRevisions);
mergedRevisions = new ArrayList<Revision>();
}
}
public void checkFinalState() throws SVNException {
if (depth != 0) {
throw new MalformedSVNResponseException();
}
}
}
private static class MalformedSVNResponseException extends SVNException {
public MalformedSVNResponseException() {
super(SVNErrorMessage.create(SVNErrorCode.UNKNOWN));
}
}
private static class ExtendedScanManager {
final SVNRepository repo;
final long revision;
final ProgressIndicator progressIndicator;
final Set<String> processedTrunkPaths;
final Map<String,Collection<String>> modulePaths;
public ExtendedScanManager(SVNRepository repo, long revision, ProgressIndicator progressIndicator) {
this.repo = repo;
this.revision = revision;
this.progressIndicator = progressIndicator;
this.processedTrunkPaths = new HashSet<String>();
this.modulePaths = new HashMap<String, Collection<String>>();
}
public Collection<Task> getPotentialTargets(String relPath, boolean includeTags) {
Collection<Task> result = new ArrayList<Task>();
String[] pathSplit = FileNameUtil.splitPath(relPath);
if (pathSplit == null) {
log.info("Couldn't determine branch/tag locations for " + relPath + ", will skip extended scan for it");
return result;
}
String trunkPath = pathSplit[0] + "/trunk" + pathSplit[2];
if (!processedTrunkPaths.add(trunkPath)) {
return result;
}
Collection<String> principalPaths = getPrincipalPathsForModule(pathSplit[0], includeTags);
for (String p : principalPaths) {
String path = p + pathSplit[2];
result.add(new Task(path, revision, false));
}
return result;
}
private Collection<String> getPrincipalPathsForModule(String module, boolean includeTags) {
Collection<String> paths = modulePaths.get(module);
if (paths != null) {
return paths;
}
showProgressInfo(progressIndicator, "Searching for branches and tags in " + module);
paths = new ArrayList<String>();
paths.add(module + "/trunk");
Collection<SVNDirEntry> entries = new ArrayList<SVNDirEntry>();
try {
String branchesPath = module + "/branches/";
repo.getDir(branchesPath, revision, false, entries);
for (SVNDirEntry de : entries) {
if (de.getKind() == SVNNodeKind.DIR) {
paths.add(branchesPath + de.getRelativePath());
}
}
} catch (SVNException e) {
log.warn("Error finding existing branches for module " + module, e);
}
if (includeTags) {
entries.clear();
try {
String tagsPath = module + "/tags/";
repo.getDir(tagsPath, revision, false, entries);
for (SVNDirEntry de : entries) {
if (de.getKind() == SVNNodeKind.DIR) {
paths.add(tagsPath + de.getRelativePath());
}
}
} catch (SVNException e) {
log.warn("Error finding existing tags for module " + module, e);
}
}
modulePaths.put(module, paths);
return paths;
}
}
}