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
2 changes: 1 addition & 1 deletion GitIntegration.Test/Builders/GitBranchListBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace ktsu.GitIntegration.Test;
public class GitBranchListBuilderTests
{
private const string ExpectedFormat =
"--format=%(refname)%1f%(refname:short)%1f%(objectname)%1f%(upstream:short)%1f%(HEAD)";
"--format=%(refname)%1f%(refname:lstrip=2)%1f%(objectname)%1f%(upstream:lstrip=2)%1f%(HEAD)";

[TestMethod]
public void BuildsTheDefaultBranchListVector()
Expand Down
75 changes: 75 additions & 0 deletions GitIntegration.Test/Integration/GitRoundTripTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,81 @@
Assert.AreEqual(annotated, afterDelete[0].Name);
}

[TestMethod]
public async Task BranchAndTagSharingANameAreBothReportedByThatNameAsync()
{
// A release branch and a release tag both called v1 make the short name ambiguous, and
// %(refname:short) then keeps part of the prefix: heads/v1 and tags/v1, which git itself
// refuses when they are passed back.
CancellationToken cancellationToken = TestContext.CancellationTokenSource.Token;

Check warning on line 391 in GitIntegration.Test/Integration/GitRoundTripTests.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=AaESEd47kcLl16Ygw8oJ&open=AaESEd47kcLl16Ygw8oJ&pullRequest=181
await IntegrationGitFixture.RequireGitAsync(cancellationToken).ConfigureAwait(false);

using TemporaryRepository temporary = new();
GitRepository repository = await InitializeAsync(temporary, cancellationToken).ConfigureAwait(false);

temporary.WriteFile("a.txt", "one\n");
_ = await repository.Add().All().ExecuteAsync(cancellationToken).ConfigureAwait(false);
_ = await repository.Commit("c1".As<GitCommitMessage>()).ExecuteAsync(cancellationToken).ConfigureAwait(false);

_ = await repository.CreateBranch("v1".As<GitBranchName>()).ExecuteAsync(cancellationToken).ConfigureAwait(false);
_ = await repository.CreateTag("v1".As<GitTagName>()).ExecuteAsync(cancellationToken).ConfigureAwait(false);

IReadOnlyList<GitBranch> branches =
await repository.Branches().LocalOnly().ExecuteAsync(cancellationToken).ConfigureAwait(false);
Assert.AreEqual(2, branches.Count);

Check warning on line 406 in GitIntegration.Test/Integration/GitRoundTripTests.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=AaESEd47kcLl16Ygw8oK&open=AaESEd47kcLl16Ygw8oK&pullRequest=181
Assert.IsTrue(branches.Any(static branch => branch.Name.WeakString == "main"));

Check warning on line 407 in GitIntegration.Test/Integration/GitRoundTripTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'Assert.IsTrue'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaESEd47kcLl16Ygw8oL&open=AaESEd47kcLl16Ygw8oL&pullRequest=181
Assert.IsTrue(branches.Any(static branch => branch.Name.WeakString == "v1"));

Check warning on line 408 in GitIntegration.Test/Integration/GitRoundTripTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'Assert.IsTrue'

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

IReadOnlyList<GitTag> tags = await repository.Tags().ExecuteAsync(cancellationToken).ConfigureAwait(false);
Assert.AreEqual("v1", tags.Single().Name.WeakString);

// The reported names are usable: deleting by them succeeds.
_ = await repository.DeleteBranch(branches.Single(static branch => branch.Name.WeakString == "v1").Name)
.ExecuteAsync(cancellationToken).ConfigureAwait(false);
_ = await repository.DeleteTag(tags.Single().Name).ExecuteAsync(cancellationToken).ConfigureAwait(false);
}

[TestMethod]
public async Task LocalBranchNamedLikeARemoteBranchLeavesTheRemoteNameAndUpstreamIntactAsync()
{
// A local branch called origin/main makes refs/remotes/origin/main shorten to
// remotes/origin/main, which used to leak into both the remote branch's name and the
// upstream of every branch tracking it.
CancellationToken cancellationToken = TestContext.CancellationTokenSource.Token;

Check warning on line 425 in GitIntegration.Test/Integration/GitRoundTripTests.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=AaESEd47kcLl16Ygw8oN&open=AaESEd47kcLl16Ygw8oN&pullRequest=181
await IntegrationGitFixture.RequireGitAsync(cancellationToken).ConfigureAwait(false);

using TemporaryRepository temporary = new();
GitRepository repository = await InitializeAsync(temporary, cancellationToken).ConfigureAwait(false);

temporary.WriteFile("a.txt", "one\n");
_ = await repository.Add().All().ExecuteAsync(cancellationToken).ConfigureAwait(false);
_ = await repository.Commit("c1".As<GitCommitMessage>()).ExecuteAsync(cancellationToken).ConfigureAwait(false);

// The remote is never contacted; git only needs its fetch refspec to resolve the upstream.
IGitProcessRunner runner = repository.ProcessRunner!;
_ = await new GitTextBuilder(runner, repository.LocalPath, "remote", "add", "origin", "https://example.invalid/r.git")
.ExecuteAsync(cancellationToken).ConfigureAwait(false);
_ = await new GitTextBuilder(runner, repository.LocalPath, "update-ref", "refs/remotes/origin/main", "HEAD")
.ExecuteAsync(cancellationToken).ConfigureAwait(false);
_ = await new GitTextBuilder(runner, repository.LocalPath, "config", "branch.main.remote", "origin")
.ExecuteAsync(cancellationToken).ConfigureAwait(false);
_ = await new GitTextBuilder(runner, repository.LocalPath, "config", "branch.main.merge", "refs/heads/main")
.ExecuteAsync(cancellationToken).ConfigureAwait(false);
_ = await repository.CreateBranch("origin/main".As<GitBranchName>()).ExecuteAsync(cancellationToken).ConfigureAwait(false);

IReadOnlyList<GitBranch> branches =
await repository.Branches().ExecuteAsync(cancellationToken).ConfigureAwait(false);

GitBranch remote = branches.Single(static branch => branch.IsRemote);
Assert.AreEqual("origin/main", remote.Name.WeakString);

GitBranch local = branches.Single(static branch => !branch.IsRemote && branch.Name.WeakString == "origin/main");
Assert.IsFalse(local.IsCurrent);

GitBranch main = branches.Single(static branch => branch.Name.WeakString == "main");
Assert.AreEqual("origin/main", main.Upstream?.WeakString);
}

[TestMethod]
public async Task CheckoutResolvesATagRatherThanAFileOfTheSameNameAsync()
{
Expand Down
2 changes: 1 addition & 1 deletion GitIntegration/Parsing/GitBranchParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ internal static IReadOnlyList<GitBranch> Parse(string output)
Name = GitParseValues.ToSemantic<GitBranchName>(fields[1], "branch name"),
Sha = GitParseValues.ToSemantic<GitCommitSha>(fields[2], "branch object id"),

// %(upstream:short) is empty when the branch tracks nothing.
// %(upstream:lstrip=2) is empty when the branch tracks nothing.
Upstream = fields[3].Length == 0
? null
: GitParseValues.ToSemantic<GitBranchName>(fields[3], "upstream branch name"),
Expand Down
21 changes: 16 additions & 5 deletions GitIntegration/Parsing/GitOutputFormats.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ internal static class GitOutputFormats
internal const string LogFormat = "%H%x1f%T%x1f%P%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1f%s%x1f%b";

/// <summary>
/// The <c>for-each-ref</c> format: full reference name, short name, object id, upstream, and
/// the current-branch marker.
/// The <c>for-each-ref</c> format: full reference name, name, object id, upstream, and the
/// current-branch marker.
/// </summary>
/// <remarks>
/// The full reference name leads, and is the reason this format differs from the one sketched
Expand All @@ -46,12 +46,19 @@ internal static class GitOutputFormats
/// and would otherwise be reported as a branch called <c>origin</c>. <c>%1f</c> is
/// <c>for-each-ref</c>'s own hex escape, which differs in spelling from <c>log</c>'s
/// <c>%x1f</c> but means the same byte.
/// <para>
/// Names come from <c>:lstrip=2</c>, never <c>:short</c>. <c>:short</c> keeps part of the prefix
/// whenever the shortest name would be ambiguous, so a branch and a tag both called <c>v1</c>
/// come back as <c>heads/v1</c> and <c>tags/v1</c>, names git itself then refuses. Stripping
/// <c>refs/heads/</c>, <c>refs/remotes/</c> or <c>refs/tags/</c> outright always yields the name
/// the reference was created with.
/// </para>
/// </remarks>
internal const string ForEachRefFormat =
"%(refname)%1f%(refname:short)%1f%(objectname)%1f%(upstream:short)%1f%(HEAD)";
"%(refname)%1f%(refname:lstrip=2)%1f%(objectname)%1f%(upstream:lstrip=2)%1f%(HEAD)";

/// <summary>
/// The <c>for-each-ref</c> format for tags: short name, the reference's own object id, the
/// The <c>for-each-ref</c> format for tags: name, the reference's own object id, the
/// object type, the dereferenced object id, and the message subject.
/// </summary>
/// <remarks>
Expand All @@ -76,7 +83,11 @@ internal static class GitOutputFormats
/// tagger's words. <see cref="GitTagParser"/> gates it on the object type for that reason.
/// Verified against git 2.43.
/// </para>
/// <para>
/// The name is <c>:lstrip=2</c> rather than <c>:short</c> for the reason
/// <see cref="ForEachRefFormat"/> gives.
/// </para>
/// </remarks>
internal const string ForEachTagFormat =
"%(refname:short)%1f%(objectname)%1f%(objecttype)%1f%(*objectname)%1f%(contents:subject)";
"%(refname:lstrip=2)%1f%(objectname)%1f%(objecttype)%1f%(*objectname)%1f%(contents:subject)";
}
Loading