diff --git a/GitIntegration.Test/Builders/GitPatchBuilderTests.cs b/GitIntegration.Test/Builders/GitPatchBuilderTests.cs index 56d090e..1193da3 100644 --- a/GitIntegration.Test/Builders/GitPatchBuilderTests.cs +++ b/GitIntegration.Test/Builders/GitPatchBuilderTests.cs @@ -28,11 +28,45 @@ public void BuildsTheDefaultPatchVector() "--no-color", "--src-prefix=a/", "--dst-prefix=b/", + "-U3", + "--no-renames", ]; Assert.AreSequenceEqual(expectedArguments, builder.BuildArguments()); } + [TestMethod] + public void PinsContextAndRenameDetectionWhenNeitherIsRequested() + { + RecordingGitProcessRunner runner = new(); + GitPatchBuilder builder = new(runner, TestPaths.Root); + + IReadOnlyList arguments = builder.BuildArguments(); + + Assert.IsTrue( + arguments.Contains("-U3"), + "Left out, diff.context=0 in the host's config yields zero-context hunks, which Apply can never stage."); + Assert.IsTrue( + arguments.Contains("--no-renames"), + "Left out, git's default rename detection and diff.renames=copies decide the patch shape instead of DetectRenames."); + Assert.IsFalse(arguments.Contains("--find-renames")); + } + + [TestMethod] + public void ExplicitContextAndRenamesReplaceThePinnedDefaults() + { + RecordingGitProcessRunner runner = new(); + GitPatchBuilder builder = new(runner, TestPaths.Root); + _ = builder.WithContext(7).DetectRenames(); + + IReadOnlyList arguments = builder.BuildArguments(); + + Assert.AreEqual(1, arguments.Count(argument => argument.StartsWith("-U", StringComparison.Ordinal))); + Assert.IsTrue(arguments.Contains("-U7")); + Assert.IsTrue(arguments.Contains("--find-renames")); + Assert.IsFalse(arguments.Contains("--no-renames")); + } + [TestMethod] public void AlwaysPinsBothPathPrefixes() { diff --git a/GitIntegration.Test/Integration/GitPatchRoundTripTests.cs b/GitIntegration.Test/Integration/GitPatchRoundTripTests.cs index 40bb9cc..11a26b8 100644 --- a/GitIntegration.Test/Integration/GitPatchRoundTripTests.cs +++ b/GitIntegration.Test/Integration/GitPatchRoundTripTests.cs @@ -242,6 +242,69 @@ public async Task RoundTripsUnderHostileDiffConfigurationAsync() "The patch read under this configuration has to apply back cleanly, or the verbs work only for users whose git is configured the way the tests assume."); } + [TestMethod] + public async Task RoundTripsUnderZeroContextConfigurationAsync() + { + await IntegrationGitFixture.RequireGitAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false); + + using TemporaryRepository repository = new(); + GitClient client = IntegrationGitFixture.CreateClient(); + + // diff.context=0 makes a plain git diff emit zero-context hunks, which git apply refuses + // without --unidiff-zero. + GitRepository seeded = await SeedAsync(client, repository, [("diff.context", "0")]).ConfigureAwait(false); + + string[] lines = [.. Enumerable.Range(1, 50).Select(number => $"line {number}")]; + repository.WriteFile("f.txt", string.Join('\n', lines) + "\n"); + await CommitAllAsync(seeded).ConfigureAwait(false); + + lines[24] = "line 25 changed"; + repository.WriteFile("f.txt", string.Join('\n', lines) + "\n"); + + GitRepository opened = await client.OpenAsync(repository.Root).ConfigureAwait(false); + GitFilePatch file = (await opened.Patch().ExecuteAsync().ConfigureAwait(false)).Files.Single(); + + _ = await opened.Apply(file.PatchFor(file.Hunks)).ToIndex().ExecuteAsync().ConfigureAwait(false); + + IReadOnlyList unstaged = await opened.Diff().ExecuteAsync().ConfigureAwait(false); + + Assert.AreEqual( + 0, + unstaged.Count, + "A patch read with no WithContext call must still apply, whatever diff.context the host sets."); + } + + [TestMethod] + public async Task ReportsAStagedRenameAsDeleteAndAddUnlessRequestedAsync() + { + await IntegrationGitFixture.RequireGitAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false); + + using TemporaryRepository repository = new(); + GitClient client = IntegrationGitFixture.CreateClient(); + + GitRepository seeded = await SeedAsync(client, repository, [("diff.renames", "copies")]).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); + + GitPatch plain = await opened.Patch().Staged().ExecuteAsync().ConfigureAwait(false); + + Assert.AreEqual(2, plain.Files.Count, "Without DetectRenames the rename is a delete and an add."); + Assert.IsFalse(plain.Files.Any(file => file.Kind == GitChangeKind.Renamed)); + + GitPatch detected = await opened.Patch().Staged().DetectRenames().ExecuteAsync().ConfigureAwait(false); + GitFilePatch renamed = detected.Files.Single(); + + Assert.AreEqual(GitChangeKind.Renamed, renamed.Kind); + Assert.AreEqual("old.txt", renamed.OriginalPath?.WeakString); + } + /// /// Creates an empty repository with the fixture identity and any extra configuration a test /// needs, before anything is committed. diff --git a/GitIntegration/Builders/GitPatchBuilder.cs b/GitIntegration/Builders/GitPatchBuilder.cs index d290aec..927961e 100644 --- a/GitIntegration/Builders/GitPatchBuilder.cs +++ b/GitIntegration/Builders/GitPatchBuilder.cs @@ -37,8 +37,8 @@ public interface IGitPatchBuilder : IGitCommandBuilder /// rather than the setting that caused it. /// /// - /// The number of context lines, at least one. Git's own default applies when this is never - /// called. + /// The number of context lines, at least one. Three, git's stock default, applies when this is + /// never called, whatever diff.context the host has configured. /// /// The same builder, to allow chaining. /// is less than one. @@ -70,6 +70,9 @@ public interface IGitPatchBuilder : IGitCommandBuilder public IGitPatchBuilder Between(GitRefName fromRevision, GitRefName toRevision); /// Reports a delete and an add of similar content as a rename. + /// + /// Detection is off until this is called, whatever diff.renames the host has configured. + /// /// The same builder, to allow chaining. public IGitPatchBuilder DetectRenames(); } @@ -88,6 +91,9 @@ internal sealed class GitPatchBuilder(IGitProcessRunner runner, AbsoluteDirector // One slot, so Against and Between cannot combine into a three-revision vector git would reject. private string[] _revisions = []; private bool _staged; + // Git's stock default, pinned so diff.context in a config file cannot change the hunk shape. + private const int DefaultContextLines = 3; + private int? _contextLines; private bool _detectRenames; @@ -177,15 +183,13 @@ protected override void AppendVerbArguments(ICollection arguments) arguments.Add("--cached"); } - if (_contextLines is int contextLines) - { - arguments.Add($"-U{contextLines}"); - } + // Always emitted. diff.context=0 would otherwise give a zero-context patch, which is the + // shape WithContext refuses because Apply can never stage it. + arguments.Add($"-U{_contextLines ?? DefaultContextLines}"); - if (_detectRenames) - { - arguments.Add("--find-renames"); - } + // Git detects renames by default, and diff.renames=copies adds copy headers this parser + // does not read, so the host's setting is overridden in both directions. + arguments.Add(_detectRenames ? "--find-renames" : "--no-renames"); if (_revisions.Length > 0) {