Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/mono/libgit2sharp.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThomasBarnekow <thomas@barnekow.info>2015-02-16 04:51:14 +0300
committerThomasBarnekow <thomas@barnekow.info>2015-04-13 23:57:31 +0300
commitc462df3a7c68449adcbd6cf86f5f33f7cfa26a01 (patch)
tree5937ccca8a69e229e57e108260e912d1a6674f30
parentcad89d0814481252a70a811c0fb5daecc8b8b745 (diff)
Implement git log --follow
This commit basically implements the git log --follow <path> command. It adds the following two methods to the IQueryableCommitLog interface: IEnumerable<LogEntry> QueryBy(string path); IEnumerable<LogEntry> QueryBy(string path, FollowFilter filter); The corresponding implementations are added to the CommitLog class. The actual functionality is implemented by the FileHistory class that is part of the LibGit2Sharp.Core namespace. Related to topics #893 and #89
-rw-r--r--LibGit2Sharp.Tests/FileHistoryFixture.cs396
-rw-r--r--LibGit2Sharp.Tests/LibGit2Sharp.Tests.csproj3
-rw-r--r--LibGit2Sharp/CommitLog.cs27
-rw-r--r--LibGit2Sharp/Core/FileHistory.cs172
-rw-r--r--LibGit2Sharp/FollowFilter.cs57
-rw-r--r--LibGit2Sharp/IQueryableCommitLog.cs15
-rw-r--r--LibGit2Sharp/LibGit2Sharp.csproj3
-rw-r--r--LibGit2Sharp/LogEntry.cs18
8 files changed, 689 insertions, 2 deletions
diff --git a/LibGit2Sharp.Tests/FileHistoryFixture.cs b/LibGit2Sharp.Tests/FileHistoryFixture.cs
new file mode 100644
index 00000000..3d09858b
--- /dev/null
+++ b/LibGit2Sharp.Tests/FileHistoryFixture.cs
@@ -0,0 +1,396 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using LibGit2Sharp.Tests.TestHelpers;
+using Xunit;
+using Xunit.Extensions;
+
+namespace LibGit2Sharp.Tests
+{
+ public class FileHistoryFixture : BaseFixture
+ {
+ [Theory]
+ [InlineData("https://github.com/nulltoken/follow-test.git")]
+ public void CanDealWithFollowTest(string url)
+ {
+ var scd = BuildSelfCleaningDirectory();
+ var clonedRepoPath = Repository.Clone(url, scd.DirectoryPath);
+
+ using (var repo = new Repository(clonedRepoPath))
+ {
+ // $ git log --follow --format=oneline so-renamed.txt
+ // 88f91835062161febb46fb270ef4188f54c09767 Update not-yet-renamed.txt AND rename into so-renamed.txt
+ // ef7cb6a63e32595fffb092cb1ae9a32310e58850 Add not-yet-renamed.txt
+ var fileHistoryEntries = repo.Commits.QueryBy("so-renamed.txt").ToList();
+ Assert.Equal(2, fileHistoryEntries.Count());
+ Assert.Equal("88f91835062161febb46fb270ef4188f54c09767", fileHistoryEntries[0].Commit.Sha);
+ Assert.Equal("ef7cb6a63e32595fffb092cb1ae9a32310e58850", fileHistoryEntries[1].Commit.Sha);
+
+ // $ git log --follow --format=oneline untouched.txt
+ // c10c1d5f74b76f20386d18674bf63fbee6995061 Initial commit
+ fileHistoryEntries = repo.Commits.QueryBy("untouched.txt").ToList();
+ Assert.Equal(1, fileHistoryEntries.Count());
+ Assert.Equal("c10c1d5f74b76f20386d18674bf63fbee6995061", fileHistoryEntries[0].Commit.Sha);
+
+ // $ git log --follow --format=oneline under-test.txt
+ // 0b5b18f2feb917dee98df1210315b2b2b23c5bec Rename file renamed.txt into under-test.txt
+ // 49921d463420a892c9547a326632ef6a9ba3b225 Update file renamed.txt
+ // 70f636e8c64bbc2dfef3735a562bb7e195d8019f Rename file under-test.txt into renamed.txt
+ // d3868d57a6aaf2ae6ed4887d805ae4bc91d8ce4d Updated file under test
+ // 9da10ef7e139c49604a12caa866aae141f38b861 Updated file under test
+ // 599a5d821fb2c0a25855b4233e26d475c2fbeb34 Updated file under test
+ // 678b086b44753000567aa64344aa0d8034fa0083 Updated file under test
+ // 8f7d9520f306771340a7c79faea019ad18e4fa1f Updated file under test
+ // bd5f8ee279924d33be8ccbde82e7f10b9d9ff237 Updated file under test
+ // c10c1d5f74b76f20386d18674bf63fbee6995061 Initial commit
+ fileHistoryEntries = repo.Commits.QueryBy("under-test.txt").ToList();
+ Assert.Equal(10, fileHistoryEntries.Count());
+ Assert.Equal("0b5b18f2feb917dee98df1210315b2b2b23c5bec", fileHistoryEntries[0].Commit.Sha);
+ Assert.Equal("49921d463420a892c9547a326632ef6a9ba3b225", fileHistoryEntries[1].Commit.Sha);
+ Assert.Equal("70f636e8c64bbc2dfef3735a562bb7e195d8019f", fileHistoryEntries[2].Commit.Sha);
+ Assert.Equal("d3868d57a6aaf2ae6ed4887d805ae4bc91d8ce4d", fileHistoryEntries[3].Commit.Sha);
+ Assert.Equal("9da10ef7e139c49604a12caa866aae141f38b861", fileHistoryEntries[4].Commit.Sha);
+ Assert.Equal("599a5d821fb2c0a25855b4233e26d475c2fbeb34", fileHistoryEntries[5].Commit.Sha);
+ Assert.Equal("678b086b44753000567aa64344aa0d8034fa0083", fileHistoryEntries[6].Commit.Sha);
+ Assert.Equal("8f7d9520f306771340a7c79faea019ad18e4fa1f", fileHistoryEntries[7].Commit.Sha);
+ Assert.Equal("bd5f8ee279924d33be8ccbde82e7f10b9d9ff237", fileHistoryEntries[8].Commit.Sha);
+ Assert.Equal("c10c1d5f74b76f20386d18674bf63fbee6995061", fileHistoryEntries[9].Commit.Sha);
+ }
+ }
+
+ [Theory]
+ [InlineData(null)]
+ public void CanFollowBranches(string specificRepoPath)
+ {
+ var repoPath = specificRepoPath ?? CreateEmptyRepository();
+ var path = "Test.txt";
+
+ var dummy = new string('a', 1024);
+
+ using (var repo = new Repository(repoPath))
+ {
+ var master0 = AddCommitToOdb(repo, "0. Initial commit for this test", path, "Before merge", dummy);
+ var fix1 = AddCommitToOdb(repo, "1. Changed on fix", path, "Change on fix branch", dummy, master0);
+ var master2 = AddCommitToOdb(repo, "2. Changed on master", path, "Independent change on master branch",
+ dummy, master0);
+
+ path = "New" + path;
+
+ var fix3 = AddCommitToOdb(repo, "3. Changed and renamed on fix", path, "Another change on fix branch",
+ dummy, fix1);
+ var master4 = AddCommitToOdb(repo, "4. Changed and renamed on master", path,
+ "Another independent change on master branch", dummy, master2);
+ var master5 = AddCommitToOdb(repo, "5. Merged fix into master", path,
+ "Manual resolution of merge conflict", dummy, master4, fix3);
+ var master6 = AddCommitToOdb(repo, "6. Changed on master", path, "Change after merge", dummy, master5);
+ var nextfix7 = AddCommitToOdb(repo, "7. Changed on next-fix", path, "Change on next-fix branch", dummy,
+ master6);
+ var master8 = AddCommitToOdb(repo, "8. Changed on master", path,
+ "Some arbitrary change on master branch", dummy, master6);
+ var master9 = AddCommitToOdb(repo, "9. Merged next-fix into master", path,
+ "Another manual resolution of merge conflict", dummy, master8, nextfix7);
+ var master10 = AddCommitToOdb(repo, "10. Changed on master", path, "A change on master after merging",
+ dummy, master9);
+
+ repo.CreateBranch("master", master10);
+ repo.Checkout("master", new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force });
+
+ // Test --date-order.
+ var timeHistory = repo.Commits.QueryBy(path,
+ new FollowFilter { SortBy = CommitSortStrategies.Time });
+ var timeCommits = new List<Commit>
+ {
+ master10, // master
+
+ master8, // master
+ nextfix7, // next-fix
+ master6, // master
+
+ master4, // master
+ fix3, // fix
+ master2, // master
+ fix1, // fix
+ master0 // master (initial commit)
+ };
+ Assert.Equal(timeCommits, timeHistory.Select(e => e.Commit));
+
+ // Test --topo-order.
+ var topoHistory = repo.Commits.QueryBy(path,
+ new FollowFilter { SortBy = CommitSortStrategies.Topological });
+ var topoCommits = new List<Commit>
+ {
+ master10, // master
+
+ nextfix7, // next-fix
+ master8, // master
+ master6, // master
+
+ fix3, // fix
+ fix1, // fix
+ master4, // master
+ master2, // master
+ master0 // master (initial commit)
+ };
+ Assert.Equal(topoCommits, topoHistory.Select(e => e.Commit));
+ }
+ }
+
+ [Fact]
+ public void CanTellComplexCommitHistory()
+ {
+ var repoPath = CreateEmptyRepository();
+ const string path1 = "Test1.txt";
+ const string path2 = "Test2.txt";
+
+ using (var repo = new Repository(repoPath))
+ {
+ // Make initial changes.
+ var commit1 = MakeAndCommitChange(repo, repoPath, path1, "Hello World");
+ MakeAndCommitChange(repo, repoPath, path2, "Second file's contents");
+ var commit2 = MakeAndCommitChange(repo, repoPath, path1, "Hello World again");
+
+ // Move the first file to a new directory.
+ var newPath1 = Path.Combine(SubFolderPath1, path1);
+ repo.Move(path1, newPath1);
+ var commit3 = repo.Commit("Moved " + path1 + " to " + newPath1,
+ Constants.Signature, Constants.Signature);
+
+ // Make further changes.
+ MakeAndCommitChange(repo, repoPath, path2, "Changed second file's contents");
+ var commit4 = MakeAndCommitChange(repo, repoPath, newPath1, "I have done it again!");
+
+ // Perform tests.
+ var fileHistoryEntries = repo.Commits.QueryBy(newPath1).ToList();
+ var changedBlobs = fileHistoryEntries.Blobs().Distinct().ToList();
+
+ Assert.Equal(4, fileHistoryEntries.Count());
+ Assert.Equal(3, changedBlobs.Count());
+
+ Assert.Equal(2, fileHistoryEntries.Count(e => e.Path == newPath1));
+ Assert.Equal(2, fileHistoryEntries.Count(e => e.Path == path1));
+
+ Assert.Equal(commit4, fileHistoryEntries[0].Commit);
+ Assert.Equal(commit3, fileHistoryEntries[1].Commit);
+ Assert.Equal(commit2, fileHistoryEntries[2].Commit);
+ Assert.Equal(commit1, fileHistoryEntries[3].Commit);
+
+ Assert.Equal(commit4.Tree[newPath1].Target, changedBlobs[0]);
+ Assert.Equal(commit2.Tree[path1].Target, changedBlobs[1]);
+ Assert.Equal(commit1.Tree[path1].Target, changedBlobs[2]);
+ }
+ }
+
+ [Fact]
+ public void CanTellSimpleCommitHistory()
+ {
+ var repoPath = CreateEmptyRepository();
+ const string path1 = "Test1.txt";
+ const string path2 = "Test2.txt";
+
+ using (var repo = new Repository(repoPath))
+ {
+ // Set up repository.
+ var commit1 = MakeAndCommitChange(repo, repoPath, path1, "Hello World");
+ MakeAndCommitChange(repo, repoPath, path2, "Second file's contents");
+ var commit3 = MakeAndCommitChange(repo, repoPath, path1, "Hello World again");
+
+ // Perform tests.
+ IEnumerable<LogEntry> history = repo.Commits.QueryBy(path1).ToList();
+ var changedBlobs = history.Blobs().Distinct();
+
+ Assert.Equal(2, history.Count());
+ Assert.Equal(2, changedBlobs.Count());
+
+ Assert.Equal(commit3, history.ElementAt(0).Commit);
+ Assert.Equal(commit1, history.ElementAt(1).Commit);
+ }
+ }
+
+ [Fact]
+ public void CanTellSingleCommitHistory()
+ {
+ var repoPath = CreateEmptyRepository();
+
+ using (var repo = new Repository(repoPath))
+ {
+ // Set up repository.
+ const string path = "Test.txt";
+ var commit = MakeAndCommitChange(repo, repoPath, path, "Hello World");
+
+ // Perform tests.
+ IEnumerable<LogEntry> history = repo.Commits.QueryBy(path).ToList();
+ var changedBlobs = history.Blobs().Distinct();
+
+ Assert.Equal(1, history.Count());
+ Assert.Equal(1, changedBlobs.Count());
+
+ Assert.Equal(path, history.First().Path);
+ Assert.Equal(commit, history.First().Commit);
+ }
+ }
+
+ [Fact]
+ public void EmptyRepositoryHasNoHistory()
+ {
+ var repoPath = CreateEmptyRepository();
+
+ using (var repo = new Repository(repoPath))
+ {
+ IEnumerable<LogEntry> history = repo.Commits.QueryBy("Test.txt").ToList();
+ Assert.Equal(0, history.Count());
+ Assert.Equal(0, history.Blobs().Count());
+ }
+ }
+
+ [Fact]
+ public void UnsupportedSortStrategyThrows()
+ {
+ var repoPath = CreateEmptyRepository();
+
+ using (var repo = new Repository(repoPath))
+ {
+ // Set up repository.
+ const string path = "Test.txt";
+ MakeAndCommitChange(repo, repoPath, path, "Hello World");
+
+ Assert.Throws<ArgumentException>(() =>
+ repo.Commits.QueryBy(path, new FollowFilter
+ {
+ SortBy = CommitSortStrategies.None
+ }));
+
+ Assert.Throws<ArgumentException>(() =>
+ repo.Commits.QueryBy(path, new FollowFilter
+ {
+ SortBy = CommitSortStrategies.Reverse
+ }));
+
+ Assert.Throws<ArgumentException>(() =>
+ repo.Commits.QueryBy(path, new FollowFilter
+ {
+ SortBy = CommitSortStrategies.Reverse |
+ CommitSortStrategies.Topological
+ }));
+
+ Assert.Throws<ArgumentException>(() =>
+ repo.Commits.QueryBy(path, new FollowFilter
+ {
+ SortBy = CommitSortStrategies.Reverse |
+ CommitSortStrategies.Time
+ }));
+
+ Assert.Throws<ArgumentException>(() =>
+ repo.Commits.QueryBy(path, new FollowFilter
+ {
+ SortBy = CommitSortStrategies.Reverse |
+ CommitSortStrategies.Topological |
+ CommitSortStrategies.Time
+ }));
+ }
+ }
+
+ #region Helpers
+
+ private Signature _signature = Constants.Signature;
+ private const string SubFolderPath1 = "SubFolder1";
+
+ private Signature GetNextSignature()
+ {
+ _signature = _signature.TimeShift(TimeSpan.FromMinutes(1));
+ return _signature;
+ }
+
+ private string CreateEmptyRepository()
+ {
+ // Create a new empty directory with subfolders.
+ var scd = BuildSelfCleaningDirectory();
+ Directory.CreateDirectory(Path.Combine(scd.DirectoryPath, SubFolderPath1));
+
+ // Initialize a GIT repository in that directory.
+ Repository.Init(scd.DirectoryPath);
+ using (var repo = new Repository(scd.DirectoryPath))
+ {
+ repo.Config.Set("user.name", _signature.Name);
+ repo.Config.Set("user.email", _signature.Email);
+ }
+
+ // Done.
+ return scd.DirectoryPath;
+ }
+
+ /// <summary>
+ /// Adds a commit to the object database. The tree will have a single text file with the given specific content.
+ /// </summary>
+ /// <param name="repo">The repository.</param>
+ /// <param name="message">The commit message.</param>
+ /// <param name="path">The file's path.</param>
+ /// <param name="specificContent">The file's content.</param>
+ /// <param name="parents">The commit's parents.</param>
+ /// <returns>The commit added to the object database.</returns>
+ private Commit AddCommitToOdb(Repository repo, string message, string path, string specificContent,
+ params Commit[] parents)
+ {
+ return AddCommitToOdb(repo, message, path, specificContent, null, parents);
+ }
+
+ /// <summary>
+ /// Adds a commit to the object database. The tree will have a single text file with the given specific content
+ /// at the beginning of the file and the given common content at the end of the file.
+ /// </summary>
+ /// <param name="repo">The repository.</param>
+ /// <param name="message">The commit message.</param>
+ /// <param name="path">The file's path.</param>
+ /// <param name="specificContent">The content specific to that file.</param>
+ /// <param name="commonContent">The content shared with other files.</param>
+ /// <param name="parents">The commit's parents.</param>
+ /// <returns>The commit added to the object database.</returns>
+ private Commit AddCommitToOdb(Repository repo, string message, string path, string specificContent,
+ string commonContent, params Commit[] parents)
+ {
+ var content = string.IsNullOrEmpty(commonContent)
+ ? specificContent
+ : specificContent + Environment.NewLine + commonContent + Environment.NewLine;
+
+ var td = new TreeDefinition();
+ td.Add(path, OdbHelper.CreateBlob(repo, content), Mode.NonExecutableFile);
+ var t = repo.ObjectDatabase.CreateTree(td);
+
+ var commitSignature = GetNextSignature();
+
+ return repo.ObjectDatabase.CreateCommit(commitSignature, commitSignature, message, t, parents, true);
+ }
+
+ private Commit MakeAndCommitChange(Repository repo, string repoPath, string path, string text,
+ string message = null)
+ {
+ Touch(repoPath, path, text);
+ repo.Stage(path);
+
+ var commitSignature = GetNextSignature();
+ return repo.Commit(message ?? "Changed " + path, commitSignature, commitSignature);
+ }
+
+ #endregion
+ }
+
+ /// <summary>
+ /// Defines extensions used by <see cref="FileHistoryFixture"/>.
+ /// </summary>
+ internal static class FileHistoryFixtureExtensions
+ {
+ /// <summary>
+ /// Gets the <see cref="Blob"/> instances contained in each <see cref="LogEntry"/>.
+ /// </summary>
+ /// <remarks>
+ /// Use the <see cref="Enumerable.Distinct{TSource}(IEnumerable{TSource})"/> extension method
+ /// to retrieve the changed blobs.
+ /// </remarks>
+ /// <param name="fileHistory">The file history.</param>
+ /// <returns>The collection of <see cref="Blob"/> instances included in the file history.</returns>
+ public static IEnumerable<Blob> Blobs(this IEnumerable<LogEntry> fileHistory)
+ {
+ return fileHistory.Select(entry => entry.Commit.Tree[entry.Path].Target).OfType<Blob>();
+ }
+ }
+}
diff --git a/LibGit2Sharp.Tests/LibGit2Sharp.Tests.csproj b/LibGit2Sharp.Tests/LibGit2Sharp.Tests.csproj
index f93a24f3..c477d4f8 100644
--- a/LibGit2Sharp.Tests/LibGit2Sharp.Tests.csproj
+++ b/LibGit2Sharp.Tests/LibGit2Sharp.Tests.csproj
@@ -57,6 +57,7 @@
<Compile Include="CheckoutFixture.cs" />
<Compile Include="CherryPickFixture.cs" />
<Compile Include="DescribeFixture.cs" />
+ <Compile Include="FileHistoryFixture.cs" />
<Compile Include="GlobalSettingsFixture.cs" />
<Compile Include="PatchStatsFixture.cs" />
<Compile Include="RefSpecFixture.cs" />
@@ -157,4 +158,4 @@
<Target Name="AfterBuild">
</Target>
-->
-</Project>
+</Project> \ No newline at end of file
diff --git a/LibGit2Sharp/CommitLog.cs b/LibGit2Sharp/CommitLog.cs
index 2472778b..a31df9e2 100644
--- a/LibGit2Sharp/CommitLog.cs
+++ b/LibGit2Sharp/CommitLog.cs
@@ -81,6 +81,32 @@ namespace LibGit2Sharp
}
/// <summary>
+ /// Returns the list of commits of the repository representing the history of a file beyond renames.
+ /// </summary>
+ /// <param name="path">The file's path.</param>
+ /// <returns>A list of file history entries, ready to be enumerated.</returns>
+ public IEnumerable<LogEntry> QueryBy(string path)
+ {
+ Ensure.ArgumentNotNull(path, "path");
+
+ return new FileHistory(repo, path);
+ }
+
+ /// <summary>
+ /// Returns the list of commits of the repository representing the history of a file beyond renames.
+ /// </summary>
+ /// <param name="path">The file's path.</param>
+ /// <param name="filter">The options used to control which commits will be returned.</param>
+ /// <returns>A list of file history entries, ready to be enumerated.</returns>
+ public IEnumerable<LogEntry> QueryBy(string path, FollowFilter filter)
+ {
+ Ensure.ArgumentNotNull(path, "path");
+ Ensure.ArgumentNotNull(filter, "filter");
+
+ return new FileHistory(repo, path, new CommitFilter {SortBy = filter.SortBy});
+ }
+
+ /// <summary>
/// Find the best possible merge base given two <see cref="Commit"/>s.
/// </summary>
/// <param name="first">The first <see cref="Commit"/>.</param>
@@ -206,7 +232,6 @@ namespace LibGit2Sharp
}
}
}
-
}
/// <summary>
diff --git a/LibGit2Sharp/Core/FileHistory.cs b/LibGit2Sharp/Core/FileHistory.cs
new file mode 100644
index 00000000..42a1aa2f
--- /dev/null
+++ b/LibGit2Sharp/Core/FileHistory.cs
@@ -0,0 +1,172 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace LibGit2Sharp.Core
+{
+ /// <summary>
+ /// Represents a file-related log of commits beyond renames.
+ /// </summary>
+ internal class FileHistory : IEnumerable<LogEntry>
+ {
+ #region Fields
+
+ /// <summary>
+ /// The allowed commit sort strategies.
+ /// </summary>
+ private static readonly List<CommitSortStrategies> AllowedSortStrategies = new List<CommitSortStrategies>
+ {
+ CommitSortStrategies.Topological,
+ CommitSortStrategies.Time,
+ CommitSortStrategies.Topological | CommitSortStrategies.Time
+ };
+
+ /// <summary>
+ /// The repository.
+ /// </summary>
+ private readonly Repository _repo;
+
+ /// <summary>
+ /// The file's path relative to the repository's root.
+ /// </summary>
+ private readonly string _path;
+
+ /// <summary>
+ /// The filter to be used in querying the commit log.
+ /// </summary>
+ private readonly CommitFilter _queryFilter;
+
+ #endregion
+
+ #region Constructors
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="FileHistory"/> class.
+ /// The commits will be enumerated in reverse chronological order.
+ /// </summary>
+ /// <param name="repo">The repository.</param>
+ /// <param name="path">The file's path relative to the repository's root.</param>
+ /// <exception cref="ArgumentNullException">If any of the parameters is null.</exception>
+ internal FileHistory(Repository repo, string path)
+ : this(repo, path, new CommitFilter())
+ { }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="FileHistory"/> class.
+ /// The given <see cref="CommitFilter"/> instance specifies the commit
+ /// sort strategies and range of commits to be considered.
+ /// Only the time (corresponding to <code>--date-order</code>) and topological
+ /// (coresponding to <code>--topo-order</code>) sort strategies are supported.
+ /// </summary>
+ /// <param name="repo">The repository.</param>
+ /// <param name="path">The file's path relative to the repository's root.</param>
+ /// <param name="queryFilter">The filter to be used in querying the commit log.</param>
+ /// <exception cref="ArgumentNullException">If any of the parameters is null.</exception>
+ /// <exception cref="ArgumentException">When an unsupported commit sort strategy is specified.</exception>
+ internal FileHistory(Repository repo, string path, CommitFilter queryFilter)
+ {
+ Ensure.ArgumentNotNull(repo, "repo");
+ Ensure.ArgumentNotNull(path, "path");
+ Ensure.ArgumentNotNull(queryFilter, "queryFilter");
+
+ // Ensure the commit sort strategy makes sense.
+ if (!AllowedSortStrategies.Contains(queryFilter.SortBy))
+ throw new ArgumentException(
+ "Unsupported sort strategy. Only 'Topological', 'Time', or 'Topological | Time' are allowed.",
+ "queryFilter");
+
+ _repo = repo;
+ _path = path;
+ _queryFilter = queryFilter;
+ }
+
+ #endregion
+
+ #region IEnumerable<LogEntry> Members
+
+ /// <summary>
+ /// Gets the <see cref="IEnumerator{LogEntry}"/> that enumerates the
+ /// <see cref="LogEntry"/> instances representing the file's history,
+ /// including renames (as in <code>git log --follow</code>).
+ /// </summary>
+ /// <returns>A <see cref="IEnumerator{LogEntry}"/>.</returns>
+ public IEnumerator<LogEntry> GetEnumerator()
+ {
+ return FullHistory(_repo, _path, _queryFilter).GetEnumerator();
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return GetEnumerator();
+ }
+
+ #endregion
+
+ /// <summary>
+ /// Gets the relevant commits in which the given file was created, changed, or renamed.
+ /// </summary>
+ /// <param name="repo">The repository.</param>
+ /// <param name="path">The file's path relative to the repository's root.</param>
+ /// <param name="filter">The filter to be used in querying the commits log.</param>
+ /// <returns>A collection of <see cref="LogEntry"/> instances.</returns>
+ private static IEnumerable<LogEntry> FullHistory(IRepository repo, string path, CommitFilter filter)
+ {
+ var map = new Dictionary<Commit, string>();
+
+ foreach (var currentCommit in repo.Commits.QueryBy(filter))
+ {
+ var currentPath = map.Keys.Count > 0 ? map[currentCommit] : path;
+ var currentTreeEntry = currentCommit.Tree[currentPath];
+
+ if (currentTreeEntry == null)
+ {
+ yield break;
+ }
+
+ var parentCount = currentCommit.Parents.Count();
+ if (parentCount == 0)
+ {
+ yield return new LogEntry { Path = currentPath, Commit = currentCommit };
+ }
+ else
+ {
+ DetermineParentPaths(repo, currentCommit, currentPath, map);
+
+ if (parentCount != 1)
+ {
+ continue;
+ }
+
+ var parentCommit = currentCommit.Parents.Single();
+ var parentPath = map[parentCommit];
+ var parentTreeEntry = parentCommit.Tree[parentPath];
+
+ if (parentTreeEntry == null ||
+ parentTreeEntry.Target.Id != currentTreeEntry.Target.Id ||
+ parentPath != currentPath)
+ {
+ yield return new LogEntry { Path = currentPath, Commit = currentCommit };
+ }
+ }
+ }
+ }
+
+ private static void DetermineParentPaths(IRepository repo, Commit currentCommit, string currentPath, IDictionary<Commit, string> map)
+ {
+ foreach (var parentCommit in currentCommit.Parents.Where(parentCommit => !map.ContainsKey(parentCommit)))
+ {
+ map.Add(parentCommit, ParentPath(repo, currentCommit, currentPath, parentCommit));
+ }
+ }
+
+ private static string ParentPath(IRepository repo, Commit currentCommit, string currentPath, Commit parentCommit)
+ {
+ var treeChanges = repo.Diff.Compare<TreeChanges>(parentCommit.Tree, currentCommit.Tree);
+ var treeEntryChanges = treeChanges.FirstOrDefault(c => c.Path == currentPath);
+ return treeEntryChanges != null && treeEntryChanges.Status == ChangeKind.Renamed
+ ? treeEntryChanges.OldPath
+ : currentPath;
+ }
+ }
+}
diff --git a/LibGit2Sharp/FollowFilter.cs b/LibGit2Sharp/FollowFilter.cs
new file mode 100644
index 00000000..7bd60708
--- /dev/null
+++ b/LibGit2Sharp/FollowFilter.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Collections.Generic;
+
+namespace LibGit2Sharp
+{
+ /// <summary>
+ /// Criteria used to order the commits of the repository when querying its history.
+ /// <para>
+ /// The commits will be enumerated from the current HEAD of the repository.
+ /// </para>
+ /// </summary>
+ public sealed class FollowFilter
+ {
+ private static readonly List<CommitSortStrategies> AllowedSortStrategies = new List<CommitSortStrategies>
+ {
+ CommitSortStrategies.Topological,
+ CommitSortStrategies.Time,
+ CommitSortStrategies.Topological | CommitSortStrategies.Time
+ };
+
+ private CommitSortStrategies _sortBy;
+
+ /// <summary>
+ /// Initializes a new instance of <see cref="FollowFilter" />.
+ /// </summary>
+ public FollowFilter()
+ {
+ SortBy = CommitSortStrategies.Time;
+ }
+
+ /// <summary>
+ /// The ordering strategy to use.
+ /// <para>
+ /// By default, the commits are shown in reverse chronological order.
+ /// </para>
+ /// <para>
+ /// Only 'Topological', 'Time', or 'Topological | Time' are allowed.
+ /// </para>
+ /// </summary>
+ public CommitSortStrategies SortBy
+ {
+ get { return _sortBy; }
+
+ set
+ {
+ if (!AllowedSortStrategies.Contains(value))
+ {
+ throw new ArgumentException(
+ "Unsupported sort strategy. Only 'Topological', 'Time', or 'Topological | Time' are allowed.",
+ "value");
+ }
+
+ _sortBy = value;
+ }
+ }
+ }
+}
diff --git a/LibGit2Sharp/IQueryableCommitLog.cs b/LibGit2Sharp/IQueryableCommitLog.cs
index 6935979e..457ad2fa 100644
--- a/LibGit2Sharp/IQueryableCommitLog.cs
+++ b/LibGit2Sharp/IQueryableCommitLog.cs
@@ -16,6 +16,21 @@ namespace LibGit2Sharp
ICommitLog QueryBy(CommitFilter filter);
/// <summary>
+ /// Returns the list of commits of the repository representing the history of a file beyond renames.
+ /// </summary>
+ /// <param name="path">The file's path.</param>
+ /// <returns>A list of file history entries, ready to be enumerated.</returns>
+ IEnumerable<LogEntry> QueryBy(string path);
+
+ /// <summary>
+ /// Returns the list of commits of the repository representing the history of a file beyond renames.
+ /// </summary>
+ /// <param name="path">The file's path.</param>
+ /// <param name="filter">The options used to control which commits will be returned.</param>
+ /// <returns>A list of file history entries, ready to be enumerated.</returns>
+ IEnumerable<LogEntry> QueryBy(string path, FollowFilter filter);
+
+ /// <summary>
/// Find the best possible merge base given two <see cref="Commit"/>s.
/// </summary>
/// <param name="first">The first <see cref="Commit"/>.</param>
diff --git a/LibGit2Sharp/LibGit2Sharp.csproj b/LibGit2Sharp/LibGit2Sharp.csproj
index 7537746d..60b35252 100644
--- a/LibGit2Sharp/LibGit2Sharp.csproj
+++ b/LibGit2Sharp/LibGit2Sharp.csproj
@@ -68,6 +68,7 @@
<Compile Include="CommitOptions.cs" />
<Compile Include="CommitSortStrategies.cs" />
<Compile Include="CompareOptions.cs" />
+ <Compile Include="Core\FileHistory.cs" />
<Compile Include="Core\Platform.cs" />
<Compile Include="Core\Handles\ConflictIteratorSafeHandle.cs" />
<Compile Include="DescribeOptions.cs" />
@@ -80,6 +81,8 @@
<Compile Include="Core\Handles\IndexReucEntrySafeHandle.cs" />
<Compile Include="EntryExistsException.cs" />
<Compile Include="FetchOptionsBase.cs" />
+ <Compile Include="LogEntry.cs" />
+ <Compile Include="FollowFilter.cs" />
<Compile Include="IBelongToARepository.cs" />
<Compile Include="Identity.cs" />
<Compile Include="IndexNameEntryCollection.cs" />
diff --git a/LibGit2Sharp/LogEntry.cs b/LibGit2Sharp/LogEntry.cs
new file mode 100644
index 00000000..d7aadc06
--- /dev/null
+++ b/LibGit2Sharp/LogEntry.cs
@@ -0,0 +1,18 @@
+namespace LibGit2Sharp
+{
+ /// <summary>
+ /// An entry in a file's commit history.
+ /// </summary>
+ public sealed class LogEntry
+ {
+ /// <summary>
+ /// The file's path relative to the repository's root.
+ /// </summary>
+ public string Path { get; internal set; }
+
+ /// <summary>
+ /// The commit in which the file was created or changed.
+ /// </summary>
+ public Commit Commit { get; internal set; }
+ }
+}