From 821026cdd387f0d0dd2ff57fa55269dd70152e26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 Aug 2025 19:02:45 +0000 Subject: [PATCH 1/3] Initial plan From 3cd5a59281d7130175062e4a6db80ef1ad8e7b79 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 Aug 2025 19:26:52 +0000 Subject: [PATCH 2/3] Implement default branch detection for nbgv install command Co-authored-by: AArnott <3548+AArnott@users.noreply.github.com> --- src/nbgv/Program.cs | 150 ++++++++++++++++-- .../InstallCommandTests.cs | 146 +++++++++++++++++ 2 files changed, 281 insertions(+), 15 deletions(-) create mode 100644 test/Nerdbank.GitVersioning.Tests/InstallCommandTests.cs diff --git a/src/nbgv/Program.cs b/src/nbgv/Program.cs index 0ad1386a..46a7f12c 100644 --- a/src/nbgv/Program.cs +++ b/src/nbgv/Program.cs @@ -1,6 +1,8 @@ // Copyright (c) .NET Foundation and Contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +#nullable enable + using System; using System.Collections.Generic; using System.CommandLine; @@ -415,6 +417,121 @@ private static int MainInner(string[] args) return (int)exitCode; } + /// + /// Detects the default branch name for the repository following the algorithm: + /// 1. If the upstream remote exists, use its HEAD reference + /// 2. If the origin remote exists, use its HEAD reference + /// 3. If any remote exists, pick one arbitrarily and use its HEAD reference + /// 4. If only one local branch exists, use that one + /// 5. Use git config init.defaultBranch if the named branch exists locally + /// 6. Use the first local branch that exists from: master, main, develop. + /// + /// The git context to query. + /// The detected default branch name, defaulting to "master" if none can be determined. + private static string DetectDefaultBranch(GitContext context) + { + if (context is LibGit2.LibGit2Context libgit2Context) + { + LibGit2Sharp.Repository repository = libgit2Context.Repository; + + // Step 1-3: Check remotes for HEAD reference + string[] remotePreferenceOrder = { "upstream", "origin" }; + + foreach (string remoteName in remotePreferenceOrder) + { + LibGit2Sharp.Remote? remote = repository.Network.Remotes[remoteName]; + if (remote is object) + { + string? defaultBranch = GetDefaultBranchFromRemote(repository, remoteName); + if (!string.IsNullOrEmpty(defaultBranch)) + { + return defaultBranch; + } + } + } + + // Check any other remotes if upstream/origin didn't work + foreach (LibGit2Sharp.Remote remote in repository.Network.Remotes) + { + if (remote.Name != "upstream" && remote.Name != "origin") + { + string? defaultBranch = GetDefaultBranchFromRemote(repository, remote.Name); + if (!string.IsNullOrEmpty(defaultBranch)) + { + return defaultBranch; + } + } + } + + // Step 4: If only one local branch exists, use that one + LibGit2Sharp.Branch[] localBranches = repository.Branches.Where(b => !b.IsRemote).ToArray(); + if (localBranches.Length == 1) + { + return localBranches[0].FriendlyName; + } + + // Step 5: Use git config init.defaultBranch if the named branch exists locally + try + { + string? configDefaultBranch = repository.Config.Get("init.defaultBranch")?.Value; + if (!string.IsNullOrEmpty(configDefaultBranch) && + localBranches.Any(b => b.FriendlyName == configDefaultBranch)) + { + return configDefaultBranch; + } + } + catch + { + // Ignore config read errors + } + + // Step 6: Use the first local branch that exists from: master, main, develop + string[] commonBranchNames = { "master", "main", "develop" }; + foreach (string branchName in commonBranchNames) + { + if (localBranches.Any(b => b.FriendlyName == branchName)) + { + return branchName; + } + } + } + + // Fallback to "master" if nothing else works + return "master"; + } + + /// + /// Gets the default branch name from a remote's HEAD reference. + /// + /// The repository to query. + /// The name of the remote. + /// The default branch name, or null if it cannot be determined. + private static string? GetDefaultBranchFromRemote(LibGit2Sharp.Repository repository, string remoteName) + { + try + { + // Try to get the symbolic reference for the remote HEAD + string remoteHeadRef = $"refs/remotes/{remoteName}/HEAD"; + LibGit2Sharp.Reference? remoteHead = repository.Refs[remoteHeadRef]; + + if (remoteHead?.TargetIdentifier is object) + { + // Extract branch name from refs/remotes/{remote}/{branch} + string targetRef = remoteHead.TargetIdentifier; + if (targetRef.StartsWith($"refs/remotes/{remoteName}/")) + { + return targetRef.Substring($"refs/remotes/{remoteName}/".Length); + } + } + } + catch + { + // Ignore errors when trying to read remote HEAD + } + + return null; + } + private static async Task OnInstallCommand(string path, string version, string[] source) { if (!SemanticVersion.TryParse(string.IsNullOrEmpty(version) ? DefaultVersionSpec : version, out SemanticVersion semver)) @@ -423,12 +540,28 @@ private static async Task OnInstallCommand(string path, string version, str return (int)ExitCodes.InvalidVersionSpec; } + string searchPath = GetSpecifiedOrCurrentDirectoryPath(path); + if (!Directory.Exists(searchPath)) + { + Console.Error.WriteLine("\"{0}\" is not an existing directory.", searchPath); + return (int)ExitCodes.NoGitRepo; + } + + using var context = GitContext.Create(searchPath, engine: GitContext.Engine.ReadWrite); + if (!context.IsRepository) + { + Console.Error.WriteLine("No git repo found at or above: \"{0}\"", searchPath); + return (int)ExitCodes.NoGitRepo; + } + + string defaultBranch = DetectDefaultBranch(context); + var options = new VersionOptions { Version = semver, PublicReleaseRefSpec = new string[] { - @"^refs/heads/master$", + $@"^refs/heads/{defaultBranch}$", @"^refs/heads/v\d+(?:\.\d+)?$", }, CloudBuild = new VersionOptions.CloudBuildOptions @@ -439,26 +572,13 @@ private static async Task OnInstallCommand(string path, string version, str }, }, }; - string searchPath = GetSpecifiedOrCurrentDirectoryPath(path); - if (!Directory.Exists(searchPath)) - { - Console.Error.WriteLine("\"{0}\" is not an existing directory.", searchPath); - return (int)ExitCodes.NoGitRepo; - } - - using var context = GitContext.Create(searchPath, engine: GitContext.Engine.ReadWrite); - if (!context.IsRepository) - { - Console.Error.WriteLine("No git repo found at or above: \"{0}\"", searchPath); - return (int)ExitCodes.NoGitRepo; - } if (string.IsNullOrEmpty(path)) { path = context.WorkingTreePath; } - VersionOptions existingOptions = context.VersionFile.GetVersion(); + VersionOptions? existingOptions = context.VersionFile.GetVersion(); if (existingOptions is not null) { if (!string.IsNullOrEmpty(version) && version != DefaultVersionSpec) diff --git a/test/Nerdbank.GitVersioning.Tests/InstallCommandTests.cs b/test/Nerdbank.GitVersioning.Tests/InstallCommandTests.cs new file mode 100644 index 00000000..91443229 --- /dev/null +++ b/test/Nerdbank.GitVersioning.Tests/InstallCommandTests.cs @@ -0,0 +1,146 @@ +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable + +using System.IO; +using LibGit2Sharp; +using Nerdbank.GitVersioning; +using Nerdbank.GitVersioning.LibGit2; +using Newtonsoft.Json.Linq; +using Xunit; +using Xunit.Abstractions; + +public class InstallCommandTests : RepoTestBase +{ + public InstallCommandTests(ITestOutputHelper logger) + : base(logger) + { + this.InitializeSourceControl(); + } + + [Fact] + public void Install_CreatesVersionJsonWithMasterBranch_WhenOnlyMasterBranchExists() + { + // Arrange: Repo is already initialized with master branch + + // Act: Install version.json using default branch detection + var versionOptions = new VersionOptions + { + Version = SemanticVersion.Parse("1.0-beta"), + PublicReleaseRefSpec = this.DetectPublicReleaseRefSpecForTesting(), + }; + + string versionFile = this.Context!.VersionFile.SetVersion(this.RepoPath, versionOptions); + + // Assert: Verify the version.json contains the correct branch + string jsonContent = File.ReadAllText(versionFile); + JObject versionJson = JObject.Parse(jsonContent); + JArray? publicReleaseRefSpec = versionJson["publicReleaseRefSpec"] as JArray; + + Assert.NotNull(publicReleaseRefSpec); + Assert.Equal("^refs/heads/master$", publicReleaseRefSpec![0]!.ToString()); + } + + [Fact] + public void Install_CreatesVersionJsonWithMainBranch_WhenOnlyMainBranchExists() + { + // Arrange: Rename the default branch to main + if (this.LibGit2Repository is object) + { + // First, make sure we have a commit, then rename + this.LibGit2Repository.Commit("Initial commit", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); + this.LibGit2Repository.Refs.Rename("refs/heads/master", "refs/heads/main"); + this.LibGit2Repository.Refs.UpdateTarget("HEAD", "refs/heads/main"); + } + + // Act: Install version.json using default branch detection + var versionOptions = new VersionOptions + { + Version = SemanticVersion.Parse("1.0-beta"), + PublicReleaseRefSpec = this.DetectPublicReleaseRefSpecForTesting(), + }; + + string versionFile = this.Context!.VersionFile.SetVersion(this.RepoPath, versionOptions); + + // Assert: Verify the version.json contains the correct branch + string jsonContent = File.ReadAllText(versionFile); + JObject versionJson = JObject.Parse(jsonContent); + JArray? publicReleaseRefSpec = versionJson["publicReleaseRefSpec"] as JArray; + + Assert.NotNull(publicReleaseRefSpec); + Assert.Equal("^refs/heads/main$", publicReleaseRefSpec![0]!.ToString()); + } + + [Fact] + public void Install_CreatesVersionJsonWithDevelopBranch_WhenOnlyDevelopBranchExists() + { + // Arrange: Rename the default branch to develop + if (this.LibGit2Repository is object) + { + // First, make sure we have a commit, then rename + this.LibGit2Repository.Commit("Initial commit", this.Signer, this.Signer, new CommitOptions { AllowEmptyCommit = true }); + this.LibGit2Repository.Refs.Rename("refs/heads/master", "refs/heads/develop"); + this.LibGit2Repository.Refs.UpdateTarget("HEAD", "refs/heads/develop"); + } + + // Act: Install version.json using default branch detection + var versionOptions = new VersionOptions + { + Version = SemanticVersion.Parse("1.0-beta"), + PublicReleaseRefSpec = this.DetectPublicReleaseRefSpecForTesting(), + }; + + string versionFile = this.Context!.VersionFile.SetVersion(this.RepoPath, versionOptions); + + // Assert: Verify the version.json contains the correct branch + string jsonContent = File.ReadAllText(versionFile); + JObject versionJson = JObject.Parse(jsonContent); + JArray? publicReleaseRefSpec = versionJson["publicReleaseRefSpec"] as JArray; + + Assert.NotNull(publicReleaseRefSpec); + Assert.Equal("^refs/heads/develop$", publicReleaseRefSpec![0]!.ToString()); + } + + protected override GitContext CreateGitContext(string path, string? committish = null) + { + return GitContext.Create(path, committish, engine: GitContext.Engine.ReadWrite); + } + + private string[] DetectPublicReleaseRefSpecForTesting() + { + // This method replicates the logic from DetectDefaultBranch for testing + string defaultBranch = "master"; // Default fallback + + if (this.Context is LibGit2Context libgit2Context) + { + LibGit2Sharp.Repository repository = libgit2Context.Repository; + + // For testing, we'll use the simple logic of checking local branches + LibGit2Sharp.Branch[] localBranches = repository.Branches.Where(b => !b.IsRemote).ToArray(); + if (localBranches.Length == 1) + { + defaultBranch = localBranches[0].FriendlyName; + } + else + { + // Use the first local branch that exists from: master, main, develop + string[] commonBranchNames = { "master", "main", "develop" }; + foreach (string branchName in commonBranchNames) + { + if (localBranches.Any(b => b.FriendlyName == branchName)) + { + defaultBranch = branchName; + break; + } + } + } + } + + return new string[] + { + $"^refs/heads/{defaultBranch}$", + @"^refs/heads/v\d+(?:\.\d+)?$", + }; + } +} From 23b90ba676cb932ebb2d1459fd51d5e5e7e9dc5e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 Aug 2025 21:53:32 +0000 Subject: [PATCH 3/3] Implement alternative default branch detection for non-LibGit2 contexts Co-authored-by: AArnott <3548+AArnott@users.noreply.github.com> --- src/nbgv/Program.cs | 311 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 310 insertions(+), 1 deletion(-) diff --git a/src/nbgv/Program.cs b/src/nbgv/Program.cs index 46a7f12c..188cd7da 100644 --- a/src/nbgv/Program.cs +++ b/src/nbgv/Program.cs @@ -495,6 +495,87 @@ private static string DetectDefaultBranch(GitContext context) } } } + else if (context.IsRepository) + { + // Alternative implementation that reads .git directory directly + string? dotGitPath = null; + + // Try to get .git path from different context types + if (context is Managed.ManagedGitContext managedContext) + { + dotGitPath = managedContext.Repository.GitDirectory; + } + else + { + // Use public API to find git directory + try + { + // Create a temporary GitContext to find the git directory + using GitContext tempContext = GitContext.Create(context.WorkingTreePath, engine: GitContext.Engine.ReadOnly); + if (tempContext.IsRepository && tempContext is Managed.ManagedGitContext tempManagedContext) + { + dotGitPath = tempManagedContext.Repository.GitDirectory; + } + } + catch + { + // Ignore errors in creating temporary context + } + } + + if (dotGitPath is object) + { + // Step 1-3: Check remotes for HEAD reference + string[] remotePreferenceOrder = { "upstream", "origin" }; + + foreach (string remoteName in remotePreferenceOrder) + { + string? defaultBranch = GetDefaultBranchFromRemoteFiles(dotGitPath, remoteName); + if (!string.IsNullOrEmpty(defaultBranch)) + { + return defaultBranch; + } + } + + // Check any other remotes if upstream/origin didn't work + string[] otherRemotes = GetRemoteNamesFromFiles(dotGitPath); + foreach (string remoteName in otherRemotes) + { + if (remoteName != "upstream" && remoteName != "origin") + { + string? defaultBranch = GetDefaultBranchFromRemoteFiles(dotGitPath, remoteName); + if (!string.IsNullOrEmpty(defaultBranch)) + { + return defaultBranch; + } + } + } + + // Step 4: If only one local branch exists, use that one + string[] localBranches = GetLocalBranchNamesFromFiles(dotGitPath); + if (localBranches.Length == 1) + { + return localBranches[0]; + } + + // Step 5: Use git config init.defaultBranch if the named branch exists locally + string? configDefaultBranch = GetConfigValueFromFiles(dotGitPath, "init.defaultBranch"); + if (!string.IsNullOrEmpty(configDefaultBranch) && localBranches.Contains(configDefaultBranch)) + { + return configDefaultBranch; + } + + // Step 6: Use the first local branch that exists from: master, main, develop + string[] commonBranchNames = { "master", "main", "develop" }; + foreach (string branchName in commonBranchNames) + { + if (localBranches.Contains(branchName)) + { + return branchName; + } + } + } + } // Fallback to "master" if nothing else works return "master"; @@ -513,7 +594,7 @@ private static string DetectDefaultBranch(GitContext context) // Try to get the symbolic reference for the remote HEAD string remoteHeadRef = $"refs/remotes/{remoteName}/HEAD"; LibGit2Sharp.Reference? remoteHead = repository.Refs[remoteHeadRef]; - + if (remoteHead?.TargetIdentifier is object) { // Extract branch name from refs/remotes/{remote}/{branch} @@ -532,6 +613,234 @@ private static string DetectDefaultBranch(GitContext context) return null; } + /// + /// Gets the default branch name from a remote's HEAD reference by reading .git files directly. + /// + /// The path to the .git directory. + /// The name of the remote. + /// The default branch name, or null if it cannot be determined. + private static string? GetDefaultBranchFromRemoteFiles(string dotGitPath, string remoteName) + { + try + { + // Try to read refs/remotes/{remote}/HEAD file + string remoteHeadPath = Path.Combine(dotGitPath, "refs", "remotes", remoteName, "HEAD"); + if (File.Exists(remoteHeadPath)) + { + string content = File.ReadAllText(remoteHeadPath).Trim(); + + // Content should be like "ref: refs/remotes/origin/main" + if (content.StartsWith("ref: ")) + { + string targetRef = content.Substring(5); // Remove "ref: " prefix + string expectedPrefix = $"refs/remotes/{remoteName}/"; + if (targetRef.StartsWith(expectedPrefix)) + { + return targetRef.Substring(expectedPrefix.Length); + } + } + } + } + catch + { + // Ignore file read errors + } + + return null; + } + + /// + /// Gets the names of all remotes by reading .git files directly. + /// + /// The path to the .git directory. + /// An array of remote names. + private static string[] GetRemoteNamesFromFiles(string dotGitPath) + { + try + { + string remotesPath = Path.Combine(dotGitPath, "refs", "remotes"); + if (Directory.Exists(remotesPath)) + { + return Directory.GetDirectories(remotesPath) + .Select(Path.GetFileName) + .Where(name => !string.IsNullOrEmpty(name)) + .ToArray()!; + } + } + catch + { + // Ignore directory read errors + } + + return Array.Empty(); + } + + /// + /// Gets the names of all local branches by reading .git files directly. + /// + /// The path to the .git directory. + /// An array of local branch names. + private static string[] GetLocalBranchNamesFromFiles(string dotGitPath) + { + var branches = new List(); + + try + { + // Read from refs/heads directory + string headsPath = Path.Combine(dotGitPath, "refs", "heads"); + if (Directory.Exists(headsPath)) + { + AddBranchesFromDirectory(headsPath, string.Empty, branches); + } + + // Also check packed-refs file + string packedRefsPath = Path.Combine(dotGitPath, "packed-refs"); + if (File.Exists(packedRefsPath)) + { + string[] lines = File.ReadAllLines(packedRefsPath); + foreach (string line in lines) + { + if (!line.StartsWith("#") && line.Contains(" refs/heads/")) + { + string[] parts = line.Split(' '); + if (parts.Length >= 2 && parts[1].StartsWith("refs/heads/")) + { + string branchName = parts[1].Substring("refs/heads/".Length); + if (!branches.Contains(branchName)) + { + branches.Add(branchName); + } + } + } + } + } + } + catch + { + // Ignore file read errors + } + + return branches.ToArray(); + } + + /// + /// Recursively adds branch names from a directory. + /// + /// The directory path to scan. + /// The relative path from refs/heads. + /// The list to add branch names to. + private static void AddBranchesFromDirectory(string directoryPath, string relativePath, List branches) + { + try + { + foreach (string filePath in Directory.GetFiles(directoryPath)) + { + string fileName = Path.GetFileName(filePath); + string branchName = string.IsNullOrEmpty(relativePath) ? fileName : $"{relativePath}/{fileName}"; + branches.Add(branchName); + } + + foreach (string subdirectoryPath in Directory.GetDirectories(directoryPath)) + { + string subdirectoryName = Path.GetFileName(subdirectoryPath); + string newRelativePath = string.IsNullOrEmpty(relativePath) ? subdirectoryName : $"{relativePath}/{subdirectoryName}"; + AddBranchesFromDirectory(subdirectoryPath, newRelativePath, branches); + } + } + catch + { + // Ignore directory read errors + } + } + + /// + /// Gets a git config value by reading .git files directly. + /// + /// The path to the .git directory. + /// The config key to read (e.g., "init.defaultBranch"). + /// The config value, or null if not found. + private static string? GetConfigValueFromFiles(string dotGitPath, string configKey) + { + try + { + // Check .git/config first + string configPath = Path.Combine(dotGitPath, "config"); + if (File.Exists(configPath)) + { + string? value = ReadConfigValue(configPath, configKey); + if (value is object) + { + return value; + } + } + + // Fall back to global config if available + string? homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(homeDir)) + { + string globalConfigPath = Path.Combine(homeDir, ".gitconfig"); + if (File.Exists(globalConfigPath)) + { + return ReadConfigValue(globalConfigPath, configKey); + } + } + } + catch + { + // Ignore config read errors + } + + return null; + } + + /// + /// Reads a config value from a git config file. + /// + /// The path to the config file. + /// The config key to read. + /// The config value, or null if not found. + private static string? ReadConfigValue(string configPath, string configKey) + { + try + { + string[] lines = File.ReadAllLines(configPath); + string[] keyParts = configKey.Split('.'); + if (keyParts.Length != 2) + { + return null; + } + + string section = keyParts[0]; + string key = keyParts[1]; + string sectionHeader = $"[{section}]"; + bool inSection = false; + + foreach (string line in lines) + { + string trimmedLine = line.Trim(); + + if (trimmedLine.StartsWith("[") && trimmedLine.EndsWith("]")) + { + inSection = string.Equals(trimmedLine, sectionHeader, StringComparison.OrdinalIgnoreCase); + } + else if (inSection && trimmedLine.Contains("=")) + { + string[] parts = trimmedLine.Split('=', 2); + if (parts.Length == 2 && string.Equals(parts[0].Trim(), key, StringComparison.OrdinalIgnoreCase)) + { + return parts[1].Trim().Trim('"'); + } + } + } + } + catch + { + // Ignore file read errors + } + + return null; + } + private static async Task OnInstallCommand(string path, string version, string[] source) { if (!SemanticVersion.TryParse(string.IsNullOrEmpty(version) ? DefaultVersionSpec : version, out SemanticVersion semver))