From 9e2be5ec636754b3e1539d48e76e63ef5a98f98e Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Sun, 19 Jul 2026 07:10:07 +0200 Subject: [PATCH] Fix crash when a git branch config section has duplicate keys GetRemoteNameAndBranch built a Dictionary from the raw key/value lines of a [branch "..."] config section with ToDictionary, which throws ArgumentException on a duplicate key. VS Code's Git extension is known to append a duplicate vscode-merge-base entry instead of updating it in place, which crashed FromLocalDirectory (and therefore GitVersionAttribute) for anyone using it. Group by key and take the last value instead (matches git's own last-value-wins semantics), and skip malformed lines without an '=' instead of throwing an IndexOutOfRange. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Fallout.Build/VCS/GitRepository.cs | 6 +- .../Fallout.Build.Tests/GitRepositoryTest.cs | 62 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/Fallout.Build/VCS/GitRepository.cs b/src/Fallout.Build/VCS/GitRepository.cs index b97e2c3d5..5c578e722 100644 --- a/src/Fallout.Build/VCS/GitRepository.cs +++ b/src/Fallout.Build/VCS/GitRepository.cs @@ -74,8 +74,10 @@ private static (string Name, string Branch) GetRemoteNameAndBranch(AbsolutePath .SkipWhile(x => x != $"[branch {branch.DoubleQuote()}]") .Skip(1) .TakeWhile(x => !x.StartsWith("[")) - .Select(x => x.Split('=')) - .ToDictionary(x => x.ElementAt(0).Trim(), x => x.ElementAt(1).Trim()); + .Where(x => x.Contains('=')) + .Select(x => x.Split('=', 2)) + .GroupBy(x => x[0].Trim(), x => x[1].Trim()) + .ToDictionary(x => x.Key, x => x.Last()); return data.TryGetValue("remote", out var remote) && data.TryGetValue("merge", out var merge) ? (remote, merge.TrimStart("refs/heads/")) : (null, null); diff --git a/tests/Fallout.Build.Tests/GitRepositoryTest.cs b/tests/Fallout.Build.Tests/GitRepositoryTest.cs index 6fa952f64..b95f4b793 100644 --- a/tests/Fallout.Build.Tests/GitRepositoryTest.cs +++ b/tests/Fallout.Build.Tests/GitRepositoryTest.cs @@ -56,4 +56,66 @@ public void FromDirectoryTest() repository.Commit.Should().NotBeNullOrEmpty(); repository.Tags.Should().NotBeNull(); } + + [Fact] + public void FromDirectoryTest_WithDuplicateVscodeMergeBaseKey_DoesNotThrow() + { + // VS Code's Git extension is known to append a duplicate `vscode-merge-base` line to the + // `[branch ""]` section instead of updating it in place, which used to crash + // GetRemoteNameAndBranch's ToDictionary call. See regression report for details. + var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + + try + { + RunGit(directory, "init --quiet --initial-branch=main"); + RunGit(directory, "config user.email test@example.com"); + RunGit(directory, "config user.name Test"); + File.WriteAllText(Path.Combine(directory, "file.txt"), "content"); + RunGit(directory, "add file.txt"); + RunGit(directory, "commit --quiet -m initial"); + RunGit(directory, "remote add origin https://github.com/nuke-build/nuke.git"); + RunGit(directory, "config branch.main.remote origin"); + RunGit(directory, "config branch.main.merge refs/heads/main"); + + var configPath = Path.Combine(directory, ".git", "config"); + File.AppendAllText( + configPath, + "\tvscode-merge-base = origin/main" + Environment.NewLine + + "\tvscode-merge-base = origin/main" + Environment.NewLine); + + var repository = GitRepository.FromLocalDirectory(directory); + + repository.Branch.Should().Be("main"); + repository.Identifier.Should().Be("nuke-build/nuke"); + } + finally + { + ForceDeleteDirectory(directory); + } + } + + private static void ForceDeleteDirectory(string directory) + { + // Git marks object files read-only, which trips up Directory.Delete on Windows. + foreach (var file in Directory.GetFiles(directory, "*", SearchOption.AllDirectories)) + File.SetAttributes(file, FileAttributes.Normal); + + Directory.Delete(directory, recursive: true); + } + + private static void RunGit(string workingDirectory, string arguments) + { + var startInfo = new System.Diagnostics.ProcessStartInfo("git", arguments) + { + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + using var process = System.Diagnostics.Process.Start(startInfo).NotNull(); + process.WaitForExit(); + process.ExitCode.Should().Be(0, process.StandardError.ReadToEnd()); + } }