diff --git a/LibGit2Sharp.Tests/IgnoreFixture.cs b/LibGit2Sharp.Tests/IgnoreFixture.cs index ef91bd179..0ad15a3f4 100644 --- a/LibGit2Sharp.Tests/IgnoreFixture.cs +++ b/LibGit2Sharp.Tests/IgnoreFixture.cs @@ -16,15 +16,15 @@ public void TemporaryRulesShouldApplyUntilCleared() { Touch(repo.Info.WorkingDirectory, "Foo.cs", "Bar"); - Assert.True(repo.Index.RetrieveStatus().Untracked.Contains("Foo.cs")); + Assert.True(repo.Index.RetrieveStatus().Untracked.Select(s => s.FilePath).Contains("Foo.cs")); repo.Ignore.AddTemporaryRules(new[] { "*.cs" }); - Assert.False(repo.Index.RetrieveStatus().Untracked.Contains("Foo.cs")); + Assert.False(repo.Index.RetrieveStatus().Untracked.Select(s => s.FilePath).Contains("Foo.cs")); repo.Ignore.ResetAllTemporaryRules(); - Assert.True(repo.Index.RetrieveStatus().Untracked.Contains("Foo.cs")); + Assert.True(repo.Index.RetrieveStatus().Untracked.Select(s => s.FilePath).Contains("Foo.cs")); } } diff --git a/LibGit2Sharp.Tests/ResetIndexFixture.cs b/LibGit2Sharp.Tests/ResetIndexFixture.cs index 121da8c74..23e6a1cc4 100644 --- a/LibGit2Sharp.Tests/ResetIndexFixture.cs +++ b/LibGit2Sharp.Tests/ResetIndexFixture.cs @@ -93,7 +93,7 @@ public void CanResetTheIndexToTheContentOfACommitWithCommittishAsArgument() "deleted_unstaged_file.txt", "modified_staged_file.txt", "modified_unstaged_file.txt" }; Assert.Equal(expected.Length, newStatus.Where(IsStaged).Count()); - Assert.Equal(expected, newStatus.Removed); + Assert.Equal(expected, newStatus.Removed.Select(s => s.FilePath)); } } @@ -111,7 +111,7 @@ public void CanResetTheIndexToTheContentOfACommitWithCommitAsArgument() "deleted_unstaged_file.txt", "modified_staged_file.txt", "modified_unstaged_file.txt" }; Assert.Equal(expected.Length, newStatus.Where(IsStaged).Count()); - Assert.Equal(expected, newStatus.Removed); + Assert.Equal(expected, newStatus.Removed.Select(s => s.FilePath)); } } diff --git a/LibGit2Sharp.Tests/StatusFixture.cs b/LibGit2Sharp.Tests/StatusFixture.cs index f9a1c487e..b70ff0ae0 100644 --- a/LibGit2Sharp.Tests/StatusFixture.cs +++ b/LibGit2Sharp.Tests/StatusFixture.cs @@ -20,6 +20,42 @@ public void CanRetrieveTheStatusOfAFile() } } + [Theory] + [InlineData(StatusShowOption.IndexAndWorkDir, FileStatus.Untracked)] + [InlineData(StatusShowOption.WorkDirOnly, FileStatus.Untracked)] + [InlineData(StatusShowOption.IndexOnly, FileStatus.Nonexistent)] + public void CanLimitStatusToWorkDirOnly(StatusShowOption show, FileStatus expected) + { + var clone = CloneStandardTestRepo(); + + using (var repo = new Repository(clone)) + { + Touch(repo.Info.WorkingDirectory, "file.txt", "content"); + + RepositoryStatus status = repo.Index.RetrieveStatus(new StatusOptions() { Show = show }); + Assert.Equal(expected, status["file.txt"].State); + } + } + + [Theory] + [InlineData(StatusShowOption.IndexAndWorkDir, FileStatus.Added)] + [InlineData(StatusShowOption.WorkDirOnly, FileStatus.Nonexistent)] + [InlineData(StatusShowOption.IndexOnly, FileStatus.Added)] + public void CanLimitStatusToIndexOnly(StatusShowOption show, FileStatus expected) + { + var clone = CloneStandardTestRepo(); + + using (var repo = new Repository(clone)) + { + Touch(repo.Info.WorkingDirectory, "file.txt", "content"); + repo.Index.Stage("file.txt"); + + RepositoryStatus status = repo.Index.RetrieveStatus(new StatusOptions() { Show = show }); + Assert.Equal(expected, status["file.txt"].State); + } + } + + [Theory] [InlineData("file")] [InlineData("file.txt")] @@ -75,18 +111,18 @@ public void CanRetrieveTheStatusOfTheWholeWorkingDirectory() RepositoryStatus status = repo.Index.RetrieveStatus(); - Assert.Equal(FileStatus.Staged, status[file]); + Assert.Equal(FileStatus.Staged, status[file].State); Assert.NotNull(status); Assert.Equal(6, status.Count()); Assert.True(status.IsDirty); - Assert.Equal("new_untracked_file.txt", status.Untracked.Single()); - Assert.Equal("modified_unstaged_file.txt", status.Modified.Single()); - Assert.Equal("deleted_unstaged_file.txt", status.Missing.Single()); - Assert.Equal("new_tracked_file.txt", status.Added.Single()); - Assert.Equal(file, status.Staged.Single()); - Assert.Equal("deleted_staged_file.txt", status.Removed.Single()); + Assert.Equal("new_untracked_file.txt", status.Untracked.Select(s => s.FilePath).Single()); + Assert.Equal("modified_unstaged_file.txt", status.Modified.Select(s => s.FilePath).Single()); + Assert.Equal("deleted_unstaged_file.txt", status.Missing.Select(s => s.FilePath).Single()); + Assert.Equal("new_tracked_file.txt", status.Added.Select(s => s.FilePath).Single()); + Assert.Equal(file, status.Staged.Select(s => s.FilePath).Single()); + Assert.Equal("deleted_staged_file.txt", status.Removed.Select(s => s.FilePath).Single()); File.AppendAllText(Path.Combine(repo.Info.WorkingDirectory, file), "Tclem's favorite commit message: boom"); @@ -94,18 +130,113 @@ public void CanRetrieveTheStatusOfTheWholeWorkingDirectory() Assert.Equal(FileStatus.Staged | FileStatus.Modified, repo.Index.RetrieveStatus(file)); RepositoryStatus status2 = repo.Index.RetrieveStatus(); - Assert.Equal(FileStatus.Staged | FileStatus.Modified, status2[file]); + Assert.Equal(FileStatus.Staged | FileStatus.Modified, status2[file].State); Assert.NotNull(status2); Assert.Equal(6, status2.Count()); Assert.True(status2.IsDirty); - Assert.Equal("new_untracked_file.txt", status2.Untracked.Single()); - Assert.Equal(new[] { file, "modified_unstaged_file.txt" }, status2.Modified); - Assert.Equal("deleted_unstaged_file.txt", status2.Missing.Single()); - Assert.Equal("new_tracked_file.txt", status2.Added.Single()); - Assert.Equal(file, status2.Staged.Single()); - Assert.Equal("deleted_staged_file.txt", status2.Removed.Single()); + Assert.Equal("new_untracked_file.txt", status2.Untracked.Select(s => s.FilePath).Single()); + Assert.Equal(new[] { file, "modified_unstaged_file.txt" }, status2.Modified.Select(s => s.FilePath)); + Assert.Equal("deleted_unstaged_file.txt", status2.Missing.Select(s => s.FilePath).Single()); + Assert.Equal("new_tracked_file.txt", status2.Added.Select(s => s.FilePath).Single()); + Assert.Equal(file, status2.Staged.Select(s => s.FilePath).Single()); + Assert.Equal("deleted_staged_file.txt", status2.Removed.Select(s => s.FilePath).Single()); + } + } + + [Fact] + public void CanRetrieveTheStatusOfRenamedFilesInWorkDir() + { + string path = CloneStandardTestRepo(); + using (var repo = new Repository(path)) + { + Touch(repo.Info.WorkingDirectory, "old_name.txt", + "This is a file with enough data to trigger similarity matching.\r\n" + + "This is a file with enough data to trigger similarity matching.\r\n" + + "This is a file with enough data to trigger similarity matching.\r\n" + + "This is a file with enough data to trigger similarity matching.\r\n"); + + repo.Index.Stage("old_name.txt"); + + File.Move(Path.Combine(repo.Info.WorkingDirectory, "old_name.txt"), + Path.Combine(repo.Info.WorkingDirectory, "rename_target.txt")); + + RepositoryStatus status = repo.Index.RetrieveStatus( + new StatusOptions() + { + DetectRenamesInIndex = true, + DetectRenamesInWorkDir = true + }); + + Assert.Equal(FileStatus.Added | FileStatus.RenamedInWorkDir, status["rename_target.txt"].State); + Assert.Equal(100, status["rename_target.txt"].IndexToWorkDirRenameDetails.Similarity); + } + } + + [Fact] + public void CanRetrieveTheStatusOfRenamedFilesInIndex() + { + string path = CloneStandardTestRepo(); + using (var repo = new Repository(path)) + { + File.Move( + Path.Combine(repo.Info.WorkingDirectory, "1.txt"), + Path.Combine(repo.Info.WorkingDirectory, "rename_target.txt")); + + repo.Index.Stage("1.txt"); + repo.Index.Stage("rename_target.txt"); + + RepositoryStatus status = repo.Index.RetrieveStatus(); + + Assert.Equal(FileStatus.RenamedInIndex, status["rename_target.txt"].State); + Assert.Equal(100, status["rename_target.txt"].HeadToIndexRenameDetails.Similarity); + } + } + + [Fact] + public void CanDetectedVariousKindsOfRenaming() + { + string path = InitNewRepository(); + using (var repo = new Repository(path)) + { + Touch(repo.Info.WorkingDirectory, "file.txt", + "This is a file with enough data to trigger similarity matching.\r\n" + + "This is a file with enough data to trigger similarity matching.\r\n" + + "This is a file with enough data to trigger similarity matching.\r\n" + + "This is a file with enough data to trigger similarity matching.\r\n"); + + repo.Index.Stage("file.txt"); + repo.Commit("Initial commit", Constants.Signature, Constants.Signature); + + File.Move(Path.Combine(repo.Info.WorkingDirectory, "file.txt"), + Path.Combine(repo.Info.WorkingDirectory, "renamed.txt")); + + var opts = new StatusOptions + { + DetectRenamesInIndex = true, + DetectRenamesInWorkDir = true + }; + + RepositoryStatus status = repo.Index.RetrieveStatus(opts); + + // This passes as expected + Assert.Equal(FileStatus.RenamedInWorkDir, status.Single().State); + + repo.Index.Stage("file.txt"); + repo.Index.Stage("renamed.txt"); + + status = repo.Index.RetrieveStatus(opts); + + Assert.Equal(FileStatus.RenamedInIndex, status.Single().State); + + File.Move(Path.Combine(repo.Info.WorkingDirectory, "renamed.txt"), + Path.Combine(repo.Info.WorkingDirectory, "renamed_again.txt")); + + status = repo.Index.RetrieveStatus(opts); + + Assert.Equal(FileStatus.RenamedInWorkDir | FileStatus.RenamedInIndex, + status.Single().State); } } @@ -154,7 +285,7 @@ public void RetrievingTheStatusOfARepositoryReturnNativeFilePaths() Assert.Equal(relFilePath, statusEntry.FilePath); - Assert.Equal(statusEntry.FilePath, repoStatus.Added.Single()); + Assert.Equal(statusEntry.FilePath, repoStatus.Added.Select(s => s.FilePath).Single()); } } @@ -169,15 +300,15 @@ public void RetrievingTheStatusOfAnEmptyRepositoryHonorsTheGitIgnoreDirectives() Touch(repo.Info.WorkingDirectory, relativePath, "I'm going to be ignored!"); RepositoryStatus status = repo.Index.RetrieveStatus(); - Assert.Equal(new[] { relativePath }, status.Untracked); + Assert.Equal(new[] { relativePath }, status.Untracked.Select(s => s.FilePath)); Touch(repo.Info.WorkingDirectory, ".gitignore", "*.txt" + Environment.NewLine); RepositoryStatus newStatus = repo.Index.RetrieveStatus(); - Assert.Equal(".gitignore", newStatus.Untracked.Single()); + Assert.Equal(".gitignore", newStatus.Untracked.Select(s => s.FilePath).Single()); Assert.Equal(FileStatus.Ignored, repo.Index.RetrieveStatus(relativePath)); - Assert.Equal(new[] { relativePath }, newStatus.Ignored); + Assert.Equal(new[] { relativePath }, newStatus.Ignored.Select(s => s.FilePath)); } } @@ -223,7 +354,7 @@ public void RetrievingTheStatusOfTheRepositoryHonorsTheGitIgnoreDirectives() RepositoryStatus status = repo.Index.RetrieveStatus(); - Assert.Equal(new[]{relativePath, "new_untracked_file.txt"}, status.Untracked); + Assert.Equal(new[] { relativePath, "new_untracked_file.txt" }, status.Untracked.Select(s => s.FilePath)); Touch(repo.Info.WorkingDirectory, ".gitignore", "*.txt" + Environment.NewLine); @@ -263,10 +394,10 @@ public void RetrievingTheStatusOfTheRepositoryHonorsTheGitIgnoreDirectives() */ RepositoryStatus newStatus = repo.Index.RetrieveStatus(); - Assert.Equal(".gitignore", newStatus.Untracked.Single()); + Assert.Equal(".gitignore", newStatus.Untracked.Select(s => s.FilePath).Single()); Assert.Equal(FileStatus.Ignored, repo.Index.RetrieveStatus(relativePath)); - Assert.Equal(new[] { relativePath, "new_untracked_file.txt" }, newStatus.Ignored); + Assert.Equal(new[] { relativePath, "new_untracked_file.txt" }, newStatus.Ignored.Select(s => s.FilePath)); } } @@ -354,7 +485,7 @@ public void RetrievingTheStatusOfTheRepositoryHonorsTheGitIgnoreDirectivesThroug Assert.Equal(FileStatus.Ignored, repo.Index.RetrieveStatus("bin/what-about-me.txt")); RepositoryStatus newStatus = repo.Index.RetrieveStatus(); - Assert.Equal(new[] { "bin" + dirSep }, newStatus.Ignored); + Assert.Equal(new[] { "bin" + dirSep }, newStatus.Ignored.Select(s => s.FilePath)); var sb = new StringBuilder(); sb.AppendLine("bin/*"); @@ -366,8 +497,43 @@ public void RetrievingTheStatusOfTheRepositoryHonorsTheGitIgnoreDirectivesThroug newStatus = repo.Index.RetrieveStatus(); - Assert.Equal(new[] { "bin" + dirSep + "look-ma.txt" }, newStatus.Ignored); - Assert.True(newStatus.Untracked.Contains("bin" + dirSep + "what-about-me.txt" )); + Assert.Equal(new[] { "bin" + dirSep + "look-ma.txt" }, newStatus.Ignored.Select(s => s.FilePath)); + Assert.True(newStatus.Untracked.Select(s => s.FilePath).Contains("bin" + dirSep + "what-about-me.txt")); + } + } + + [Fact] + public void CanRetrieveStatusOfFilesInSubmodule() + { + var path = CloneSubmoduleTestRepo(); + using (var repo = new Repository(path)) + { + string[] expected = new string[] { + ".gitmodules", + "sm_changed_file", + "sm_changed_head", + "sm_changed_index", + "sm_changed_untracked_file", + "sm_missing_commits" + }; + + RepositoryStatus status = repo.Index.RetrieveStatus(); + Assert.Equal(expected, status.Modified.Select(x => x.FilePath).ToArray()); + } + } + + [Fact] + public void CanExcludeStatusOfFilesInSubmodule() + { + var path = CloneSubmoduleTestRepo(); + using (var repo = new Repository(path)) + { + string[] expected = new string[] { + ".gitmodules", + }; + + RepositoryStatus status = repo.Index.RetrieveStatus(new StatusOptions() { ExcludeSubmodules = true }); + Assert.Equal(expected, status.Modified.Select(x => x.FilePath).ToArray()); } } } diff --git a/LibGit2Sharp/Core/GitStatusEntry.cs b/LibGit2Sharp/Core/GitStatusEntry.cs new file mode 100644 index 000000000..2e13a186a --- /dev/null +++ b/LibGit2Sharp/Core/GitStatusEntry.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; + +namespace LibGit2Sharp.Core +{ + /// + /// A status entry from libgit2. + /// + [StructLayout(LayoutKind.Sequential)] + internal class GitStatusEntry + { + /// + /// Calculated status of a filepath in the working directory considering the current and the . + /// + public FileStatus Status; + + /// + /// The difference between the and . + /// + public IntPtr HeadToIndexPtr; + + /// + /// The difference between the and the working directory. + /// + public IntPtr IndexToWorkDirPtr; + } +} diff --git a/LibGit2Sharp/Core/GitStatusOptions.cs b/LibGit2Sharp/Core/GitStatusOptions.cs new file mode 100644 index 000000000..5d02ebfe9 --- /dev/null +++ b/LibGit2Sharp/Core/GitStatusOptions.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; + +namespace LibGit2Sharp.Core +{ + [StructLayout(LayoutKind.Sequential)] + internal class GitStatusOptions : IDisposable + { + public uint Version = 1; + + public GitStatusShow Show; + public GitStatusOptionFlags Flags; + + GitStrArrayIn PathSpec; + + public void Dispose() + { + if (PathSpec == null) + { + return; + } + + PathSpec.Dispose(); + } + } + + internal enum GitStatusShow + { + IndexAndWorkDir = 0, + IndexOnly = 1, + WorkDirOnly = 2, + } + + [Flags] + internal enum GitStatusOptionFlags + { + IncludeUntracked = (1 << 0), + IncludeIgnored = (1 << 1), + IncludeUnmodified = (1 << 2), + ExcludeSubmodules = (1 << 3), + RecurseUntrackedDirs = (1 << 4), + DisablePathspecMatch = (1 << 5), + RecurseIgnoredDirs = (1 << 6), + RenamesHeadToIndex = (1 << 7), + RenamesIndexToWorkDir = (1 << 8), + SortCaseSensitively = (1 << 9), + SortCaseInsensitively = (1 << 10), + RenamesFromRewrites = (1 << 11), + } +} diff --git a/LibGit2Sharp/Core/Handles/StatusEntrySafeHandle.cs b/LibGit2Sharp/Core/Handles/StatusEntrySafeHandle.cs new file mode 100644 index 000000000..ef8433529 --- /dev/null +++ b/LibGit2Sharp/Core/Handles/StatusEntrySafeHandle.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; + +namespace LibGit2Sharp.Core.Handles +{ + internal class StatusEntrySafeHandle : NotOwnedSafeHandleBase + { + public StatusEntrySafeHandle() + : base() + { + } + + public StatusEntrySafeHandle(IntPtr handle) + : base() + { + this.SetHandle(handle); + } + + public GitStatusEntry MarshalAsGitStatusEntry() + { + return (GitStatusEntry)Marshal.PtrToStructure(handle, typeof(GitStatusEntry)); + } + } +} diff --git a/LibGit2Sharp/Core/Handles/StatusListSafeHandle.cs b/LibGit2Sharp/Core/Handles/StatusListSafeHandle.cs new file mode 100644 index 000000000..74316a3fa --- /dev/null +++ b/LibGit2Sharp/Core/Handles/StatusListSafeHandle.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace LibGit2Sharp.Core.Handles +{ + internal class StatusListSafeHandle : SafeHandleBase + { + protected override bool ReleaseHandleImpl() + { + Proxy.git_status_list_free(handle); + return true; + } + } +} diff --git a/LibGit2Sharp/Core/NativeMethods.cs b/LibGit2Sharp/Core/NativeMethods.cs index 93e0ff595..2c6a2a6ea 100644 --- a/LibGit2Sharp/Core/NativeMethods.cs +++ b/LibGit2Sharp/Core/NativeMethods.cs @@ -1127,13 +1127,25 @@ internal static extern int git_status_file( RepositorySafeHandle repo, [MarshalAs(UnmanagedType.CustomMarshaler, MarshalCookie = UniqueId.UniqueIdentifier, MarshalTypeRef = typeof(StrictFilePathMarshaler))] FilePath filepath); - internal delegate int git_status_cb( - IntPtr path, - uint statusflags, - IntPtr payload); [DllImport(libgit2)] - internal static extern int git_status_foreach(RepositorySafeHandle repo, git_status_cb cb, IntPtr payload); + internal static extern int git_status_list_new( + out StatusListSafeHandle git_status_list, + RepositorySafeHandle repo, + GitStatusOptions options); + + [DllImport(libgit2)] + internal static extern int git_status_list_entrycount( + StatusListSafeHandle statusList); + + [DllImport(libgit2)] + internal static extern StatusEntrySafeHandle git_status_byindex( + StatusListSafeHandle list, + UIntPtr idx); + + [DllImport(libgit2)] + internal static extern void git_status_list_free( + IntPtr statusList); [DllImport(libgit2)] internal static extern int git_submodule_lookup( diff --git a/LibGit2Sharp/Core/Proxy.cs b/LibGit2Sharp/Core/Proxy.cs index fd8f6fda7..8fa771656 100644 --- a/LibGit2Sharp/Core/Proxy.cs +++ b/LibGit2Sharp/Core/Proxy.cs @@ -2178,9 +2178,35 @@ public static FileStatus git_status_file(RepositorySafeHandle repo, FilePath pat } } - public static ICollection git_status_foreach(RepositorySafeHandle repo, Func resultSelector) + public static StatusListSafeHandle git_status_list_new(RepositorySafeHandle repo, GitStatusOptions options) { - return git_foreach(resultSelector, c => NativeMethods.git_status_foreach(repo, (x, y, p) => c(x, y, p), IntPtr.Zero)); + using (ThreadAffinity()) + { + StatusListSafeHandle handle; + int res = NativeMethods.git_status_list_new(out handle, repo, options); + Ensure.ZeroResult(res); + return handle; + } + } + + public static int git_status_list_entrycount(StatusListSafeHandle list) + { + using (ThreadAffinity()) + { + int res = NativeMethods.git_status_list_entrycount(list); + Ensure.Int32Result(res); + return res; + } + } + + public static StatusEntrySafeHandle git_status_byindex(StatusListSafeHandle list, long idx) + { + return NativeMethods.git_status_byindex(list, (UIntPtr)idx); + } + + public static void git_status_list_free(IntPtr statusList) + { + NativeMethods.git_status_list_free(statusList); } #endregion diff --git a/LibGit2Sharp/FileStatus.cs b/LibGit2Sharp/FileStatus.cs index bf6975d0d..08fa73f60 100644 --- a/LibGit2Sharp/FileStatus.cs +++ b/LibGit2Sharp/FileStatus.cs @@ -36,7 +36,7 @@ public enum FileStatus /// /// The renaming of a file has been promoted from the working directory to the Index. A previous version exists in the Head. /// - Renamed = (1 << 3), /* GIT_STATUS_INDEX_RENAMED */ + RenamedInIndex = (1 << 3), /* GIT_STATUS_INDEX_RENAMED */ /// /// A change in type for a file has been promoted from the working directory to the Index. A previous version exists in the Head. @@ -63,6 +63,11 @@ public enum FileStatus /// TypeChanged = (1 << 10), /* GIT_STATUS_WT_TYPECHANGE */ + /// + /// The file has been renamed in the working directory. The previous version at the previous name exists in the Index. + /// + RenamedInWorkDir = (1 << 11), /* GIT_STATUS_WT_RENAMED */ + /// /// The file is but its name and/or path matches an exclude pattern in a gitignore file. /// diff --git a/LibGit2Sharp/Index.cs b/LibGit2Sharp/Index.cs index 992c0f703..1e7031e60 100644 --- a/LibGit2Sharp/Index.cs +++ b/LibGit2Sharp/Index.cs @@ -498,11 +498,13 @@ public virtual FileStatus RetrieveStatus(string filePath) /// /// Retrieves the state of all files in the working directory, comparing them against the staging area and the latest commmit. /// + /// If set, the options that control the status investigation. /// A holding the state of all the files. - public virtual RepositoryStatus RetrieveStatus() + public virtual RepositoryStatus RetrieveStatus(StatusOptions options = null) { ReloadFromDisk(); - return new RepositoryStatus(repo); + + return new RepositoryStatus(repo, options); } internal void Reset(TreeChanges changes) diff --git a/LibGit2Sharp/LibGit2Sharp.csproj b/LibGit2Sharp/LibGit2Sharp.csproj index 3832ac559..e0a30a56b 100644 --- a/LibGit2Sharp/LibGit2Sharp.csproj +++ b/LibGit2Sharp/LibGit2Sharp.csproj @@ -89,6 +89,12 @@ + + + + + + diff --git a/LibGit2Sharp/RenameDetails.cs b/LibGit2Sharp/RenameDetails.cs new file mode 100644 index 000000000..bfc470589 --- /dev/null +++ b/LibGit2Sharp/RenameDetails.cs @@ -0,0 +1,116 @@ +using System; +using System.Diagnostics; +using LibGit2Sharp.Core; + +namespace LibGit2Sharp +{ + /// + /// Holds the rename details of a particular file. + /// + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public class RenameDetails : IEquatable + { + private readonly string oldFilePath; + private readonly string newFilePath; + private readonly int similarity; + + private static readonly LambdaEqualityHelper equalityHelper = + new LambdaEqualityHelper(x => x.OldFilePath, x => x.NewFilePath, x => x.Similarity); + + /// + /// Needed for mocking purposes. + /// + protected RenameDetails() + { } + + internal RenameDetails(string oldFilePath, string newFilePath, int similarity) + { + this.oldFilePath = oldFilePath; + this.newFilePath = newFilePath; + this.similarity = similarity; + } + + /// + /// Gets the relative filepath to the working directory of the old file (the rename source). + /// + public virtual string OldFilePath + { + get { return oldFilePath; } + } + + /// + /// Gets the relative filepath to the working directory of the new file (the rename target). + /// + public virtual string NewFilePath + { + get { return newFilePath; } + } + + /// + /// Gets the similarity between the old file an the new file (0-100). + /// + public virtual int Similarity + { + get { return similarity; } + } + + /// + /// Determines whether the specified is equal to the current . + /// + /// The to compare with the current . + /// True if the specified is equal to the current ; otherwise, false. + public override bool Equals(object obj) + { + return Equals(obj as RenameDetails); + } + + /// + /// Determines whether the specified is equal to the current . + /// + /// The to compare with the current . + /// True if the specified is equal to the current ; otherwise, false. + public bool Equals(RenameDetails other) + { + return equalityHelper.Equals(this, other); + } + + /// + /// Returns the hash code for this instance. + /// + /// A 32-bit signed integer hash code. + public override int GetHashCode() + { + return equalityHelper.GetHashCode(this); + } + + /// + /// Tests if two are equal. + /// + /// First to compare. + /// Second to compare. + /// True if the two objects are equal; false otherwise. + public static bool operator ==(RenameDetails left, RenameDetails right) + { + return Equals(left, right); + } + + /// + /// Tests if two are different. + /// + /// First to compare. + /// Second to compare. + /// True if the two objects are different; false otherwise. + public static bool operator !=(RenameDetails left, RenameDetails right) + { + return !Equals(left, right); + } + + private string DebuggerDisplay + { + get + { + return string.Format("{0} -> {1} [{2}%]", OldFilePath, NewFilePath, Similarity); + } + } + } +} diff --git a/LibGit2Sharp/RepositoryStatus.cs b/LibGit2Sharp/RepositoryStatus.cs index f025ab0a4..ddb8f58db 100644 --- a/LibGit2Sharp/RepositoryStatus.cs +++ b/LibGit2Sharp/RepositoryStatus.cs @@ -4,8 +4,10 @@ using System.Diagnostics; using System.Globalization; using System.Linq; +using System.Runtime.InteropServices; using LibGit2Sharp.Core; using LibGit2Sharp.Core.Compat; +using LibGit2Sharp.Core.Handles; namespace LibGit2Sharp { @@ -17,20 +19,22 @@ namespace LibGit2Sharp public class RepositoryStatus : IEnumerable { private readonly ICollection statusEntries; - private readonly List added = new List(); - private readonly List staged = new List(); - private readonly List removed = new List(); - private readonly List missing = new List(); - private readonly List modified = new List(); - private readonly List untracked = new List(); - private readonly List ignored = new List(); + private readonly List added = new List(); + private readonly List staged = new List(); + private readonly List removed = new List(); + private readonly List missing = new List(); + private readonly List modified = new List(); + private readonly List untracked = new List(); + private readonly List ignored = new List(); + private readonly List renamedInIndex = new List(); + private readonly List renamedInWorkDir = new List(); private readonly bool isDirty; - private readonly IDictionary> dispatcher = Build(); + private readonly IDictionary> dispatcher = Build(); - private static IDictionary> Build() + private static IDictionary> Build() { - return new Dictionary> + return new Dictionary> { { FileStatus.Untracked, (rs, s) => rs.untracked.Add(s) }, { FileStatus.Modified, (rs, s) => rs.modified.Add(s) }, @@ -38,7 +42,9 @@ private static IDictionary> Build() { FileStatus.Added, (rs, s) => rs.added.Add(s) }, { FileStatus.Staged, (rs, s) => rs.staged.Add(s) }, { FileStatus.Removed, (rs, s) => rs.removed.Add(s) }, + { FileStatus.RenamedInIndex, (rs, s) => rs.renamedInIndex.Add(s) }, { FileStatus.Ignored, (rs, s) => rs.ignored.Add(s) }, + { FileStatus.RenamedInWorkDir, (rs, s) => rs.renamedInWorkDir.Add(s) } }; } @@ -48,34 +54,118 @@ private static IDictionary> Build() protected RepositoryStatus() { } - internal RepositoryStatus(Repository repo) + internal RepositoryStatus(Repository repo, StatusOptions options) { - statusEntries = Proxy.git_status_foreach(repo.Handle, StateChanged); - isDirty = statusEntries.Any(entry => entry.State != FileStatus.Ignored); + statusEntries = new List(); + + using (GitStatusOptions coreOptions = CreateStatusOptions(options ?? new StatusOptions())) + { + StatusListSafeHandle list = Proxy.git_status_list_new(repo.Handle, coreOptions); + int count = Proxy.git_status_list_entrycount(list); + + for (int i = 0; i < count; i++) + { + StatusEntrySafeHandle e = Proxy.git_status_byindex(list, i); + GitStatusEntry entry = e.MarshalAsGitStatusEntry(); + + GitDiffDelta deltaHeadToIndex = null; + GitDiffDelta deltaIndexToWorkDir = null; + + if (entry.HeadToIndexPtr != IntPtr.Zero) + { + deltaHeadToIndex = (GitDiffDelta)Marshal.PtrToStructure(entry.HeadToIndexPtr, typeof(GitDiffDelta)); + } + if (entry.IndexToWorkDirPtr != IntPtr.Zero) + { + deltaIndexToWorkDir = (GitDiffDelta)Marshal.PtrToStructure(entry.IndexToWorkDirPtr, typeof(GitDiffDelta)); + } + + AddStatusEntryForDelta(entry.Status, deltaHeadToIndex, deltaIndexToWorkDir); + } + + isDirty = statusEntries.Any(entry => entry.State != FileStatus.Ignored); + } + } + + private static GitStatusOptions CreateStatusOptions(StatusOptions options) + { + var coreOptions = new GitStatusOptions + { + Version = 1, + Show = (GitStatusShow)options.Show, + Flags = + GitStatusOptionFlags.IncludeIgnored | + GitStatusOptionFlags.IncludeUntracked | + GitStatusOptionFlags.RecurseUntrackedDirs, + }; + + if (options.DetectRenamesInIndex) + { + coreOptions.Flags |= + GitStatusOptionFlags.RenamesHeadToIndex | + GitStatusOptionFlags.RenamesFromRewrites; + } + + if (options.DetectRenamesInWorkDir) + { + coreOptions.Flags |= + GitStatusOptionFlags.RenamesIndexToWorkDir | + GitStatusOptionFlags.RenamesFromRewrites; + } + + if (options.ExcludeSubmodules) + { + coreOptions.Flags |= + GitStatusOptionFlags.ExcludeSubmodules; + } + + return coreOptions; } - private StatusEntry StateChanged(IntPtr filePathPtr, uint state) + private void AddStatusEntryForDelta(FileStatus gitStatus, GitDiffDelta deltaHeadToIndex, GitDiffDelta deltaIndexToWorkDir) { - FilePath filePath = LaxFilePathMarshaler.FromNative(filePathPtr); - var gitStatus = (FileStatus)state; + RenameDetails headToIndexRenameDetails = null; + RenameDetails indexToWorkDirRenameDetails = null; + + if ((gitStatus & FileStatus.RenamedInIndex) == FileStatus.RenamedInIndex) + { + headToIndexRenameDetails = new RenameDetails( + LaxFilePathMarshaler.FromNative(deltaHeadToIndex.OldFile.Path).Native, + LaxFilePathMarshaler.FromNative(deltaHeadToIndex.NewFile.Path).Native, + (int)deltaHeadToIndex.Similarity); + } + + if ((gitStatus & FileStatus.RenamedInWorkDir) == FileStatus.RenamedInWorkDir) + { + indexToWorkDirRenameDetails = new RenameDetails( + LaxFilePathMarshaler.FromNative(deltaIndexToWorkDir.OldFile.Path).Native, + LaxFilePathMarshaler.FromNative(deltaIndexToWorkDir.NewFile.Path).Native, + (int)deltaIndexToWorkDir.Similarity); + } + + var filePath = (deltaIndexToWorkDir != null) ? + LaxFilePathMarshaler.FromNative(deltaIndexToWorkDir.NewFile.Path).Native : + LaxFilePathMarshaler.FromNative(deltaHeadToIndex.NewFile.Path).Native; + + StatusEntry statusEntry = new StatusEntry(filePath, gitStatus, headToIndexRenameDetails, indexToWorkDirRenameDetails); - foreach (KeyValuePair> kvp in dispatcher) + foreach (KeyValuePair> kvp in dispatcher) { if (!gitStatus.HasFlag(kvp.Key)) { continue; } - kvp.Value(this, filePath.Native); + kvp.Value(this, statusEntry); } - return new StatusEntry(filePath.Native, gitStatus); + statusEntries.Add(statusEntry); } /// - /// Gets the for the specified relative path. + /// Gets the for the specified relative path. /// - public virtual FileStatus this[string path] + public virtual StatusEntry this[string path] { get { @@ -87,10 +177,10 @@ public virtual FileStatus this[string path] if (entries.Count == 0) { - return FileStatus.Nonexistent; + return new StatusEntry(path, FileStatus.Nonexistent); } - return entries.Single().State; + return entries.Single(); } } @@ -115,7 +205,7 @@ IEnumerator IEnumerable.GetEnumerator() /// /// List of files added to the index, which are not in the current commit /// - public virtual IEnumerable Added + public virtual IEnumerable Added { get { return added; } } @@ -123,7 +213,7 @@ public virtual IEnumerable Added /// /// List of files added to the index, which are already in the current commit with different content /// - public virtual IEnumerable Staged + public virtual IEnumerable Staged { get { return staged; } } @@ -131,7 +221,7 @@ public virtual IEnumerable Staged /// /// List of files removed from the index but are existent in the current commit /// - public virtual IEnumerable Removed + public virtual IEnumerable Removed { get { return removed; } } @@ -139,7 +229,7 @@ public virtual IEnumerable Removed /// /// List of files existent in the index but are missing in the working directory /// - public virtual IEnumerable Missing + public virtual IEnumerable Missing { get { return missing; } } @@ -147,7 +237,7 @@ public virtual IEnumerable Missing /// /// List of files with unstaged modifications. A file may be modified and staged at the same time if it has been modified after adding. /// - public virtual IEnumerable Modified + public virtual IEnumerable Modified { get { return modified; } } @@ -155,7 +245,7 @@ public virtual IEnumerable Modified /// /// List of files existing in the working directory but are neither tracked in the index nor in the current commit. /// - public virtual IEnumerable Untracked + public virtual IEnumerable Untracked { get { return untracked; } } @@ -163,11 +253,27 @@ public virtual IEnumerable Untracked /// /// List of files existing in the working directory that are ignored. /// - public virtual IEnumerable Ignored + public virtual IEnumerable Ignored { get { return ignored; } } + /// + /// List of files that were renamed and staged. + /// + public virtual IEnumerable RenamedInIndex + { + get { return renamedInIndex; } + } + + /// + /// List of files that were renamed in the working directory but have not been staged. + /// + public virtual IEnumerable RenamedInWorkDir + { + get { return renamedInWorkDir; } + } + /// /// True if the index or the working directory has been altered since the last commit. False otherwise. /// diff --git a/LibGit2Sharp/StatusEntry.cs b/LibGit2Sharp/StatusEntry.cs index a11d57be2..a049d3164 100644 --- a/LibGit2Sharp/StatusEntry.cs +++ b/LibGit2Sharp/StatusEntry.cs @@ -12,9 +12,11 @@ public class StatusEntry : IEquatable { private readonly string filePath; private readonly FileStatus state; + private readonly RenameDetails headToIndexRenameDetails; + private readonly RenameDetails indexToWorkDirRenameDetails; private static readonly LambdaEqualityHelper equalityHelper = - new LambdaEqualityHelper(x => x.FilePath, x => x.State); + new LambdaEqualityHelper(x => x.FilePath, x => x.State, x => x.HeadToIndexRenameDetails, x => x.IndexToWorkDirRenameDetails); /// /// Needed for mocking purposes. @@ -22,10 +24,12 @@ public class StatusEntry : IEquatable protected StatusEntry() { } - internal StatusEntry(string filePath, FileStatus state) + internal StatusEntry(string filePath, FileStatus state, RenameDetails headToIndexRenameDetails = null, RenameDetails indexToWorkDirRenameDetails = null) { this.filePath = filePath; this.state = state; + this.headToIndexRenameDetails = headToIndexRenameDetails; + this.indexToWorkDirRenameDetails = indexToWorkDirRenameDetails; } /// @@ -37,13 +41,29 @@ public virtual FileStatus State } /// - /// Gets the relative filepath to the working directory of the file. + /// Gets the relative new filepath to the working directory of the file. /// public virtual string FilePath { get { return filePath; } } + /// + /// Gets the rename details from the HEAD to the Index, if this contains + /// + public virtual RenameDetails HeadToIndexRenameDetails + { + get { return headToIndexRenameDetails; } + } + + /// + /// Gets the rename details from the Index to the working directory, if this contains + /// + public virtual RenameDetails IndexToWorkDirRenameDetails + { + get { return indexToWorkDirRenameDetails; } + } + /// /// Determines whether the specified is equal to the current . /// @@ -97,7 +117,19 @@ public override int GetHashCode() private string DebuggerDisplay { - get { return string.Format("{0}: {1}", State, FilePath); } + get + { + if ((State & FileStatus.RenamedInIndex) == FileStatus.RenamedInIndex || + (State & FileStatus.RenamedInWorkDir) == FileStatus.RenamedInWorkDir) + { + string oldFilePath = ((State & FileStatus.RenamedInIndex) == FileStatus.RenamedInIndex) ? + HeadToIndexRenameDetails.OldFilePath : IndexToWorkDirRenameDetails.OldFilePath; + + return string.Format("{0}: {1} -> {2}", State, oldFilePath, FilePath); + } + + return string.Format("{0}: {1}", State, FilePath); + } } } } diff --git a/LibGit2Sharp/StatusOptions.cs b/LibGit2Sharp/StatusOptions.cs new file mode 100644 index 000000000..13963711a --- /dev/null +++ b/LibGit2Sharp/StatusOptions.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace LibGit2Sharp +{ + /// + /// Flags controlling what files are reported by status. + /// + public enum StatusShowOption + { + /// + /// Both the index and working directory are examined for changes + /// + IndexAndWorkDir = 0, + + /// + /// Only the index is examined for changes + /// + IndexOnly = 1, + + /// + /// Only the working directory is examined for changes + /// + WorkDirOnly = 2 + } + + /// + /// Options controlling the status behavior. + /// + public sealed class StatusOptions + { + /// + /// Initializes a new instance of the class. + /// By default, both the index and the working directory will be scanned + /// for status, and renames will be detected from changes staged in the + /// index only. + /// + public StatusOptions() + { + DetectRenamesInIndex = true; + } + + /// + /// Which files should be scanned and returned + /// + public StatusShowOption Show { get; set; } + + /// + /// Examine the staged changes for renames. + /// + public bool DetectRenamesInIndex { get; set; } + + /// + /// Examine unstaged changes in the working directory for renames. + /// + public bool DetectRenamesInWorkDir { get; set; } + + /// + /// Exclude submodules from being scanned for status + /// + public bool ExcludeSubmodules { get; set; } + } +}