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)
{