Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
150 changes: 135 additions & 15 deletions src/nbgv/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -415,6 +417,121 @@ private static int MainInner(string[] args)
return (int)exitCode;
}

/// <summary>
/// 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.
/// </summary>
/// <param name="context">The git context to query.</param>
/// <returns>The detected default branch name, defaulting to "master" if none can be determined.</returns>
private static string DetectDefaultBranch(GitContext context)
{
if (context is LibGit2.LibGit2Context libgit2Context)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot This will often not be true. Can you implement the alternative as well (presumably reading files from the .git directory manually)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented the alternative approach for non-LibGit2 contexts in commit 23b90ba. The implementation now handles ManagedGitContext and other context types by reading .git directory files directly, using the same fallback algorithm as the LibGit2 version.

{
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<string>("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";
}

/// <summary>
/// Gets the default branch name from a remote's HEAD reference.
/// </summary>
/// <param name="repository">The repository to query.</param>
/// <param name="remoteName">The name of the remote.</param>
/// <returns>The default branch name, or null if it cannot be determined.</returns>
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<int> OnInstallCommand(string path, string version, string[] source)
{
if (!SemanticVersion.TryParse(string.IsNullOrEmpty(version) ? DefaultVersionSpec : version, out SemanticVersion semver))
Expand All @@ -423,12 +540,28 @@ private static async Task<int> 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
Expand All @@ -439,26 +572,13 @@ private static async Task<int> 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)
Expand Down
146 changes: 146 additions & 0 deletions test/Nerdbank.GitVersioning.Tests/InstallCommandTests.cs
Original file line number Diff line number Diff line change
@@ -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+)?$",
};
}
}
Loading