diff options
author | ThomasBarnekow <thomas@barnekow.info> | 2015-02-16 04:51:14 +0300 |
---|---|---|
committer | ThomasBarnekow <thomas@barnekow.info> | 2015-04-13 23:57:31 +0300 |
commit | c462df3a7c68449adcbd6cf86f5f33f7cfa26a01 (patch) | |
tree | 5937ccca8a69e229e57e108260e912d1a6674f30 | |
parent | cad89d0814481252a70a811c0fb5daecc8b8b745 (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.cs | 396 | ||||
-rw-r--r-- | LibGit2Sharp.Tests/LibGit2Sharp.Tests.csproj | 3 | ||||
-rw-r--r-- | LibGit2Sharp/CommitLog.cs | 27 | ||||
-rw-r--r-- | LibGit2Sharp/Core/FileHistory.cs | 172 | ||||
-rw-r--r-- | LibGit2Sharp/FollowFilter.cs | 57 | ||||
-rw-r--r-- | LibGit2Sharp/IQueryableCommitLog.cs | 15 | ||||
-rw-r--r-- | LibGit2Sharp/LibGit2Sharp.csproj | 3 | ||||
-rw-r--r-- | LibGit2Sharp/LogEntry.cs | 18 |
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; } + } +} |