diff --git a/GitIntegration.Test/Builders/GitDiffBuilderTests.cs b/GitIntegration.Test/Builders/GitDiffBuilderTests.cs index cd1ec6d..e6d0c30 100644 --- a/GitIntegration.Test/Builders/GitDiffBuilderTests.cs +++ b/GitIntegration.Test/Builders/GitDiffBuilderTests.cs @@ -29,6 +29,7 @@ public void BuildsTheDefaultDiffVector() "diff", "--name-status", "-z", + "--no-renames", ]; CollectionAssert.AreEqual(expectedArguments, arguments.ToArray()); } @@ -47,6 +48,24 @@ public void MapsTheOptionFlags() CollectionAssert.Contains(arguments, "--find-copies"); } + [TestMethod] + public void TurnsRenameDetectionOffUnlessRequested() + { + // Git defaults diff.renames to true, so the vector has to say "off" rather than say nothing. + string[] plain = [.. new GitDiffBuilder(new RecordingGitProcessRunner(), TestPaths.Root).BuildArguments()]; + CollectionAssert.Contains(plain, "--no-renames"); + CollectionAssert.DoesNotContain(plain, "--find-renames"); + CollectionAssert.DoesNotContain(plain, "--find-copies"); + + GitDiffBuilder renames = new(new RecordingGitProcessRunner(), TestPaths.Root); + _ = renames.DetectRenames(); + CollectionAssert.DoesNotContain(renames.BuildArguments().ToArray(), "--no-renames"); + + GitDiffBuilder copies = new(new RecordingGitProcessRunner(), TestPaths.Root); + _ = copies.DetectCopies(); + CollectionAssert.DoesNotContain(copies.BuildArguments().ToArray(), "--no-renames"); + } + [TestMethod] public void PutsASingleRevisionBehindTheEndOfOptionsMarker() { diff --git a/GitIntegration.Test/Integration/GitPatchRoundTripTests.cs b/GitIntegration.Test/Integration/GitPatchRoundTripTests.cs index 11a26b8..466fa71 100644 --- a/GitIntegration.Test/Integration/GitPatchRoundTripTests.cs +++ b/GitIntegration.Test/Integration/GitPatchRoundTripTests.cs @@ -274,6 +274,49 @@ public async Task RoundTripsUnderZeroContextConfigurationAsync() "A patch read with no WithContext call must still apply, whatever diff.context the host sets."); } + [TestMethod] + [DataRow("true")] + [DataRow("false")] + [DataRow("copies")] + public async Task DiffReportsAStagedRenameAsPatchDoesWhateverTheHostsDiffRenamesAsync(string diffRenames) + { + await IntegrationGitFixture.RequireGitAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false); + + using TemporaryRepository repository = new(); + GitClient client = IntegrationGitFixture.CreateClient(); + + GitRepository seeded = await SeedAsync(client, repository, [("diff.renames", diffRenames)]).ConfigureAwait(false); + + repository.WriteFile("old.txt", "one\ntwo\nthree\nfour\nfive\n"); + await CommitAllAsync(seeded).ConfigureAwait(false); + + repository.DeleteFile("old.txt"); + repository.WriteFile("new.txt", "one\ntwo\nthree\nfour\nfive\n"); + _ = await seeded.Add().All().ExecuteAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false); + + GitRepository opened = await client.OpenAsync(repository.Root).ConfigureAwait(false); + + // Without DetectRenames, Diff() must agree with Patch(): a delete and an add, never a rename or + // a copy — a staging UI pairs the file list from one with the hunks from the other. + IReadOnlyList plain = await opened.Diff().Staged().ExecuteAsync().ConfigureAwait(false); + GitPatch patch = await opened.Patch().Staged().ExecuteAsync().ConfigureAwait(false); + + CollectionAssert.AreEquivalent( + patch.Files.Select(file => (file.Kind, file.Path.WeakString)).ToArray(), + plain.Select(entry => (entry.Kind, entry.Path.WeakString)).ToArray(), + $"Diff() and Patch() disagree with diff.renames={diffRenames}."); + CollectionAssert.AreEquivalent( + new[] { GitChangeKind.Added, GitChangeKind.Deleted }, + plain.Select(entry => entry.Kind).ToArray()); + + IReadOnlyList detected = + await opened.Diff().Staged().DetectRenames().ExecuteAsync().ConfigureAwait(false); + GitDiffEntry renamed = detected.Single(); + + Assert.AreEqual(GitChangeKind.Renamed, renamed.Kind); + Assert.AreEqual("old.txt", renamed.OriginalPath?.WeakString); + } + [TestMethod] public async Task ReportsAStagedRenameAsDeleteAndAddUnlessRequestedAsync() { diff --git a/GitIntegration/Builders/GitDiffBuilder.cs b/GitIntegration/Builders/GitDiffBuilder.cs index 66ee05e..aeb0759 100644 --- a/GitIntegration/Builders/GitDiffBuilder.cs +++ b/GitIntegration/Builders/GitDiffBuilder.cs @@ -36,10 +36,18 @@ public interface IGitDiffBuilder : IGitCommandBuilderReports a delete and an add of similar content as a rename. + /// + /// Detection is off until this or is called, whatever + /// diff.renames the host has configured. + /// /// The same builder, to allow chaining. public IGitDiffBuilder DetectRenames(); /// Reports an add whose content came from an existing file as a copy. + /// + /// Copies are not reported until this is called, whatever diff.renames the host has + /// configured. + /// /// The same builder, to allow chaining. public IGitDiffBuilder DetectCopies(); @@ -172,6 +180,9 @@ protected override void AppendVerbArguments(ICollection arguments) arguments.Add("--cached"); } + // Git has defaulted diff.renames to true since 2.9, so leaving detection unspecified hands the + // choice to the host's config: DetectRenames() becomes a no-op, diff.renames=copies reports + // copies nobody asked for, and Diff() disagrees with Patch(), which pins this the same way. if (_detectRenames) { arguments.Add("--find-renames"); @@ -182,6 +193,11 @@ protected override void AppendVerbArguments(ICollection arguments) arguments.Add("--find-copies"); } + if (!_detectRenames && !_detectCopies) + { + arguments.Add("--no-renames"); + } + if (_revisions.Length > 0) { AppendOperands(arguments, _revisions);