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
138 changes: 138 additions & 0 deletions GitIntegration.Test/Integration/GitLiteralPathspecTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Checks that a file path given to a verb names that one file, against a real git binary.
/// </summary>
/// <remarks>
/// 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).
/// </remarks>
[TestClass]
[TestCategory("Integration")]
public class GitLiteralPathspecTests
{
private static readonly GitAuthorName AuthorName = "Fixture Author".As<GitAuthorName>();
private static readonly GitAuthorEmail AuthorEmail = "fixture@example.com".As<GitAuthorEmail>();

private static async Task<GitRepository> InitializeWithCommitAsync(
TemporaryRepository temporary,
CancellationToken cancellationToken)
{
GitClient client = IntegrationGitFixture.CreateClient();

GitInitResult init = await client
.Init(temporary.Root)
.WithInitialBranch("main".As<GitBranchName>())
.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<GitCommitMessage>()).ExecuteAsync(cancellationToken).ConfigureAwait(false);

return repository;
}

private static async Task<string> 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;

Check warning on line 63 in GitIntegration.Test/Integration/GitLiteralPathspecTests.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=AaDt01apUetEHDF5Cl2T&open=AaDt01apUetEHDF5Cl2T&pullRequest=159
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<RelativeFilePath>()).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.");
}

Check warning on line 84 in GitIntegration.Test/Integration/GitLiteralPathspecTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[OSCondition]' attribute instead of 'RuntimeInformation.IsOSPlatform' calls with early return or 'Assert.Inconclusive'

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

CancellationToken cancellationToken = TestContext.CancellationTokenSource.Token;

Check warning on line 86 in GitIntegration.Test/Integration/GitLiteralPathspecTests.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=AaDt01apUetEHDF5Cl2U&open=AaDt01apUetEHDF5Cl2U&pullRequest=159
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<RelativeFilePath>()).ExecuteAsync(cancellationToken).ConfigureAwait(false);

Assert.AreEqual("foo", await StagedPathsAsync(repository, cancellationToken).ConfigureAwait(false));
}

[TestMethod]
public async Task AddOfABracketedNameStagesOnlyThatFileAsync()
{
CancellationToken cancellationToken = TestContext.CancellationTokenSource.Token;

Check warning on line 104 in GitIntegration.Test/Integration/GitLiteralPathspecTests.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=AaDt01apUetEHDF5Cl2V&open=AaDt01apUetEHDF5Cl2V&pullRequest=159
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<RelativeFilePath>()).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;

Check warning on line 121 in GitIntegration.Test/Integration/GitLiteralPathspecTests.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=AaDt01apUetEHDF5Cl2X&open=AaDt01apUetEHDF5Cl2X&pullRequest=159
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<RelativeFilePath>())
.ExecuteAsync(cancellationToken).ConfigureAwait(false);

Assert.AreEqual("file[1].txt", string.Join(',', patch.Files.Select(file => file.Path.WeakString)));
}

public TestContext TestContext { get; set; } = null!;
}
3 changes: 3 additions & 0 deletions GitIntegration/Builders/GitAddBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ public IGitAddBuilder UpdateTrackedOnly()
return this;
}

/// <inheritdoc />
protected override bool PassesPathsLiterally => _paths.Count > 0;

/// <inheritdoc />
protected override void AppendVerbArguments(ICollection<string> arguments)
{
Expand Down
19 changes: 19 additions & 0 deletions GitIntegration/Builders/GitCommandBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ public abstract class GitCommandBuilder<TResult>(IGitProcessRunner runner, Absol
/// <returns>The parsed result.</returns>
protected abstract TResult ParseResult(GitProcessResult result);

/// <summary>
/// Gets a value indicating whether the command passes caller-supplied file paths, which git
/// must then match literally rather than as pathspec patterns.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
protected virtual bool PassesPathsLiterally => false;

/// <summary>
/// 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.
Expand Down Expand Up @@ -115,6 +125,15 @@ public IReadOnlyList<string> 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;
Expand Down
3 changes: 3 additions & 0 deletions GitIntegration/Builders/GitDiffBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ public IGitDiffBuilder WithLineCounts()
return this;
}

/// <inheritdoc />
protected override bool PassesPathsLiterally => _paths.Count > 0;

/// <inheritdoc />
protected override void AppendVerbArguments(ICollection<string> arguments)
{
Expand Down
3 changes: 3 additions & 0 deletions GitIntegration/Builders/GitLogBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ public IGitLogBuilder ExcludingRemoteTrackingRefs()
return this;
}

/// <inheritdoc />
protected override bool PassesPathsLiterally => _paths.Count > 0;

/// <inheritdoc />
protected override void AppendVerbArguments(ICollection<string> arguments)
{
Expand Down
3 changes: 3 additions & 0 deletions GitIntegration/Builders/GitPatchBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ public IGitPatchBuilder DetectRenames()
return this;
}

/// <inheritdoc />
protected override bool PassesPathsLiterally => _paths.Count > 0;

/// <inheritdoc />
protected override void AppendVerbArguments(ICollection<string> arguments)
{
Expand Down
3 changes: 3 additions & 0 deletions GitIntegration/Builders/GitRestoreBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ internal sealed class GitRestoreBuilder(IGitProcessRunner runner, AbsoluteDirect
{
private readonly RelativeFilePath _path = Ensure.NotNull(path);

/// <inheritdoc />
protected override bool PassesPathsLiterally => true;

/// <summary>
/// Appends the verb and the path, separating the two with a bare <c>--</c> rather than through
/// <c>AppendOperands</c>.
Expand Down
3 changes: 3 additions & 0 deletions GitIntegration/Builders/GitRevListBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ public IGitRevListBuilder ForPath(RelativeFilePath path)
return this;
}

/// <inheritdoc />
protected override bool PassesPathsLiterally => _paths.Count > 0;

/// <inheritdoc />
protected override void AppendVerbArguments(ICollection<string> arguments)
{
Expand Down
Loading