changeset 691:72fc7774b87e

Fix file.isCopy() for blame/annotate. Refactor status and blame to use newly introduced FileHistory helper that builds file rename history
author Artem Tikhomirov <tikhomirov.artem@gmail.com>
date Fri, 02 Aug 2013 23:07:23 +0200
parents b286222158be
children e970b333f284
files src/org/tmatesoft/hg/core/HgChangesetFileSneaker.java src/org/tmatesoft/hg/internal/FileHistory.java src/org/tmatesoft/hg/internal/FileRenameHistory.java src/org/tmatesoft/hg/internal/FileRevisionHistoryChunk.java src/org/tmatesoft/hg/repo/HgStatusCollector.java test-data/test-repos.jar test/org/tmatesoft/hg/test/TestFileRenameUtils.java test/org/tmatesoft/hg/test/TestHistory.java
diffstat 8 files changed, 409 insertions(+), 147 deletions(-) [+]
line wrap: on
line diff
--- a/src/org/tmatesoft/hg/core/HgChangesetFileSneaker.java	Thu Aug 01 21:45:47 2013 +0200
+++ b/src/org/tmatesoft/hg/core/HgChangesetFileSneaker.java	Fri Aug 02 23:07:23 2013 +0200
@@ -152,7 +152,7 @@
 				if (csetIndex > csetFileEnds) {
 					return new Outcome(Outcome.Kind.Success, String.format("%s: last known changeset for the file %s is %d. Follow renames is possible towards older changesets only", phaseMsg, file, csetFileEnds));
 				}
-				// XXX code is similar to that in HgStatusCollector#getOriginIfCopy. Why it's different in lastOrigin processing then?
+				// @see FileRenameHistory, with similar code, which doesn't trace alternative paths
 				// traceback stack keeps record of all files with isCopy(fileRev) == true we've tried to follow, so that we can try earlier file
 				// revisions in case followed fileRev didn't succeed
 				ArrayDeque<Pair<HgDataFile, Integer>> traceback = new ArrayDeque<Pair<HgDataFile, Integer>>();
--- a/src/org/tmatesoft/hg/internal/FileHistory.java	Thu Aug 01 21:45:47 2013 +0200
+++ b/src/org/tmatesoft/hg/internal/FileHistory.java	Fri Aug 02 23:07:23 2013 +0200
@@ -17,14 +17,15 @@
 package org.tmatesoft.hg.internal;
 
 import static org.tmatesoft.hg.core.HgIterateDirection.NewToOld;
+import static org.tmatesoft.hg.core.HgIterateDirection.OldToNew;
 
 import java.util.Collections;
 import java.util.LinkedList;
 
 import org.tmatesoft.hg.core.HgIterateDirection;
 import org.tmatesoft.hg.core.Nodeid;
+import org.tmatesoft.hg.internal.FileRenameHistory.Chunk;
 import org.tmatesoft.hg.repo.HgDataFile;
-import org.tmatesoft.hg.repo.HgRepository;
 import org.tmatesoft.hg.repo.HgRuntimeException;
 
 /**
@@ -58,42 +59,28 @@
 	}
 
 	public void build() throws HgRuntimeException {
-		assert fileCompleteHistory.isEmpty();
-		HgDataFile currentFile = df;
-		final int changelogRevIndexEnd = csetTo;
-		final int changelogRevIndexStart = csetFrom;
-		int fileLastClogRevIndex = changelogRevIndexEnd;
-		FileRevisionHistoryChunk nextChunk = null;
 		fileCompleteHistory.clear(); // just in case, #build() is not expected to be called more than once
-		do {
-			FileRevisionHistoryChunk fileHistory = new FileRevisionHistoryChunk(currentFile);
-			fileHistory.init(fileLastClogRevIndex);
-			fileHistory.linkTo(nextChunk);
-			fileCompleteHistory.addFirst(fileHistory); // to get the list in old-to-new order
-			nextChunk = fileHistory;
-			if (fileHistory.changeset(0) > changelogRevIndexStart && currentFile.isCopy()) {
-				// fileHistory.changeset(0) is the earliest revision we know about so far,
-				// once we get to revisions earlier than the requested start, stop digging.
-				// The reason there's NO == (i.e. not >=) because:
-				// (easy): once it's equal, we've reached our intended start
-				// (hard): if changelogRevIndexStart happens to be exact start of one of renames in the 
-				// chain of renames (test-annotate2 repository, file1->file1a->file1b, i.e. points 
-				// to the very start of file1a or file1 history), presence of == would get us to the next 
-				// chunk and hence changed parents of present chunk's first element. Our annotate alg 
-				// relies on parents only (i.e. knows nothing about 'last iteration element') to find out 
-				// what to compare, and hence won't report all lines of 'last iteration element' (which is the
-				// first revision of the renamed file) as "added in this revision", leaving gaps in annotate
-				HgRepository repo = currentFile.getRepo();
-				Nodeid originLastRev = currentFile.getCopySourceRevision();
-				currentFile = repo.getFileNode(currentFile.getCopySourceName());
-				fileLastClogRevIndex = currentFile.getChangesetRevisionIndex(currentFile.getRevisionIndex(originLastRev));
-				// XXX perhaps, shall fail with meaningful exception if new file doesn't exist (.i/.d not found for whatever reason)
-				// or source revision is missing?
-			} else {
-				fileHistory.chopAtChangeset(changelogRevIndexStart);
-				currentFile = null; // stop iterating
+		Nodeid fileRev = df.getRepo().getManifest().getFileRevision(csetTo, df.getPath());
+		int fileRevIndex = df.getRevisionIndex(fileRev);
+		FileRenameHistory frh = new FileRenameHistory(csetFrom, csetTo);
+		if (frh.isOutOfRange(df, fileRevIndex)) {
+			return;
+		}
+		frh.build(df, fileRevIndex);
+		FileRevisionHistoryChunk prevChunk = null;
+		for (Chunk c : frh.iterate(OldToNew)) {
+			FileRevisionHistoryChunk fileHistory = new FileRevisionHistoryChunk(c.file(), c.firstCset(), c.lastCset(), c.firstFileRev(), c.lastFileRev());
+			fileHistory.init();
+			if (fileHistory.revisionCount() == 0) {
+				// no revisions on our cset range of interest
+				continue;
 			}
-		} while (currentFile != null && fileLastClogRevIndex > changelogRevIndexStart);
+			if (prevChunk != null) {
+				prevChunk.linkTo(fileHistory);
+			}
+			fileCompleteHistory.addLast(fileHistory); // to get the list in old-to-new order
+			prevChunk = fileHistory;
+		}
 		// fileCompleteHistory is in (origin, intermediate target, ultimate target) order
 	}
 	
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/org/tmatesoft/hg/internal/FileRenameHistory.java	Fri Aug 02 23:07:23 2013 +0200
@@ -0,0 +1,162 @@
+/*
+ * Copyright (c) 2013 TMate Software Ltd
+ *  
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 2 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * For information on how to redistribute this software under
+ * the terms of a license other than GNU General Public License
+ * contact TMate Software at support@hg4j.com
+ */
+package org.tmatesoft.hg.internal;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+
+import org.tmatesoft.hg.core.HgFileRevision;
+import org.tmatesoft.hg.core.HgIterateDirection;
+import org.tmatesoft.hg.repo.HgDataFile;
+
+/**
+ * Traces file renames. Quite similar to HgChangesetFileSneaker, although the latter tries different paths
+ * to find origin names, while this class traces first renames found only.
+ * 
+ * @author Artem Tikhomirov
+ * @author TMate Software Ltd.
+ */
+public final class FileRenameHistory {
+	
+	private final int csetFrom;
+	private final int csetTo;
+	private final List<Chunk> history;
+
+	public FileRenameHistory(int csetStartIndex, int csetEndIndex) {
+		csetFrom = csetStartIndex;
+		csetTo = csetEndIndex;
+		history = new ArrayList<Chunk>(3);
+	}
+	
+	public int startChangeset() {
+		return csetFrom;
+	}
+	
+	public int endChangeset() {
+		return csetTo;
+	}
+	
+	public boolean isOutOfRange(HgDataFile df, int fileRev) {
+		return df.getChangesetRevisionIndex(fileRev) < csetFrom || df.getChangesetRevisionIndex(0) > csetTo;
+	}
+	
+	public void build(HgDataFile df, int fileRev) {
+		assert !isOutOfRange(df, fileRev);
+		LinkedList<Chunk> chunks = new LinkedList<Chunk>();
+		int chunkStart = 0, chunkEnd = fileRev;
+		int csetChunkEnd = -1, csetChunkStart = -1;
+		while (fileRev >= 0) {
+			int cset = df.getChangesetRevisionIndex(fileRev);
+			if (csetChunkEnd == -1) {
+				csetChunkEnd = cset;
+			}
+			if (cset <= csetFrom) {
+				chunkStart = fileRev;
+				csetChunkStart = csetFrom;
+				break;
+			}
+			if (cset > csetTo) {
+				chunkEnd = --fileRev;
+				csetChunkEnd = -1;
+				continue;
+			}
+			csetChunkStart = cset;
+			if (df.isCopy(fileRev)) {
+				chunks.addFirst(new Chunk(df, fileRev, chunkEnd, csetChunkStart, csetChunkEnd));
+				HgFileRevision origin = df.getCopySource(fileRev);
+				df = df.getRepo().getFileNode(origin.getPath());
+				fileRev = chunkEnd = df.getRevisionIndex(origin.getRevision());
+				chunkStart = 0;
+				csetChunkEnd = cset - 1; // if df is copy, cset can't be 0
+				csetChunkStart = -1;
+			} else {
+				fileRev--;
+			}
+		}
+		assert chunkStart >= 0;
+		assert chunkEnd >= 0; // can be negative only if df.cset(0) > csetTo
+		assert csetChunkEnd >= 0;
+		assert csetChunkStart >= 0;
+		chunks.addFirst(new Chunk(df, chunkStart, chunkEnd, csetChunkStart, csetChunkEnd));
+
+		history.clear();
+		history.addAll(chunks);
+	}
+
+	public Iterable<Chunk> iterate(HgIterateDirection order) {
+		if (order == HgIterateDirection.NewToOld) {
+			return ReverseIterator.reversed(history);
+		}
+		assert order == HgIterateDirection.OldToNew;
+		return Collections.unmodifiableList(history);
+	}
+	
+	public int chunks() {
+		return history.size();
+	}
+	
+	public Chunk chunkAt(int cset) {
+		if (cset < csetFrom || cset > csetTo) {
+			return null;
+		}
+		for (Chunk c : history) {
+			if (c.firstCset() > cset) {
+				break;
+			}
+			if (cset <= c.lastCset()) {
+				return c;
+			}
+		}
+		return null;
+	}
+
+
+	/**
+	 * file has changes [firstFileRev..lastFileRev] that have occurred somewhere in [firstCset..lastCset] 
+	 */
+	public static class Chunk {
+		private final HgDataFile df;
+		private final int fileRevFrom;
+		private final int fileRevTo;
+		private final int csetFrom;
+		private final int csetTo;
+		Chunk(HgDataFile file, int fileRevStart, int fileRevEnd, int csetStart, int csetEnd) {
+			df = file;
+			fileRevFrom = fileRevStart;
+			fileRevTo = fileRevEnd;
+			csetFrom = csetStart;
+			csetTo = csetEnd;
+		}
+		public HgDataFile file() {
+			return df;
+		}
+		public int firstFileRev() {
+			return fileRevFrom;
+		}
+		public int lastFileRev() {
+			return fileRevTo;
+		}
+		public int firstCset() {
+			return csetFrom;
+		}
+		public int lastCset() {
+			return csetTo;
+		}
+	}
+}
--- a/src/org/tmatesoft/hg/internal/FileRevisionHistoryChunk.java	Thu Aug 01 21:45:47 2013 +0200
+++ b/src/org/tmatesoft/hg/internal/FileRevisionHistoryChunk.java	Fri Aug 02 23:07:23 2013 +0200
@@ -25,7 +25,6 @@
 import java.util.LinkedList;
 
 import org.tmatesoft.hg.core.HgIterateDirection;
-import org.tmatesoft.hg.core.Nodeid;
 import org.tmatesoft.hg.repo.HgDataFile;
 import org.tmatesoft.hg.repo.HgRepository;
 import org.tmatesoft.hg.repo.HgRuntimeException;
@@ -41,16 +40,22 @@
 	private final HgDataFile df;
 	// change ancestry, sequence of file revisions
 	private IntVector fileRevsToVisit;
-	// parent pairs of complete file history
+	// parent pairs of complete file history, index offset by fileRevFrom
 	private IntVector fileParentRevs;
-	// map file revision to changelog revision (sparse array, only file revisions to visit are set)
+	// map file revision to changelog revision (sparse array, only file revisions to visit are set), index offset by fileRevFrom
 	private int[] file2changelog;
 	private int originChangelogRev = BAD_REVISION, originFileRev = BAD_REVISION;
-	private int csetRangeStart = NO_REVISION, csetRangeEnd = BAD_REVISION; 
+	private final int csetFrom, csetTo; 
+	private final int fileRevFrom, fileRevTo;
 	
 
-	public FileRevisionHistoryChunk(HgDataFile file) {
+	public FileRevisionHistoryChunk(HgDataFile file, int csetStart, int csetEnd, int fileStart, int fileEnd) {
+		assert fileEnd >= fileStart;
 		df = file;
+		csetFrom = csetStart;
+		csetTo = csetEnd;
+		fileRevFrom = fileStart;
+		fileRevTo = fileEnd;
 	}
 	
 	/**
@@ -64,52 +69,57 @@
 	 * @return changeset this file history chunk was chopped at, or {@link HgRepository#NO_REVISION} if none specified
 	 */
 	public int getStartChangeset() {
-		return csetRangeStart;
+		return csetFrom;
 	}
 	
 	/**
 	 * @return changeset this file history chunk ends at
 	 */
 	public int getEndChangeset() {
-		return csetRangeEnd;
+		return csetTo;
 	}
 	
-	public void init(int changelogRevisionIndex) throws HgRuntimeException {
-		csetRangeEnd = changelogRevisionIndex;
-		// XXX df.indexWalk(0, fileRevIndex, ) might be more effective
-		Nodeid fileRev = df.getRepo().getManifest().getFileRevision(changelogRevisionIndex, df.getPath());
-		int fileRevIndex = df.getRevisionIndex(fileRev);
+	public void init() throws HgRuntimeException {
 		int[] fileRevParents = new int[2];
-		fileParentRevs = new IntVector((fileRevIndex+1) * 2, 0);
-		fileParentRevs.add(NO_REVISION, NO_REVISION); // parents of fileRevIndex == 0
-		for (int i = 1; i <= fileRevIndex; i++) {
+		final int totalFileRevs = fileRevTo - fileRevFrom + 1;
+		fileParentRevs = new IntVector(totalFileRevs * 2, 0);
+		// pretend parents of fileRevStart are not set, regardless of actual state as we are not going to visit them anyway
+		fileParentRevs.add(NO_REVISION, NO_REVISION); 
+		// XXX df.indexWalk(fileRevStart, fileRevEnd, ) might be more effective
+		for (int i = fileRevFrom+1; i <= fileRevTo; i++) {
 			df.parents(i, fileRevParents, null, null);
 			fileParentRevs.add(fileRevParents[0], fileRevParents[1]);
 		}
 		// fileRevsToVisit keep file change ancestry from new to old
-		fileRevsToVisit = new IntVector(fileRevIndex + 1, 0);
+		fileRevsToVisit = new IntVector(totalFileRevs, 0);
 		// keep map of file revision to changelog revision
-		file2changelog = new int[fileRevIndex+1];
+		file2changelog = new int[totalFileRevs];
 		// only elements worth visit would get mapped, so there would be unfilled areas in the file2changelog,
 		// prevent from error (make it explicit) by bad value
 		Arrays.fill(file2changelog, BAD_REVISION);
 		LinkedList<Integer> queue = new LinkedList<Integer>();
-		BitSet seen = new BitSet(fileRevIndex + 1);
-		queue.add(fileRevIndex);
+		BitSet seen = new BitSet(totalFileRevs);
+		queue.add(fileRevTo);
 		do {
-			int x = queue.removeFirst();
-			if (seen.get(x)) {
+			int fileRev = queue.removeFirst();
+			int offFileRev = fileRev - fileRevFrom;
+			if (seen.get(offFileRev)) {
 				continue;
 			}
-			seen.set(x);
-			fileRevsToVisit.add(x);
-			file2changelog[x] = df.getChangesetRevisionIndex(x);
-			int p1 = fileParentRevs.get(2*x);
-			int p2 = fileParentRevs.get(2*x + 1);
-			if (p1 != NO_REVISION) {
+			seen.set(offFileRev);
+			int csetRev = df.getChangesetRevisionIndex(fileRev);
+			if (csetRev < csetFrom || csetRev > csetTo) {
+				continue;
+			}
+			fileRevsToVisit.add(fileRev);
+
+			file2changelog[offFileRev] = csetRev;
+			int p1 = fileParentRevs.get(2*offFileRev);
+			int p2 = fileParentRevs.get(2*offFileRev + 1);
+			if (p1 != NO_REVISION && p1 >= fileRevFrom) {
 				queue.addLast(p1);
 			}
-			if (p2 != NO_REVISION) {
+			if (p2 != NO_REVISION && p2 >= fileRevFrom) {
 				queue.addLast(p2);
 			}
 		} while (!queue.isEmpty());
@@ -117,37 +127,13 @@
 		fileRevsToVisit.sort(false);
 	}
 	
-	public void linkTo(FileRevisionHistoryChunk target) {
-		// assume that target.init() has been called already 
-		if (target == null) {
+	public void linkTo(FileRevisionHistoryChunk next) {
+		// assume that init() has been called already 
+		if (next == null) {
 			return;
 		}
-		target.originFileRev = fileRevsToVisit.get(0); // files to visit are new to old
-		target.originChangelogRev = changeset(target.originFileRev);
-	}
-
-	/**
-	 * Mark revision closest(ceil) to specified as the very first one (no parents) 
-	 */
-	public void chopAtChangeset(int firstChangelogRevOfInterest) {
-		csetRangeStart = firstChangelogRevOfInterest;
-		if (firstChangelogRevOfInterest == 0) {
-			return; // nothing to do
-		}
-		int i = 0, x = fileRevsToVisit.size(), fileRev = BAD_REVISION;
-		// fileRevsToVisit is new to old, greater numbers to smaller
-		while (i < x && changeset(fileRev = fileRevsToVisit.get(i)) >= firstChangelogRevOfInterest) {
-			i++;
-		}
-		assert fileRev != BAD_REVISION; // there's at least 1 revision in fileRevsToVisit
-		if (i == x && changeset(fileRev) != firstChangelogRevOfInterest) {
-			assert false : "Requested changeset shall belong to the chunk";
-			return;
-		}
-		fileRevsToVisit.trimTo(i); // no need to iterate more
-		// pretend fileRev got no parents
-		fileParentRevs.set(fileRev * 2, NO_REVISION);
-		fileParentRevs.set(fileRev, NO_REVISION);
+		next.originFileRev = fileRevsToVisit.get(0); // files to visit are new to old
+		next.originChangelogRev = changeset(next.originFileRev);
 	}
 
 	public int[] fileRevisions(HgIterateDirection iterateOrder) {
@@ -172,30 +158,32 @@
 	}
 	
 	public int changeset(int fileRevIndex) {
-		return file2changelog[fileRevIndex];
+		return file2changelog[fileRevIndex - fileRevFrom];
 	}
 	
 	public void fillFileParents(int fileRevIndex, int[] fileParents) {
-		if (fileRevIndex == 0 && originFileRev != BAD_REVISION) {
+		if (fileRevIndex == fileRevFrom && originFileRev != BAD_REVISION) {
 			// this chunk continues another file
 			assert originFileRev != NO_REVISION;
 			fileParents[0] = originFileRev;
 			fileParents[1] = NO_REVISION;
 			return;
 		}
-		fileParents[0] = fileParentRevs.get(fileRevIndex * 2);
-		fileParents[1] = fileParentRevs.get(fileRevIndex * 2 + 1);
+		int x = fileRevIndex - fileRevFrom;
+		fileParents[0] = fileParentRevs.get(x * 2);
+		fileParents[1] = fileParentRevs.get(x * 2 + 1);
 	}
 	
 	public void fillCsetParents(int fileRevIndex, int[] csetParents) {
-		if (fileRevIndex == 0 && originFileRev != BAD_REVISION) {
-			assert originFileRev != NO_REVISION;
+		if (fileRevIndex == fileRevFrom && originFileRev != BAD_REVISION) {
+			assert originChangelogRev != NO_REVISION;
 			csetParents[0] = originChangelogRev;
 			csetParents[1] = NO_REVISION; // I wonder if possible to start a copy with two parents?
 			return;
 		}
-		int fp1 = fileParentRevs.get(fileRevIndex * 2);
-		int fp2 = fileParentRevs.get(fileRevIndex * 2 + 1);
+		int x = fileRevIndex - fileRevFrom;
+		int fp1 = fileParentRevs.get(x * 2);
+		int fp2 = fileParentRevs.get(x * 2 + 1);
 		csetParents[0] = fp1 == NO_REVISION ? NO_REVISION : changeset(fp1);
 		csetParents[1] = fp2 == NO_REVISION ? NO_REVISION : changeset(fp2);
 	}
--- a/src/org/tmatesoft/hg/repo/HgStatusCollector.java	Thu Aug 01 21:45:47 2013 +0200
+++ b/src/org/tmatesoft/hg/repo/HgStatusCollector.java	Fri Aug 02 23:07:23 2013 +0200
@@ -26,8 +26,9 @@
 import java.util.Map;
 import java.util.TreeSet;
 
-import org.tmatesoft.hg.core.HgFileRevision;
 import org.tmatesoft.hg.core.Nodeid;
+import org.tmatesoft.hg.internal.FileRenameHistory;
+import org.tmatesoft.hg.internal.FileRenameHistory.Chunk;
 import org.tmatesoft.hg.internal.IntMap;
 import org.tmatesoft.hg.internal.ManifestRevision;
 import org.tmatesoft.hg.internal.Pool;
@@ -376,45 +377,21 @@
 		assert fnameRev != null;
 		assert !Nodeid.NULL.equals(fnameRev); 
 		int fileRevIndex = fnameRev == null ? 0 : df.getRevisionIndex(fnameRev);
-		Path lastOriginFound = null;
-		while(fileRevIndex >=0) {
-			if (!df.isCopy(fileRevIndex)) {
-				fileRevIndex--;
-				continue;
-			}
-			int csetRevIndex = df.getChangesetRevisionIndex(fileRevIndex);
-			if (csetRevIndex <= originalChangesetIndex) {
-				// we've walked past originalChangelogRevIndex and no chances we'll find origin
-				// if we get here, it means either fname's origin is not from the base revision
-				// or the last found rename is still valid
-				return lastOriginFound;
-			}
-			HgFileRevision origin = df.getCopySource(fileRevIndex);
-			// prepare for the next step, df(copyFromFileRev) would point to copy origin and its revision
-			df = hgRepo.getFileNode(origin.getPath());
-			int copyFromFileRevIndex = df.getRevisionIndex(origin.getRevision());
-			if (originals.contains(origin.getPath())) {
-				int copyFromCsetIndex = df.getChangesetRevisionIndex(copyFromFileRevIndex);
-				if (copyFromCsetIndex <= originalChangesetIndex) {
-					// copy/rename source was known prior to rev1 
-					// (both r1Files.contains is true and original was created earlier than rev1)
-					// without r1Files.contains changelogRevision <= rev1 won't suffice as the file
-					// might get removed somewhere in between (changelogRevision < R < rev1)
-					return origin.getPath();
-				}
-				// copy/rename happened in [copyFromCsetIndex..target], let's see if
-				// origin wasn't renamed once more in [originalChangesetIndex..copyFromCsetIndex]
-				lastOriginFound = origin.getPath();
-				// FALL-THROUGH
-			} else {
-				// clear last known origin if the file was renamed once again to something we don't have in base
-				lastOriginFound = null;
-			}
-			// try more steps away
-			// copyFromFileRev or one of its predecessors might be copies as well
-			fileRevIndex = copyFromFileRevIndex; // df is already origin file
+		FileRenameHistory frh = new FileRenameHistory(originalChangesetIndex, df.getChangesetRevisionIndex(fileRevIndex));
+		if (frh.isOutOfRange(df, fileRevIndex)) {
+			return null;
 		}
-		return lastOriginFound;
+		frh.build(df, fileRevIndex);
+		Chunk c = frh.chunkAt(originalChangesetIndex);
+		if (c == null) {
+			// file rename history doesn't go deep up to changeset of interest
+			return null;
+		}
+		Path nameAtOrigin = c.file().getPath();
+		if (originals.contains(nameAtOrigin)) {
+			return nameAtOrigin;
+		}
+		return null;
 	}
 
 	// XXX for r1..r2 status, only modified, added, removed (and perhaps, clean) make sense
Binary file test-data/test-repos.jar has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/test/org/tmatesoft/hg/test/TestFileRenameUtils.java	Fri Aug 02 23:07:23 2013 +0200
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2013 TMate Software Ltd
+ *  
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 2 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * For information on how to redistribute this software under
+ * the terms of a license other than GNU General Public License
+ * contact TMate Software at support@hg4j.com
+ */
+package org.tmatesoft.hg.test;
+
+import static org.tmatesoft.hg.core.HgIterateDirection.NewToOld;
+import static org.tmatesoft.hg.core.HgIterateDirection.OldToNew;
+
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.tmatesoft.hg.core.HgChangesetFileSneaker;
+import org.tmatesoft.hg.core.HgException;
+import org.tmatesoft.hg.core.HgIterateDirection;
+import org.tmatesoft.hg.internal.FileRenameHistory;
+import org.tmatesoft.hg.internal.FileRenameHistory.Chunk;
+import org.tmatesoft.hg.repo.HgDataFile;
+import org.tmatesoft.hg.repo.HgRepository;
+
+/**
+ * TODO add tests for {@link HgChangesetFileSneaker}
+ * @author Artem Tikhomirov
+ * @author TMate Software Ltd.
+ */
+public class TestFileRenameUtils {
+	
+
+	@Rule
+	public ErrorCollectorExt errorCollector = new ErrorCollectorExt();
+
+	@Test
+	public void testFileRenameHistory() throws HgException {
+		HgRepository repo = Configuration.get().find("log-renames");
+		// history of files from their TIP perspective 
+		// series of (fname, fileRefStart, fileRevEnd, csetStart, csetEnd), new to old
+		Object[] historyA = new Object[] {"a", 2, 2, 5, 5, "c", 0, 1, 2, 4, "b", 0, 1, 0, 1};
+		Object[] historyB = new Object[] {"b", 2, 3, 3, 4, "a", 0, 1, 0, 2 };
+		Object[] historyC = new Object[] {"c", 0, 1, 2, 4, "b", 0, 1, 0, 1 };
+		Object[] historyD = new Object[] {"d", 1, 1, 5, 5, "b", 2, 3, 3, 4, "a", 0, 1, 0, 2};
+		
+		FileRenameHistory frh = new FileRenameHistory(0, 5);
+		for (Object[] history : new Object[][] {historyA, historyB, historyC, historyD}) {
+			String fname = history[0].toString();
+			HgDataFile df = repo.getFileNode(fname);
+			Assert.assertFalse(frh.isOutOfRange(df, df.getLastRevision()));
+			frh.build(df, df.getLastRevision());
+			int recordIndex = 0;
+			errorCollector.assertEquals(history.length / 5, frh.chunks());
+			for (Chunk c : frh.iterate(HgIterateDirection.NewToOld)) {
+				compareChunk(fname, c, history, recordIndex++);
+			}
+			errorCollector.assertEquals("Shall compare full history", history.length, recordIndex * 5);
+		}
+		//
+		HgDataFile df = repo.getFileNode("d");
+		Assert.assertFalse(frh.isOutOfRange(df, 0));
+		frh.build(df, 0);
+		errorCollector.assertEquals(1, frh.chunks());
+		Chunk c = frh.iterate(NewToOld).iterator().next();
+		compareChunk("abandoned d(0)", c, new Object[] { "d", 0, 0, 4, 4 }, 0);
+		//
+		df = repo.getFileNode("a");
+		Assert.assertFalse(frh.isOutOfRange(df, 0));
+		frh.build(df, 0);
+		errorCollector.assertEquals(1, frh.chunks());
+		c = frh.iterate(NewToOld).iterator().next();
+		compareChunk("a(0) and boundary checks", c, new Object[] { "a", 0, 0, 0, 0 }, 0);
+		//
+		repo = Configuration.get().find("test-annotate"); // need a long file history
+		df = repo.getFileNode("file1");
+		Assert.assertTrue("[sanity]", repo.getChangelog().getLastRevision() >=9);
+		Assert.assertTrue("[sanity]", df.exists() && df.getLastRevision() >= 9);
+		frh = new FileRenameHistory(0, 9);
+		frh.build(df, 9);
+		errorCollector.assertEquals(1, frh.chunks());
+		c = frh.iterate(NewToOld).iterator().next();
+		compareChunk("regular file, no renames", c, new Object[] { "file1", 0, 9, 0, 9 }, 0);
+		// restricted range
+		frh = new FileRenameHistory(3, 6);
+		Assert.assertFalse(frh.isOutOfRange(df, 9));
+		frh.build(df, 9); // start from out of range revision
+		errorCollector.assertEquals(1, frh.chunks());
+		c = frh.iterate(OldToNew).iterator().next();
+		compareChunk("regular file, no renames, in range 3..6", c, new Object[] { "file1", 3, 6, 3, 6 }, 0);
+	}
+	
+	private void compareChunk(String msg, Chunk chunk, Object[] expected, int recordOffset) {
+		int off = recordOffset * 5;
+		errorCollector.assertEquals(msg, expected[off], chunk.file().getPath().toString());
+		errorCollector.assertEquals(msg, expected[off+1], chunk.firstFileRev());
+		errorCollector.assertEquals(msg, expected[off+2], chunk.lastFileRev());
+		errorCollector.assertEquals(msg, expected[off+3], chunk.firstCset());
+		errorCollector.assertEquals(msg, expected[off+4], chunk.lastCset());
+	}
+
+}
--- a/test/org/tmatesoft/hg/test/TestHistory.java	Thu Aug 01 21:45:47 2013 +0200
+++ b/test/org/tmatesoft/hg/test/TestHistory.java	Fri Aug 02 23:07:23 2013 +0200
@@ -21,6 +21,7 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertTrue;
 import static org.tmatesoft.hg.core.HgIterateDirection.NewToOld;
+import static org.tmatesoft.hg.core.HgIterateDirection.OldToNew;
 
 import java.util.ArrayList;
 import java.util.Collections;
@@ -69,7 +70,7 @@
 	private LogOutputParser changelogParser;
 	
 	public static void main(String[] args) throws Throwable {
-		TestHistory th = new TestHistory();
+		TestHistory th = new TestHistory(new HgLookup().detectFromWorkingDir());
 		th.testCompleteLog();
 		th.testFollowHistory();
 		th.errorCollector.verify();
@@ -81,19 +82,22 @@
 		th.errorCollector.verify();
 	}
 	
-	public TestHistory() throws Exception {
-		this(new HgLookup().detectFromWorkingDir());
-//		this(new HgLookup().detect("\\temp\\hg\\hello"));
+	public TestHistory() {
+		eh = new ExecHelper(changelogParser = new LogOutputParser(true), null);
 	}
 
 	private TestHistory(HgRepository hgRepo) {
+		this();
 		repo = hgRepo;
-		eh = new ExecHelper(changelogParser = new LogOutputParser(true), repo.getWorkingDir());
-		
+		eh.cwd(repo.getWorkingDir());
 	}
-
+	
 	@Test
 	public void testCompleteLog() throws Exception {
+		if (repo == null) {
+			repo = Configuration.get().own();
+			eh.cwd(repo.getWorkingDir());
+		}
 		changelogParser.reset();
 		eh.run("hg", "log", "--debug");
 		List<HgChangeset> r = new HgLogCommand(repo).execute();
@@ -105,6 +109,10 @@
 	
 	@Test
 	public void testFollowHistory() throws Exception {
+		if (repo == null) {
+			repo = Configuration.get().own();
+			eh.cwd(repo.getWorkingDir());
+		}
 		final Path f = Path.create("cmdline/org/tmatesoft/hg/console/Remote.java");
 		assertTrue(repo.getFileNode(f).exists());
 		changelogParser.reset();
@@ -409,6 +417,37 @@
 		report("HgChangesetHandler+RenameHandler with followRenames = false, new2old iteration order", h1.getChanges(), false);
 		report("HgChangesetTreeHandler+RenameHandler with followRenames = false, new2old iteration order", h2.getResult(), false);
 	}
+
+	@Test
+	public void testFollowMultipleRenames() throws Exception {
+		repo = Configuration.get().find("log-renames");
+		String fname = "a";
+		eh.run("hg", "log", "--debug", "--follow", fname, "--cwd", repo.getLocation());
+		HgLogCommand cmd = new HgLogCommand(repo);
+		cmd.file(fname, true, true);
+		CollectWithRenameHandler h1;
+		//
+		cmd.order(OldToNew).execute(h1 = new CollectWithRenameHandler());
+		errorCollector.assertEquals(2, h1.rh.renames.size());
+		report("Follow a->c->b, old2new:", h1.getChanges(), true);
+		//
+		cmd.order(NewToOld).execute(h1 = new CollectWithRenameHandler());
+		errorCollector.assertEquals(2, h1.rh.renames.size());
+		report("Follow a->c->b, new2old:", h1.getChanges(), false);
+		//
+		//
+		TreeCollectHandler h2 = new TreeCollectHandler(false);
+		RenameCollector rh = new RenameCollector(h2);
+		cmd.order(OldToNew).execute(h2);
+		errorCollector.assertEquals(2, rh.renames.size());
+		report("Tree. Follow a->c->b, old2new:", h2.getResult(), true);
+		//
+		h2 = new TreeCollectHandler(false);
+		rh = new RenameCollector(h2);
+		cmd.order(NewToOld).execute(h2);
+		errorCollector.assertEquals(2, rh.renames.size());
+		report("Tree. Follow a->c->b, new2old:", h2.getResult(), false);
+	}
 	
 	private void assertRename(String fnameFrom, String fnameTo, Pair<HgFileRevision, HgFileRevision> rename) {
 		errorCollector.assertEquals(fnameFrom, rename.first().getPath().toString());