From b0b857ea8bc2d11e7645dce1931f213a1c5351c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 16:28:13 +0000 Subject: [PATCH] Report branch and tag names unprefixed when a branch and tag share a name [patch] %(refname:short) keeps part of the prefix whenever the shortest name is ambiguous, so a branch and a tag both called v1 came back as heads/v1 and tags/v1, and a local branch called origin/main turned the remote-tracking branch and every upstream into remotes/origin/main. None of those names can be passed back to git. Read names with :lstrip=2 instead, which always strips exactly refs/heads/, refs/remotes/ or refs/tags/. Fixes ktsu-dev/GitIntegration#137 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W83XxSnDX5Fu4sVPDKjXem --- .../Builders/GitBranchListBuilderTests.cs | 2 +- .../Integration/GitRoundTripTests.cs | 75 +++++++++++++++++++ GitIntegration/Parsing/GitBranchParser.cs | 2 +- GitIntegration/Parsing/GitOutputFormats.cs | 21 ++++-- 4 files changed, 93 insertions(+), 7 deletions(-) diff --git a/GitIntegration.Test/Builders/GitBranchListBuilderTests.cs b/GitIntegration.Test/Builders/GitBranchListBuilderTests.cs index 3906eb3..91ba001 100644 --- a/GitIntegration.Test/Builders/GitBranchListBuilderTests.cs +++ b/GitIntegration.Test/Builders/GitBranchListBuilderTests.cs @@ -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() diff --git a/GitIntegration.Test/Integration/GitRoundTripTests.cs b/GitIntegration.Test/Integration/GitRoundTripTests.cs index 5366994..50a8240 100644 --- a/GitIntegration.Test/Integration/GitRoundTripTests.cs +++ b/GitIntegration.Test/Integration/GitRoundTripTests.cs @@ -382,6 +382,81 @@ public async Task TagCreateListAndDeleteRoundTripAsync() 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; + 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()).ExecuteAsync(cancellationToken).ConfigureAwait(false); + + _ = await repository.CreateBranch("v1".As()).ExecuteAsync(cancellationToken).ConfigureAwait(false); + _ = await repository.CreateTag("v1".As()).ExecuteAsync(cancellationToken).ConfigureAwait(false); + + IReadOnlyList branches = + await repository.Branches().LocalOnly().ExecuteAsync(cancellationToken).ConfigureAwait(false); + Assert.AreEqual(2, branches.Count); + Assert.IsTrue(branches.Any(static branch => branch.Name.WeakString == "main")); + Assert.IsTrue(branches.Any(static branch => branch.Name.WeakString == "v1")); + + IReadOnlyList 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; + 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()).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()).ExecuteAsync(cancellationToken).ConfigureAwait(false); + + IReadOnlyList 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() { diff --git a/GitIntegration/Parsing/GitBranchParser.cs b/GitIntegration/Parsing/GitBranchParser.cs index 27ab4b1..a0a9407 100644 --- a/GitIntegration/Parsing/GitBranchParser.cs +++ b/GitIntegration/Parsing/GitBranchParser.cs @@ -62,7 +62,7 @@ internal static IReadOnlyList Parse(string output) Name = GitParseValues.ToSemantic(fields[1], "branch name"), Sha = GitParseValues.ToSemantic(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(fields[3], "upstream branch name"), diff --git a/GitIntegration/Parsing/GitOutputFormats.cs b/GitIntegration/Parsing/GitOutputFormats.cs index e310bb8..5d97eaf 100644 --- a/GitIntegration/Parsing/GitOutputFormats.cs +++ b/GitIntegration/Parsing/GitOutputFormats.cs @@ -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"; /// - /// The for-each-ref format: full reference name, short name, object id, upstream, and - /// the current-branch marker. + /// The for-each-ref format: full reference name, name, object id, upstream, and the + /// current-branch marker. /// /// /// The full reference name leads, and is the reason this format differs from the one sketched @@ -46,12 +46,19 @@ internal static class GitOutputFormats /// and would otherwise be reported as a branch called origin. %1f is /// for-each-ref's own hex escape, which differs in spelling from log's /// %x1f but means the same byte. + /// + /// Names come from :lstrip=2, never :short. :short keeps part of the prefix + /// whenever the shortest name would be ambiguous, so a branch and a tag both called v1 + /// come back as heads/v1 and tags/v1, names git itself then refuses. Stripping + /// refs/heads/, refs/remotes/ or refs/tags/ outright always yields the name + /// the reference was created with. + /// /// 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)"; /// - /// The for-each-ref format for tags: short name, the reference's own object id, the + /// The for-each-ref format for tags: name, the reference's own object id, the /// object type, the dereferenced object id, and the message subject. /// /// @@ -76,7 +83,11 @@ internal static class GitOutputFormats /// tagger's words. gates it on the object type for that reason. /// Verified against git 2.43. /// + /// + /// The name is :lstrip=2 rather than :short for the reason + /// gives. + /// /// 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)"; }