Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix resetting selected lines for renamed files in index #10252

Merged
merged 13 commits into from
Oct 19, 2022
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
78 changes: 75 additions & 3 deletions GitCommands/Patches/PatchManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ public static class PatchManager
}
}

public static byte[]? GetSelectedLinesAsPatch(string text, int selectionPosition, int selectionLength, bool isIndex, Encoding fileContentEncoding, bool isNewFile)
public static byte[]? GetSelectedLinesAsPatch(string text, int selectionPosition, int selectionLength, bool isIndex, Encoding fileContentEncoding, bool reset, bool isNewFile, bool isRenamed)
{
var selectedChunks = GetSelectedChunks(text, selectionPosition, selectionLength, out var header);
IReadOnlyList<Chunk>? selectedChunks = GetSelectedChunks(text, selectionPosition, selectionLength, out string header);

if (selectedChunks is null || header is null)
{
Expand All @@ -43,9 +43,15 @@ public static class PatchManager
header = CorrectHeaderForNewFile(header);
}

// if file is renamed and selected lines are being reset from index then the patch undoes the rename too
if (isIndex && isRenamed && reset)
{
header = CorrectHeaderForRenamedFile(header);
}

string? body = ToIndexPatch(selectedChunks, isIndex, isWholeFile: false);

if (header is null || body is null)
if (body is null)
{
return null;
}
Expand Down Expand Up @@ -82,6 +88,72 @@ private static string CorrectHeaderForNewFile(string header)
return sb.ToString();
}

private static string CorrectHeaderForRenamedFile(string header)
{
// Expected input:
//
// diff --git a/original.txt b/original2.txt
// similarity index 88%
// rename from original.txt
// rename to original2.txt
// index 0e05069..d4029ea 100644
// --- a/original.txt
// +++ b/original2.txt

// Expected output:
//
// diff --git a/original2.txt b/original2.txt
// index 0e05069..d4029ea 100644
// --- a/original2.txt
// +++ b/original2.txt

string[] headerLines = header.Split(new[] { '\n' });
string? oldNameWithPrefix = null;
string? newName = null;
foreach (string line in headerLines)
{
if (line.StartsWith("+++"))
{
// Takes the "original2.txt" part from patch file line: +++ b/original2.txt
newName = line[6..];
}
else if (line.StartsWith("---"))
{
// Takes the "a/original.txt" part from patch file line: --- a/original.txt
oldNameWithPrefix = line[4..];
}
}

StringBuilder sb = new();

for (int i = 0; i < headerLines.Length; i++)
{
string line = headerLines[i];
if (line.StartsWith("--- "))
{
line = $"--- a/{newName}";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a risky check. I believe that a/, b/ is forced right now in GE, but Git could change the output format any time.

Copy link
Contributor Author

@mdonatas mdonatas Oct 15, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose that would be a serious breaking change (and not just for GE). Also git documentation mentions these prefixes explicitly https://git-scm.com/docs/diff-format

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be changed with diff.mnemonicprefix=true. It was forced to false some time ago so a/b is likely stable.
But number of spaces etc are not porcelain and can be changed without notice.
It could be different in existing Git versions too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you approve of using regex here? Something like:
^---\s+(?<a>.+)/(?<aName>.+)$ and
^\+\+\+\s+(?<b>.+)/(?<bName>.+)$

to capture a prefix used and a file name as well as make it more resilient to spacing.

}
else if (line.Contains($" {oldNameWithPrefix} "))
{
line = line.Replace($" {oldNameWithPrefix} ", $" a/{newName} ");
}
else if (line.StartsWith("rename from ") || line.StartsWith("rename to ") || line.StartsWith("similarity index "))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are any of these strings ever translated in Git?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since these are part of a diff format used by git and are called extended headers I think it's safe to assume that that wouldn't happen. At least documentation doesn't mention anything of that sort.
https://git-scm.com/docs/diff-format

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is described as a format, it is not even a 'requested' format, certainly not a porcelain interface.
Adding translations in Git will break too.
It could be OK anyway, if the consequences are low (like current behavior in master).
There need to be at least comments about this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to push back if I may. Translation argument sounds almost like saying that translating browsers would mean translating HTTP Headers they send (custom ones, not covered by the standard).
These are headers added by the tools for the tools (at least I would assume so). They are meant to be parsable. Translating them would break older versions thus I can't imaging that ever happening. Although if you still insist, I'll add a comment.

{
// Note: this logic depends on git not localizing patch file format
continue;
}

if (i != 0)
{
sb.Append('\n');
}

sb.Append(line);
}

return sb.ToString();
}

public static byte[]? GetSelectedLinesAsNewPatch(GitModule module, string newFileName, string text, int selectionPosition, int selectionLength, Encoding fileContentEncoding, bool reset, byte[] filePreamble, string? treeGuid)
{
var selectedChunks = FromNewFile(module, text, selectionPosition, selectionLength, reset, filePreamble, fileContentEncoding);
Expand Down
2 changes: 2 additions & 0 deletions GitUI/CommandsDialogs/FormCommit.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3404,6 +3404,8 @@ internal TestAccessor(FormCommit formCommit)
internal CheckBox ResetAuthor => _formCommit.ResetAuthor;

internal CheckBox Amend => _formCommit.Amend;

internal void RescanChanges() => _formCommit.RescanChanges();
}
}

Expand Down
29 changes: 24 additions & 5 deletions GitUI/Editor/FileViewer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ private enum ViewMode
private Func<Task>? _deferShowFunc;
private readonly ContinuousScrollEventManager _continuousScrollEventManager;
private FileStatusItem? _viewItem;
private readonly TaskDialogPage _NO_TRANSLATE_resetSelectedLinesConfirmationDialog;

private static string[] _rangeDiffFullPrefixes = { " ", " ++", " + ", " +", " --", " - ", " -", " +-", " -+", " " };
private static string[] _combinedDiffFullPrefixes = { " ", "++", "+ ", " +", "--", "- ", " -" };
Expand Down Expand Up @@ -197,6 +198,16 @@ public FileViewer()

_fullPathResolver = new FullPathResolver(() => Module.WorkingDir);
SupportLinePatching = false;

_NO_TRANSLATE_resetSelectedLinesConfirmationDialog = new()
{
Text = TranslatedStrings.ResetSelectedLinesConfirmation,
Caption = TranslatedStrings.ResetChangesCaption,
Icon = TaskDialogIcon.Warning,
Buttons = { TaskDialogButton.Yes, TaskDialogButton.No },
DefaultButton = TaskDialogButton.Yes,
Copy link
Member

@RussKie RussKie Oct 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a good idea? I think the default button for destructive operations should be the one that doesn't lead to data loss.
Which button is selected by default now (in the message box)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes is currently the default in the MessageBox and because I use it a lot in my daily work (select text, r, enter, repeat) I didn't want to change the behavior even though I agree with what you said.

SizeToContent = true,
};
}

// Public properties
Expand Down Expand Up @@ -1446,7 +1457,9 @@ public void StageSelectedLines(bool stage)
GetSelectionLength(),
isIndex: !stage,
Encoding,
_viewItem.Item.IsNew);
reset: false,
_viewItem.Item.IsNew,
_viewItem.Item.IsRenamed);
}

if (patch is null || patch.Length == 0)
Expand Down Expand Up @@ -1476,8 +1489,7 @@ public void ResetNoncommittedSelectedLines()
return;
}

if (MessageBox.Show(this, TranslatedStrings.ResetSelectedLinesConfirmation, TranslatedStrings.ResetChangesCaption,
MessageBoxButtons.YesNo, MessageBoxIcon.Warning) == DialogResult.No)
if (TaskDialog.ShowDialog(Handle, _NO_TRANSLATE_resetSelectedLinesConfirmationDialog) == TaskDialogButton.No)
{
return;
}
Expand Down Expand Up @@ -1508,7 +1520,9 @@ public void ResetNoncommittedSelectedLines()
GetSelectionLength(),
isIndex: true,
Encoding,
_viewItem.Item.IsNew);
reset: true,
_viewItem.Item.IsNew,
_viewItem.Item.IsRenamed);
}
else
{
Expand Down Expand Up @@ -1579,7 +1593,9 @@ private void ApplySelectedLines(bool allFile, bool reverse)
selectionLength,
isIndex: false,
Encoding,
_viewItem.Item.IsNew);
reset: false,
_viewItem.Item.IsNew,
_viewItem.Item.IsRenamed);
}
else
{
Expand Down Expand Up @@ -1983,6 +1999,7 @@ public bool ShowSyntaxHighlightingInDiff
set => _fileViewer.ShowSyntaxHighlightingInDiff = value;
}

public TaskDialogPage ResetSelectedLinesConfirmationDialog => _fileViewer._NO_TRANSLATE_resetSelectedLinesConfirmationDialog;
public ToolStripButton IgnoreWhitespaceAtEolButton => _fileViewer.ignoreWhitespaceAtEol;
public ToolStripMenuItem IgnoreWhitespaceAtEolMenuItem => _fileViewer.ignoreWhitespaceAtEolToolStripMenuItem;

Expand All @@ -1992,6 +2009,8 @@ public bool ShowSyntaxHighlightingInDiff
public ToolStripButton IgnoreAllWhitespacesButton => _fileViewer.ignoreAllWhitespaces;
public ToolStripMenuItem IgnoreAllWhitespacesMenuItem => _fileViewer.ignoreAllWhitespaceChangesToolStripMenuItem;

internal CommandStatus ExecuteCommand(Command command) => _fileViewer.ExecuteCommand((int)command);

internal void IgnoreWhitespaceAtEolToolStripMenuItem_Click(object sender, EventArgs e) => _fileViewer.IgnoreWhitespaceAtEolToolStripMenuItem_Click(sender, e);
internal void IgnoreWhitespaceChangesToolStripMenuItemClick(object sender, EventArgs e) => _fileViewer.IgnoreWhitespaceChangesToolStripMenuItemClick(sender, e);
internal void IgnoreAllWhitespaceChangesToolStripMenuItem_Click(object sender, EventArgs e) => _fileViewer.IgnoreAllWhitespaceChangesToolStripMenuItem_Click(sender, e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
using GitCommands;
using GitUI;
using GitUI.CommandsDialogs;
using GitUI.Editor;
using GitUI.UserControls;
using ICSharpCode.TextEditor;
using NUnit.Framework;

Expand Down Expand Up @@ -244,7 +246,7 @@ public void Should_stage_only_filtered_on_StageAll()
testform.StageAllToolItem.PerformClick();
var fileNotMatchedByFilterIsStillUnstaged = testform.UnstagedList.AllItems.Where(i => i.Item.Name == "file2.txt").Any();
var fileNotMatchedByFilterIsStillUnstaged = testform.UnstagedList.AllItems.Any(i => i.Item.Name == "file2.txt");
gerhardol marked this conversation as resolved.
Show resolved Hide resolved
Assert.AreEqual(2, testform.StagedList.AllItemsCount);
Assert.AreEqual(1, testform.UnstagedList.AllItemsCount);
Expand Down Expand Up @@ -296,15 +298,15 @@ public void Should_unstage_only_filtered_on_UnstageAll()
testform.UnstageAllToolItem.PerformClick();
var fileNotMatchedByFilterIsStillStaged = testform.StagedList.AllItems.Where(i => i.Item.Name == "file2.txt").Any();
var fileNotMatchedByFilterIsStillStaged = testform.StagedList.AllItems.Any(i => i.Item.Name == "file2.txt");
Assert.AreEqual(2, testform.UnstagedList.AllItemsCount);
Assert.AreEqual(1, testform.StagedList.AllItemsCount);
Assert.IsTrue(fileNotMatchedByFilterIsStillStaged);
});
}

[Test, TestCaseSource(typeof(CommitMessageTestData), "TestCases")]
[Test, TestCaseSource(typeof(CommitMessageTestData), nameof(CommitMessageTestData.TestCases))]
public void AddSelectionToCommitMessage_shall_be_ignored_unless_diff_is_focused(
string message,
int selectionStart,
Expand All @@ -317,7 +319,7 @@ public void Should_unstage_only_filtered_on_UnstageAll()
expectedResult: false, expectedMessage: message, expectedSelectionStart: selectionStart);
}

[Test, TestCaseSource(typeof(CommitMessageTestData), "TestCases")]
[Test, TestCaseSource(typeof(CommitMessageTestData), nameof(CommitMessageTestData.TestCases))]
public void AddSelectionToCommitMessage_shall_be_ignored_if_no_difftext_is_selected(
string message,
int selectionStart,
Expand All @@ -330,7 +332,7 @@ public void Should_unstage_only_filtered_on_UnstageAll()
expectedResult: false, expectedMessage: message, expectedSelectionStart: selectionStart);
}

[Test, TestCaseSource(typeof(CommitMessageTestData), "TestCases")]
[Test, TestCaseSource(typeof(CommitMessageTestData), nameof(CommitMessageTestData.TestCases))]
public void AddSelectionToCommitMessage_shall_modify_the_commit_message(
string message,
int selectionStart,
Expand All @@ -344,7 +346,7 @@ public void Should_unstage_only_filtered_on_UnstageAll()
}

[Test]
public void editFileToolStripMenuItem_Click_no_selection_should_not_throw()
public void EditFileToolStripMenuItem_Click_no_selection_should_not_throw()
{
RunFormTest(async form =>
{
Expand Down Expand Up @@ -473,6 +475,61 @@ public void SelectedDiff_remembers_geometry()
});
}

[Test]
public void ShouldNotUndoRenameFileWhenResettingStagedLines()
{
RunFormTest(form =>
{
FormCommit.TestAccessor ta = form.GetTestAccessor();
FileViewer.TestAccessor selectedDiff = ta.SelectedDiff.GetTestAccessor();
FileViewerInternal? selectedDiffInternal = selectedDiff.FileViewerInternal;
// Commit a file, rename it and introduce a slight content change
string contents = "this\nhas\nmany\nlines\nthis\nhas\nmany\nlines\nthis\nhas\nmany\nlines?\n";
_referenceRepository.CreateCommit("commit", contents, "original.txt");
_referenceRepository.DeleteRepoFile("original.txt");
contents = contents.Replace("?", "!");
_referenceRepository.CreateRepoFile("original2.txt", contents);
ta.RescanChanges();
ThreadHelper.JoinPendingOperations();
ta.UnstagedList.SelectedItems = ta.UnstagedList.AllItems;
ta.UnstagedList.Focus();
ta.ExecuteCommand(FormCommit.Command.StageSelectedFile);
ta.StagedList.SelectedGitItem = ta.StagedList.AllItems.Single(i => i.Item.Name.Contains("original2.txt")).Item;
selectedDiffInternal.Focus();
ThreadHelper.JoinPendingOperations();
selectedDiffInternal.GetTestAccessor().TextEditor.ActiveTextAreaControl.SelectionManager.SetSelection(
new TextLocation(2, 11), new TextLocation(5, 12));
int textLengthBeforeReset = selectedDiffInternal.GetTestAccessor().TextEditor.ActiveTextAreaControl.Document.TextLength;
selectedDiff.ResetSelectedLinesConfirmationDialog.Created += (s, e) =>
{
// Auto-press `Yes`
selectedDiff.ResetSelectedLinesConfirmationDialog.Buttons[0].PerformClick();
};
selectedDiff.ExecuteCommand(FileViewer.Command.ResetLines);
ta.RescanChanges();
ThreadHelper.JoinPendingOperations();
int textLengthAfterReset = selectedDiffInternal.GetTestAccessor().TextEditor.ActiveTextAreaControl.Document.TextLength;
textLengthBeforeReset.Should().BeGreaterThan(0);
textLengthAfterReset.Should().BeGreaterThan(0);
textLengthAfterReset.Should().BeLessThan(textLengthBeforeReset);
FileStatusItem? stagedAndRenamed = ta.StagedList.AllItems.FirstOrDefault(i => i.Item.Name.Contains("original2.txt"));
stagedAndRenamed.Should().NotBeNull();
stagedAndRenamed!.Item.IsRenamed.Should().BeTrue();
});
}

private void RunGeometryMemoryTest(Func<FormCommit, Rectangle> boundsAccessor, Action<Rectangle, Rectangle> testDriver)
{
var bounds1 = Rectangle.Empty;
Expand Down
17 changes: 17 additions & 0 deletions IntegrationTests/UI.IntegrationTests/GlobalSetupOnce.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using NUnit.Framework;

[SetUpFixture]
public class GlobalSetupOnce
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this class get used?

Copy link
Contributor Author

@mdonatas mdonatas Oct 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the NUnit docs

This is the attribute that marks a class that contains the one-time setup or teardown methods for all the test fixtures in a given namespace.

Since GlobalSetupOnce is in no namespace it's executed once per assembly. I've followed Answer 2 from this article.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you

{
[OneTimeSetUp]
public void RunBeforeAnyTests()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
}

[OneTimeTearDown]
public void RunAfterAnyTests()
{
}
}
17 changes: 17 additions & 0 deletions UnitTests/CommonTestUtils/GitModuleTestHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,23 @@ public string CreateRepoFile(string fileName, string fileContent)
return CreateRepoFile("", fileName, fileContent);
}

public string DeleteRepoFile(string fileName)
{
string parentDir = Module.WorkingDir;
string filePath = Path.Combine(parentDir, fileName);
if (!Directory.Exists(parentDir))
{
return filePath;
}

if (File.Exists(filePath))
{
File.Delete(filePath);
}

return filePath;
}

/// <summary>
/// Set dummy user and email locally for the module, no global setting in AppVeyor
/// Must also be set on the submodule, local settings are not included when adding it
Expand Down