Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions GitIntegration.Test/Integration/GitPatchRoundTripTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,56 @@
Assert.AreEqual(1, unstaged.Single().Insertions);
}

[TestMethod]
public async Task UnstagingOneHunkOfAStagedRenameKeepsTheRenameAsync()
{
await IntegrationGitFixture.RequireGitAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false);

using TemporaryRepository repository = new();
GitClient client = IntegrationGitFixture.CreateClient();
GitRepository seeded = await SeedAsync(client, repository, []).ConfigureAwait(false);

string[] lines = [.. Enumerable.Range(1, 30).Select(number => $"{number}")];
repository.WriteFile("a", string.Join('\n', lines) + "\n");
await CommitAllAsync(seeded).ConfigureAwait(false);

// Rename a to b, change lines 2 and 28 of b, and stage all of it.
repository.DeleteFile("a");
lines[1] = "2 changed";
lines[27] = "28 changed";
repository.WriteFile("b", string.Join('\n', lines) + "\n");
_ = await seeded.Add().All()
.ExecuteAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false);

GitRepository opened = await client.OpenAsync(repository.Root).ConfigureAwait(false);

Check warning on line 90 in GitIntegration.Test/Integration/GitPatchRoundTripTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaEUdNmXLNgfEFUT5alF&open=AaEUdNmXLNgfEFUT5alF&pullRequest=193

GitFilePatch file = (await opened.Patch().Staged().DetectRenames().ExecuteAsync().ConfigureAwait(false)).Files.Single();

Check warning on line 92 in GitIntegration.Test/Integration/GitPatchRoundTripTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaEUdNmXLNgfEFUT5alG&open=AaEUdNmXLNgfEFUT5alG&pullRequest=193

Assert.AreEqual("a", file.OriginalPath?.WeakString, "The fixture must stage a rename, or this proves nothing.");
Assert.AreEqual(2, file.Hunks.Count, "The fixture must produce two hunks, or this proves nothing.");

Check warning on line 95 in GitIntegration.Test/Integration/GitPatchRoundTripTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.HasCount' instead of 'Assert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaEUdNmXLNgfEFUT5alK&open=AaEUdNmXLNgfEFUT5alK&pullRequest=193

_ = await opened.Apply(file.PatchFor([file.Hunks[0]])).ToIndex().Reversed().ExecuteAsync().ConfigureAwait(false);

Check warning on line 97 in GitIntegration.Test/Integration/GitPatchRoundTripTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaEUdNmXLNgfEFUT5alH&open=AaEUdNmXLNgfEFUT5alH&pullRequest=193

IReadOnlyList<GitDiffEntry> staged = await opened.Diff().Staged().DetectRenames().WithLineCounts()
.ExecuteAsync().ConfigureAwait(false);

Check warning on line 100 in GitIntegration.Test/Integration/GitPatchRoundTripTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaEUdNmXLNgfEFUT5alI&open=AaEUdNmXLNgfEFUT5alI&pullRequest=193
IReadOnlyList<GitDiffEntry> unstaged = await opened.Diff().WithLineCounts()
.ExecuteAsync().ConfigureAwait(false);

Check warning on line 102 in GitIntegration.Test/Integration/GitPatchRoundTripTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaEUdNmXLNgfEFUT5alJ&open=AaEUdNmXLNgfEFUT5alJ&pullRequest=193

GitDiffEntry rename = staged.Single();
Assert.AreEqual(GitChangeKind.Renamed, rename.Kind);
Assert.AreEqual("b", rename.Path.WeakString);
Assert.AreEqual("a", rename.OriginalPath?.WeakString);
Assert.AreEqual(1, rename.Insertions, "Only the line-28 change stays staged.");

GitDiffEntry pending = unstaged.Single();
Assert.AreEqual("b", pending.Path.WeakString);
Assert.AreEqual(1, pending.Insertions, "Only the line-2 change was unstaged.");
}

[TestMethod]
public async Task CheckedReportsFailureWhenTheWorkingTreeMovedAsync()
{
await IntegrationGitFixture.RequireGitAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false);

Check warning on line 118 in GitIntegration.Test/Integration/GitPatchRoundTripTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'TestContext.CancellationToken' instead of 'TestContext.CancellationTokenSource.Token'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaEUdNmXLNgfEFUT5alL&open=AaEUdNmXLNgfEFUT5alL&pullRequest=193

using TemporaryRepository repository = new();
GitClient client = IntegrationGitFixture.CreateClient();
Expand All @@ -85,7 +131,7 @@
_ = await init.Repository.Add().All()
.ExecuteAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false);
_ = await init.Repository.Commit("seed".As<GitCommitMessage>())
.ExecuteAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false);

Check warning on line 134 in GitIntegration.Test/Integration/GitPatchRoundTripTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'TestContext.CancellationToken' instead of 'TestContext.CancellationTokenSource.Token'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaEUdNmXLNgfEFUT5alM&open=AaEUdNmXLNgfEFUT5alM&pullRequest=193

repository.WriteFile("f.txt", "one\nCHANGED\nthree\n");

Expand Down
43 changes: 43 additions & 0 deletions GitIntegration.Test/Models/GitPatchTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,49 @@ public class GitPatchTests
Hunks = [HunkOne, HunkTwo],
};

private const string RenameHeader =
"diff --git a/a.txt b/b.txt\nsimilarity index 93%\nrename from a.txt\nrename to b.txt\n"
+ "index 92dfa21..81f8caa 100644\n--- a/a.txt\n+++ b/b.txt\n";

private static GitFilePatch RenamedFileWithTwoHunks => FileWithTwoHunks with
{
Path = "b.txt".As<RelativeFilePath>(),
OriginalPath = "a.txt".As<RelativeFilePath>(),
Header = RenameHeader,
};

[TestMethod]
public void PatchForSomeHunksOfARenameWritesAPlainChangeToTheNewPath()
{
string patch = RenamedFileWithTwoHunks.PatchFor([HunkOne]);

Assert.AreEqual(
"diff --git a/b.txt b/b.txt\nindex 92dfa21..81f8caa 100644\n--- a/b.txt\n+++ b/b.txt\n" + HunkOne.Text,
patch);
}

[TestMethod]
public void PatchForEveryHunkOfARenameKeepsTheRenameHeader() =>
Assert.AreEqual(
RenameHeader + HunkOne.Text + HunkTwo.Text,
RenamedFileWithTwoHunks.PatchFor([HunkOne, HunkTwo]));

[TestMethod]
public void PatchForSomeHunksOfAQuotedRenameKeepsTheQuoting()
{
GitFilePatch file = RenamedFileWithTwoHunks with
{
Path = "b \"q\".txt".As<RelativeFilePath>(),
Header = "diff --git a/a.txt \"b/b \\\"q\\\".txt\"\nsimilarity index 93%\nrename from a.txt\n"
+ "rename to \"b \\\"q\\\".txt\"\nindex 92dfa21..81f8caa 100644\n--- a/a.txt\n+++ \"b/b \\\"q\\\".txt\"\n",
};

Assert.AreEqual(
"diff --git \"a/b \\\"q\\\".txt\" \"b/b \\\"q\\\".txt\"\nindex 92dfa21..81f8caa 100644\n"
+ "--- \"a/b \\\"q\\\".txt\"\n+++ \"b/b \\\"q\\\".txt\"\n" + HunkTwo.Text,
file.PatchFor([HunkTwo]));
}

[TestMethod]
public void PatchForOneHunkCarriesTheHeaderAndThatHunkOnly()
{
Expand Down
76 changes: 75 additions & 1 deletion GitIntegration/Models/GitPatch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@
/// another file would otherwise get a shorter patch than it asked for, or a header with no body
/// at all, and neither outcome says which hunk went missing.
/// </para>
/// <para>
/// For a renamed or copied file, the full header, rename lines included, is kept only when
/// every hunk is given. A subset is written as a plain modification of <see cref="Path"/>, so
/// that reverse-applying it to the index unstages only those hunks and leaves the rename staged.
/// </para>
/// </remarks>
/// <param name="hunks">The hunks to include.</param>
/// <returns>The patch text.</returns>
Expand All @@ -159,7 +164,12 @@
nameof(hunks));
}

StringBuilder builder = new(Header);
// A renamed or copied file's header moves the path as well as carrying the hunks. Writing
// it under only some of the hunks would apply, or with --reverse undo, the whole rename
// alongside the chosen lines, so a subset goes out as a plain change to the new path.
bool partialRename = OriginalPath is not null && Hunks.Any(hunk => !wanted.Contains(hunk));

StringBuilder builder = new(partialRename ? ModificationHeaderForNewPath() : Header);
int emitted = 0;

foreach (GitHunk hunk in Hunks.Where(wanted.Contains))
Expand All @@ -176,6 +186,70 @@
nameof(hunks))
: builder.ToString();
}

/// <summary>
/// Rewrites <see cref="Header"/> as a plain modification of the new path: both sides of the
/// <c>diff --git</c>, <c>---</c> and <c>+++</c> lines name it, and the similarity, rename and
/// copy lines are dropped. Every other line, the index and mode lines included, is kept.
/// </summary>
/// <returns>The rewritten header.</returns>
private string ModificationHeaderForNewPath()
{
const string NewSidePrefix = "+++ ";
const string OldSidePrefix = "--- ";

string[] lines = Header.Split('\n');
string newSide = lines.FirstOrDefault(line => line.StartsWith(NewSidePrefix, StringComparison.Ordinal))?[NewSidePrefix.Length..]
?? throw new InvalidOperationException(
$"The header for '{Path.WeakString}' has no '+++' line, so its hunks cannot be written under the new path alone.");

// git C-quotes the whole operand, prefix included, so the prefix sits after the quote.
string oldSide = newSide.StartsWith("\"b/", StringComparison.Ordinal)
? "\"a/" + newSide[3..]
: newSide.StartsWith("b/", StringComparison.Ordinal)

Check warning on line 209 in GitIntegration/Models/GitPatch.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 209 in GitIntegration/Models/GitPatch.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 209 in GitIntegration/Models/GitPatch.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 209 in GitIntegration/Models/GitPatch.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 209 in GitIntegration/Models/GitPatch.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 209 in GitIntegration/Models/GitPatch.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 209 in GitIntegration/Models/GitPatch.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 209 in GitIntegration/Models/GitPatch.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.
? "a/" + newSide[2..]
: throw new InvalidOperationException(
$"The header for '{Path.WeakString}' has a '+++' line with no b/ prefix: '{newSide}'.");

Check warning on line 212 in GitIntegration/Models/GitPatch.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaEUdNhqLNgfEFUT5alE&open=AaEUdNhqLNgfEFUT5alE&pullRequest=193

StringBuilder builder = new();

for (int index = 0; index < lines.Length; index++)
{
string line = lines[index];

if (index == lines.Length - 1)
{
// The text after the header's final newline, which is empty for a well-formed header.
_ = builder.Append(line);
continue;
}

if (index == 0)
{
line = $"diff --git {oldSide} {newSide}";
}
else if (line.StartsWith(OldSidePrefix, StringComparison.Ordinal))
{
line = OldSidePrefix + oldSide;
}
else if (IsRenameOrCopyLine(line))
{
continue;
}

_ = builder.Append(line).Append('\n');
}

return builder.ToString();
}

private static bool IsRenameOrCopyLine(string line) =>
line.StartsWith("similarity index ", StringComparison.Ordinal) ||
line.StartsWith("dissimilarity index ", StringComparison.Ordinal) ||
line.StartsWith("rename from ", StringComparison.Ordinal) ||
line.StartsWith("rename to ", StringComparison.Ordinal) ||
line.StartsWith("copy from ", StringComparison.Ordinal) ||
line.StartsWith("copy to ", StringComparison.Ordinal);
}

/// <summary>
Expand Down
Loading