diff --git a/GitIntegration.Test/Integration/GitLiteralPathspecTests.cs b/GitIntegration.Test/Integration/GitLiteralPathspecTests.cs new file mode 100644 index 0000000..0655e24 --- /dev/null +++ b/GitIntegration.Test/Integration/GitLiteralPathspecTests.cs @@ -0,0 +1,138 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.GitIntegration.Test; + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using ktsu.Semantics.Paths; +using ktsu.Semantics.Strings; + +/// +/// Checks that a file path given to a verb names that one file, against a real git binary. +/// +/// +/// git reads its path operands as pathspecs by default, so "file[1].txt" is a glob that also +/// matches file1.txt, and a leading colon introduces pathspec magic (ktsu-dev/GitIntegration#125). +/// +[TestClass] +[TestCategory("Integration")] +public class GitLiteralPathspecTests +{ + private static readonly GitAuthorName AuthorName = "Fixture Author".As(); + private static readonly GitAuthorEmail AuthorEmail = "fixture@example.com".As(); + + private static async Task InitializeWithCommitAsync( + TemporaryRepository temporary, + CancellationToken cancellationToken) + { + GitClient client = IntegrationGitFixture.CreateClient(); + + GitInitResult init = await client + .Init(temporary.Root) + .WithInitialBranch("main".As()) + .ExecuteAsync(cancellationToken).ConfigureAwait(false); + + GitRepository repository = init.Repository; + await IntegrationGitFixture.ConfigureIdentityAsync(repository, AuthorName, AuthorEmail, cancellationToken) + .ConfigureAwait(false); + + temporary.WriteFile("seed.txt", "seed\n"); + _ = await repository.Add().All().ExecuteAsync(cancellationToken).ConfigureAwait(false); + _ = await repository.Commit("seed".As()).ExecuteAsync(cancellationToken).ConfigureAwait(false); + + return repository; + } + + private static async Task StagedPathsAsync(GitRepository repository, CancellationToken cancellationToken) + { + GitStatus status = await repository.Status().ExecuteAsync(cancellationToken).ConfigureAwait(false); + return string.Join( + ',', + status.Entries + .Where(entry => entry.IndexState == GitFileState.Added) + .Select(entry => entry.Path.WeakString) + .Order(StringComparer.Ordinal)); + } + + [TestMethod] + public async Task UnstageOfABracketedNameLeavesTheFileItAlsoMatchesStagedAsync() + { + CancellationToken cancellationToken = TestContext.CancellationTokenSource.Token; + await IntegrationGitFixture.RequireGitAsync(cancellationToken).ConfigureAwait(false); + + using TemporaryRepository temporary = new(); + GitRepository repository = await InitializeWithCommitAsync(temporary, cancellationToken).ConfigureAwait(false); + + temporary.WriteFile("file[1].txt", "a\n"); + temporary.WriteFile("file1.txt", "b\n"); + _ = await repository.Add().All().ExecuteAsync(cancellationToken).ConfigureAwait(false); + + _ = await repository.Unstage("file[1].txt".As()).ExecuteAsync(cancellationToken).ConfigureAwait(false); + + Assert.AreEqual("file1.txt", await StagedPathsAsync(repository, cancellationToken).ConfigureAwait(false)); + } + + [TestMethod] + public async Task UnstageOfAColonLeadingNameUnstagesThatFileAsync() + { + if (OperatingSystem.IsWindows()) + { + Assert.Inconclusive("Windows file names cannot contain a colon."); + } + + CancellationToken cancellationToken = TestContext.CancellationTokenSource.Token; + await IntegrationGitFixture.RequireGitAsync(cancellationToken).ConfigureAwait(false); + + using TemporaryRepository temporary = new(); + GitRepository repository = await InitializeWithCommitAsync(temporary, cancellationToken).ConfigureAwait(false); + + temporary.WriteFile(":foo", "a\n"); + temporary.WriteFile("foo", "b\n"); + _ = await repository.Add().All().ExecuteAsync(cancellationToken).ConfigureAwait(false); + + _ = await repository.Unstage(":foo".As()).ExecuteAsync(cancellationToken).ConfigureAwait(false); + + Assert.AreEqual("foo", await StagedPathsAsync(repository, cancellationToken).ConfigureAwait(false)); + } + + [TestMethod] + public async Task AddOfABracketedNameStagesOnlyThatFileAsync() + { + CancellationToken cancellationToken = TestContext.CancellationTokenSource.Token; + await IntegrationGitFixture.RequireGitAsync(cancellationToken).ConfigureAwait(false); + + using TemporaryRepository temporary = new(); + GitRepository repository = await InitializeWithCommitAsync(temporary, cancellationToken).ConfigureAwait(false); + + temporary.WriteFile("file[1].txt", "a\n"); + temporary.WriteFile("file1.txt", "b\n"); + + _ = await repository.Add().ForPath("file[1].txt".As()).ExecuteAsync(cancellationToken).ConfigureAwait(false); + + Assert.AreEqual("file[1].txt", await StagedPathsAsync(repository, cancellationToken).ConfigureAwait(false)); + } + + [TestMethod] + public async Task PatchForABracketedNameReturnsOnlyThatFileAsync() + { + CancellationToken cancellationToken = TestContext.CancellationTokenSource.Token; + await IntegrationGitFixture.RequireGitAsync(cancellationToken).ConfigureAwait(false); + + using TemporaryRepository temporary = new(); + GitRepository repository = await InitializeWithCommitAsync(temporary, cancellationToken).ConfigureAwait(false); + + temporary.WriteFile("file[1].txt", "a\n"); + temporary.WriteFile("file1.txt", "b\n"); + _ = await repository.Add().All().ExecuteAsync(cancellationToken).ConfigureAwait(false); + + GitPatch patch = await repository.Patch().Staged().ForPath("file[1].txt".As()) + .ExecuteAsync(cancellationToken).ConfigureAwait(false); + + Assert.AreEqual("file[1].txt", string.Join(',', patch.Files.Select(file => file.Path.WeakString))); + } + + public TestContext TestContext { get; set; } = null!; +} diff --git a/GitIntegration/Builders/GitAddBuilder.cs b/GitIntegration/Builders/GitAddBuilder.cs index d1e1611..bd3be2a 100644 --- a/GitIntegration/Builders/GitAddBuilder.cs +++ b/GitIntegration/Builders/GitAddBuilder.cs @@ -62,6 +62,9 @@ public IGitAddBuilder UpdateTrackedOnly() return this; } + /// + protected override bool PassesPathsLiterally => _paths.Count > 0; + /// protected override void AppendVerbArguments(ICollection arguments) { diff --git a/GitIntegration/Builders/GitCommandBuilder.cs b/GitIntegration/Builders/GitCommandBuilder.cs index cc71e7f..e097c96 100644 --- a/GitIntegration/Builders/GitCommandBuilder.cs +++ b/GitIntegration/Builders/GitCommandBuilder.cs @@ -68,6 +68,16 @@ public abstract class GitCommandBuilder(IGitProcessRunner runner, Absol /// The parsed result. protected abstract TResult ParseResult(GitProcessResult result); + /// + /// Gets a value indicating whether the command passes caller-supplied file paths, which git + /// must then match literally rather than as pathspec patterns. + /// + /// + /// Only builders that emit paths turn this on. git exports the setting to the processes it + /// starts, so setting it on every command would also change how hooks read their pathspecs. + /// + protected virtual bool PassesPathsLiterally => false; + /// /// Appends caller-supplied operands after an end-of-options marker, so a value beginning with a /// dash cannot be reinterpreted by git as an option. @@ -115,6 +125,15 @@ public IReadOnlyList BuildArguments() arguments.Add("-c"); arguments.Add("color.ui=false"); + // Caller paths are RelativeFilePath values naming one file each, so git must not read + // glob characters or a leading colon in them as pathspec syntax, or "file[1].txt" also + // matches file1.txt (ktsu-dev/GitIntegration#125). This is a global option, so it has to + // come before the verb. + if (PassesPathsLiterally) + { + arguments.Add("--literal-pathspecs"); + } + AppendVerbArguments(arguments); return arguments; diff --git a/GitIntegration/Builders/GitDiffBuilder.cs b/GitIntegration/Builders/GitDiffBuilder.cs index 7cc61fe..66ee05e 100644 --- a/GitIntegration/Builders/GitDiffBuilder.cs +++ b/GitIntegration/Builders/GitDiffBuilder.cs @@ -141,6 +141,9 @@ public IGitDiffBuilder WithLineCounts() return this; } + /// + protected override bool PassesPathsLiterally => _paths.Count > 0; + /// protected override void AppendVerbArguments(ICollection arguments) { diff --git a/GitIntegration/Builders/GitLogBuilder.cs b/GitIntegration/Builders/GitLogBuilder.cs index 31d4c4f..bc1305a 100644 --- a/GitIntegration/Builders/GitLogBuilder.cs +++ b/GitIntegration/Builders/GitLogBuilder.cs @@ -150,6 +150,9 @@ public IGitLogBuilder ExcludingRemoteTrackingRefs() return this; } + /// + protected override bool PassesPathsLiterally => _paths.Count > 0; + /// protected override void AppendVerbArguments(ICollection arguments) { diff --git a/GitIntegration/Builders/GitPatchBuilder.cs b/GitIntegration/Builders/GitPatchBuilder.cs index 927961e..2dbc69c 100644 --- a/GitIntegration/Builders/GitPatchBuilder.cs +++ b/GitIntegration/Builders/GitPatchBuilder.cs @@ -149,6 +149,9 @@ public IGitPatchBuilder DetectRenames() return this; } + /// + protected override bool PassesPathsLiterally => _paths.Count > 0; + /// protected override void AppendVerbArguments(ICollection arguments) { diff --git a/GitIntegration/Builders/GitRestoreBuilder.cs b/GitIntegration/Builders/GitRestoreBuilder.cs index 6b0d967..0af4cb1 100644 --- a/GitIntegration/Builders/GitRestoreBuilder.cs +++ b/GitIntegration/Builders/GitRestoreBuilder.cs @@ -37,6 +37,9 @@ internal sealed class GitRestoreBuilder(IGitProcessRunner runner, AbsoluteDirect { private readonly RelativeFilePath _path = Ensure.NotNull(path); + /// + protected override bool PassesPathsLiterally => true; + /// /// Appends the verb and the path, separating the two with a bare -- rather than through /// AppendOperands. diff --git a/GitIntegration/Builders/GitRevListBuilder.cs b/GitIntegration/Builders/GitRevListBuilder.cs index 2fff337..a8f28b4 100644 --- a/GitIntegration/Builders/GitRevListBuilder.cs +++ b/GitIntegration/Builders/GitRevListBuilder.cs @@ -72,6 +72,9 @@ public IGitRevListBuilder ForPath(RelativeFilePath path) return this; } + /// + protected override bool PassesPathsLiterally => _paths.Count > 0; + /// protected override void AppendVerbArguments(ICollection arguments) {