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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ Enable `GenerateVersionFile` to output version information during build:

The file is written to `$(OutputPath)/version.json` and is included in `dotnet publish` output.

## Analyzers

SimpleBranchVersioning includes built-in analyzers that provide build-time feedback about version detection and potential issues with branch names.

See [AnalyzerReleases.Shipped.md](src/SimpleBranchVersioning/AnalyzerReleases.Shipped.md) for the list of available diagnostics.

## Requirements

- .NET SDK 8.0 or later
Expand Down
2 changes: 2 additions & 0 deletions src/SimpleBranchVersioning/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@

Rule ID | Category | Severity | Notes
--------|----------|----------|-------
SBV002 | SimpleBranchVersioning | Warning | Invalid NuGet prerelease characters in branch name
SBV003 | SimpleBranchVersioning | Warning | Excessive branch name length
41 changes: 41 additions & 0 deletions src/SimpleBranchVersioning/AppVersionGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ public sealed class AppVersionGenerator : IIncrementalGenerator
defaultSeverity: DiagnosticSeverity.Info,
isEnabledByDefault: true);

private static readonly DiagnosticDescriptor InvalidNuGetCharactersWarning = new(
id: "SBV002",
title: "Invalid NuGet prerelease characters",
messageFormat: "Branch name contains characters invalid in NuGet versions: {0}. These will cause package restore issues.",
category: "SimpleBranchVersioning",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);

private static readonly DiagnosticDescriptor ExcessiveLengthWarning = new(
id: "SBV003",
title: "Excessive branch name length",
messageFormat: "Branch name '{0}' ({1} chars) exceeds recommended maximum of {2} characters",
category: "SimpleBranchVersioning",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);

private const string AttributeSource = """
// <auto-generated/>
#nullable enable
Expand Down Expand Up @@ -152,6 +168,31 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
branch,
commitId));

// Validate branch name for NuGet compatibility (non-release branches only)
if (!VersionCalculator.IsReleaseBranch(branch))
{
string normalizedBranch = branch.Replace('/', '.');

var (hasInvalid, invalidChars) = BranchNameValidator.ValidateCharacters(normalizedBranch);
if (hasInvalid)
{
ctx.ReportDiagnostic(Diagnostic.Create(
InvalidNuGetCharactersWarning,
Location.None,
invalidChars));
}

if (BranchNameValidator.IsExcessiveLength(branch))
{
ctx.ReportDiagnostic(Diagnostic.Create(
ExcessiveLengthWarning,
Location.None,
branch,
branch.Length,
BranchNameValidator.MaxBranchLength));
}
}

// Determine namespace:
// 1. If config specifies namespace explicitly, use it (empty string = global namespace)
// 2. If top-level statements detected and no config, use global namespace
Expand Down
60 changes: 60 additions & 0 deletions src/SimpleBranchVersioning/BranchNameValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.Text.RegularExpressions;

namespace SimpleBranchVersioning;

/// <summary>
/// Validates branch names for NuGet version compatibility.
/// </summary>
public static class BranchNameValidator
{
/// <summary>
/// Maximum recommended branch name length for practical version strings.
/// </summary>
public const int MaxBranchLength = 128;

// NuGet prerelease identifiers allow: [0-9A-Za-z-]
// After slash→dot normalization, dots are also valid
// This pattern matches any character that is NOT valid
private static readonly Regex InvalidNuGetCharsPattern = new(
@"[^0-9A-Za-z.\-]",
RegexOptions.Compiled | RegexOptions.CultureInvariant,
TimeSpan.FromSeconds(1));

/// <summary>
/// Validates that a normalized branch name contains only NuGet-compatible characters.
/// </summary>
/// <param name="normalizedBranch">Branch name with slashes replaced by dots.</param>
/// <returns>
/// A tuple containing whether invalid characters were found and a string of the invalid characters.
/// </returns>
public static (bool HasInvalidChars, string? InvalidChars) ValidateCharacters(string normalizedBranch)
{
if (string.IsNullOrEmpty(normalizedBranch))
{
return (false, null);
}

var matches = InvalidNuGetCharsPattern.Matches(normalizedBranch);
if (matches.Count == 0)
{
return (false, null);
}

// Collect unique invalid characters
// Cast required for .NET Standard 2.0 compatibility (MatchCollection doesn't implement IEnumerable<Match>)
var invalidChars = matches
.Cast<Match>()
.Select(m => m.Value)
.Distinct(StringComparer.Ordinal)
.OrderBy(c => c, StringComparer.Ordinal);

return (true, string.Join(", ", invalidChars.Select(c => $"'{c}'")));
}

/// <summary>
/// Checks if a branch name exceeds the recommended maximum length.
/// </summary>
/// <param name="branch">The original branch name.</param>
/// <returns>True if the branch name is excessively long.</returns>
public static bool IsExcessiveLength(string branch) => !string.IsNullOrEmpty(branch) && branch.Length > MaxBranchLength;
}
7 changes: 7 additions & 0 deletions src/SimpleBranchVersioning/VersionCalculator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ public static class VersionCalculator
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture,
TimeSpan.FromSeconds(1));

/// <summary>
/// Determines if the branch name matches the release branch pattern.
/// </summary>
/// <param name="branch">The git branch name.</param>
/// <returns>True if this is a release branch.</returns>
public static bool IsReleaseBranch(string branch) => ReleasePattern.IsMatch(branch);

/// <summary>
/// Calculates all version formats based on branch name and commit ID.
/// </summary>
Expand Down
93 changes: 93 additions & 0 deletions tests/SimpleBranchVersioning.Tests/AppVersionGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -498,5 +498,98 @@ public async Task Generator_VersionDetectedDiagnostic_ContainsVersionInfo()
await Assert.That(message).Contains("release/v2.0.0");
}

[Test]
[Arguments("feature/test_underscore", "'_'")]
[Arguments("feature/user@name", "'@'")]
[Arguments("feature/test+plus", "'+'")]
public async Task Generator_InvalidNuGetChars_ReportsSBV002Warning(
string branch, string expectedInvalidChar)
{
var result = GeneratorTestHelper.RunGenerator(
MinimalSource,
branchOverride: branch);

var diagnostic = result.GeneratorDiagnostics
.FirstOrDefault(d => string.Equals(d.Id, "SBV002", StringComparison.Ordinal));

await Assert.That(diagnostic).IsNotNull();
await Assert.That(diagnostic!.Severity).IsEqualTo(DiagnosticSeverity.Warning);
await Assert.That(diagnostic.GetMessage()).Contains(expectedInvalidChar);
}

[Test]
[Arguments("feature/valid-name")]
[Arguments("bugfix/issue-42")]
[Arguments("main")]
public async Task Generator_ValidNuGetChars_DoesNotReportSBV002(string branch)
{
var result = GeneratorTestHelper.RunGenerator(
MinimalSource,
branchOverride: branch);

var diagnostic = result.GeneratorDiagnostics
.FirstOrDefault(d => string.Equals(d.Id, "SBV002", StringComparison.Ordinal));

await Assert.That(diagnostic).IsNull();
}

[Test]
public async Task Generator_ReleaseBranch_DoesNotReportSBV002()
{
var result = GeneratorTestHelper.RunGenerator(
MinimalSource,
branchOverride: "release/v1.2.3");

var diagnostic = result.GeneratorDiagnostics
.FirstOrDefault(d => string.Equals(d.Id, "SBV002", StringComparison.Ordinal));

await Assert.That(diagnostic).IsNull();
}

[Test]
public async Task Generator_ExcessiveBranchLength_ReportsSBV003Warning()
{
string longBranch = "feature/" + new string('a', 150);

var result = GeneratorTestHelper.RunGenerator(
MinimalSource,
branchOverride: longBranch);

var diagnostic = result.GeneratorDiagnostics
.FirstOrDefault(d => string.Equals(d.Id, "SBV003", StringComparison.Ordinal));

await Assert.That(diagnostic).IsNotNull();
await Assert.That(diagnostic!.Severity).IsEqualTo(DiagnosticSeverity.Warning);
await Assert.That(diagnostic.GetMessage()).Contains("128");
}

[Test]
public async Task Generator_NormalBranchLength_DoesNotReportSBV003()
{
var result = GeneratorTestHelper.RunGenerator(
MinimalSource,
branchOverride: "feature/normal-length-branch");

var diagnostic = result.GeneratorDiagnostics
.FirstOrDefault(d => string.Equals(d.Id, "SBV003", StringComparison.Ordinal));

await Assert.That(diagnostic).IsNull();
}

[Test]
public async Task Generator_ReleaseBranch_DoesNotReportSBV003()
{
string longReleaseBranch = "release/v1.2.3-" + new string('a', 150);

var result = GeneratorTestHelper.RunGenerator(
MinimalSource,
branchOverride: longReleaseBranch);

var diagnostic = result.GeneratorDiagnostics
.FirstOrDefault(d => string.Equals(d.Id, "SBV003", StringComparison.Ordinal));

await Assert.That(diagnostic).IsNull();
}

#endregion
}
120 changes: 120 additions & 0 deletions tests/SimpleBranchVersioning.Tests/BranchNameValidatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
namespace SimpleBranchVersioning.Tests;

public class BranchNameValidatorTests
{
#region ValidateCharacters Tests

[Test]
[Arguments("feature.login", false)]
[Arguments("bugfix.issue-42", false)]
[Arguments("main", false)]
[Arguments("feature.nested.path", false)]
[Arguments("1.2.3-beta", false)]
public async Task ValidateCharacters_ValidBranchName_ReturnsNoInvalidChars(
string normalizedBranch, bool expectedHasInvalid)
{
var (hasInvalid, _) = BranchNameValidator.ValidateCharacters(normalizedBranch);

await Assert.That(hasInvalid).IsEqualTo(expectedHasInvalid);
}

[Test]
[Arguments("feature_login", true, "'_'")]
[Arguments("user@feature", true, "'@'")]
[Arguments("feature+test", true, "'+'")]
[Arguments("feature login", true, "' '")]
public async Task ValidateCharacters_InvalidChars_ReturnsInvalidCharsString(
string normalizedBranch, bool expectedHasInvalid, string expectedInvalidChars)
{
var (hasInvalid, invalidChars) = BranchNameValidator.ValidateCharacters(normalizedBranch);

await Assert.That(hasInvalid).IsEqualTo(expectedHasInvalid);
await Assert.That(invalidChars).IsEqualTo(expectedInvalidChars);
}

[Test]
public async Task ValidateCharacters_MultipleInvalidChars_ReturnsAllUniqueChars()
{
var (hasInvalid, invalidChars) = BranchNameValidator.ValidateCharacters("feature_test@user_name");

await Assert.That(hasInvalid).IsTrue();
await Assert.That(invalidChars).Contains("'_'");
await Assert.That(invalidChars).Contains("'@'");
}

[Test]
public async Task ValidateCharacters_EmptyString_ReturnsNoInvalidChars()
{
var (hasInvalid, invalidChars) = BranchNameValidator.ValidateCharacters("");

await Assert.That(hasInvalid).IsFalse();
await Assert.That(invalidChars).IsNull();
}

[Test]
public async Task ValidateCharacters_NullString_ReturnsNoInvalidChars()
{
var (hasInvalid, invalidChars) = BranchNameValidator.ValidateCharacters(null!);

await Assert.That(hasInvalid).IsFalse();
await Assert.That(invalidChars).IsNull();
}

#endregion

#region IsExcessiveLength Tests

[Test]
[Arguments(50, false)]
[Arguments(100, false)]
[Arguments(128, false)]
[Arguments(129, true)]
[Arguments(200, true)]
public async Task IsExcessiveLength_VariousLengths_ReturnsExpectedResult(
int branchLength, bool expectedExcessive)
{
string branch = new('a', branchLength);

bool isExcessive = BranchNameValidator.IsExcessiveLength(branch);

await Assert.That(isExcessive).IsEqualTo(expectedExcessive);
}

[Test]
public async Task IsExcessiveLength_EmptyString_ReturnsFalse()
{
bool isExcessive = BranchNameValidator.IsExcessiveLength("");

await Assert.That(isExcessive).IsFalse();
}

[Test]
public async Task IsExcessiveLength_NullString_ReturnsFalse()
{
bool isExcessive = BranchNameValidator.IsExcessiveLength(null!);

await Assert.That(isExcessive).IsFalse();
}

#endregion

#region IsReleaseBranch Tests (VersionCalculator)

[Test]
[Arguments("release/v1.2.3", true)]
[Arguments("release/1.2.3", true)]
[Arguments("release/v0.0.1", true)]
[Arguments("release/v1.0.0-beta", true)]
[Arguments("main", false)]
[Arguments("feature/login", false)]
[Arguments("release/feature", false)]
[Arguments("release/main", false)]
public async Task IsReleaseBranch_ReturnsExpectedResult(string branch, bool expected)
{
bool isRelease = VersionCalculator.IsReleaseBranch(branch);

await Assert.That(isRelease).IsEqualTo(expected);
}

#endregion
}