diff --git a/docs/input/docs/reference/custom-formatting.md b/docs/input/docs/reference/custom-formatting.md index c970fea342..606bf5c19e 100644 --- a/docs/input/docs/reference/custom-formatting.md +++ b/docs/input/docs/reference/custom-formatting.md @@ -82,6 +82,30 @@ assembly-informational-format: "{Major}.{Minor}.{Patch}-{CommitsSinceVersionSour assembly-informational-format: "{SemVer}-{BranchName:l}" ``` +## Legacy .NET Composite Format Syntax + +GitVersion maintains backward compatibility with legacy .NET composite format syntax using semicolons for positive/negative/zero sections: + +```yaml +# Legacy zero-padded with empty fallback +assembly-informational-format: "{Major}.{Minor}.{Patch}-{CommitsSinceVersionSource:0000;;''}" +# Result: "6.13.54-0002" (or "6.13.54" when CommitsSinceVersionSource is 0) + +# Three-section format: positive;negative;zero +assembly-informational-format: "{Value:positive;negative;zero}" + +# Two-section format: positive;negative +assembly-informational-format: "{Value:pos;neg}" +``` + +**Format Sections:** +- **First section**: Used for positive values +- **Second section**: Used for negative values +- **Third section**: Used for zero values (optional) +- **Empty quotes** (`''` or `""`) create empty output + +**Mixed Syntax:** You can combine legacy semicolon syntax with modern `??` fallback syntax in the same template. + ## Examples Based on actual test cases from the implementation: diff --git a/src/GitVersion.Core.Tests/Formatting/BackwardCompatibilityTests.cs b/src/GitVersion.Core.Tests/Formatting/BackwardCompatibilityTests.cs new file mode 100644 index 0000000000..390d86427c --- /dev/null +++ b/src/GitVersion.Core.Tests/Formatting/BackwardCompatibilityTests.cs @@ -0,0 +1,35 @@ +namespace GitVersion.Core.Tests.Formatting; + +[TestFixture] +public class LegacyRegexPatternTests +{ + [Test] + public void ExpandTokensRegex_ShouldParseLegacySemicolonSyntax() + { + const string input = "{CommitsSinceVersionSource:0000;;''}"; + + var matches = RegexPatterns.Common.ExpandTokensRegex().Matches(input); + + matches.Count.ShouldBe(1); + var match = matches[0]; + match.Groups["member"].Value.ShouldBe("CommitsSinceVersionSource"); + match.Groups["format"].Success.ShouldBeTrue(); + } + + [Test] + public void ExpandTokensRegex_ShouldHandleMixedSyntax() + { + const string input = "{NewStyle:0000 ?? 'fallback'} {OldStyle:pos;neg;zero}"; + + var matches = RegexPatterns.Common.ExpandTokensRegex().Matches(input); + + matches.Count.ShouldBe(2); + + var newMatch = matches[0]; + newMatch.Groups["member"].Value.ShouldBe("NewStyle"); + newMatch.Groups["fallback"].Value.ShouldBe("fallback"); + + var oldMatch = matches[1]; + oldMatch.Groups["member"].Value.ShouldBe("OldStyle"); + } +} diff --git a/src/GitVersion.Core.Tests/Formatting/DateFormatterTests.cs b/src/GitVersion.Core.Tests/Formatting/DateFormatterTests.cs index bd288a66bf..4504c909ae 100644 --- a/src/GitVersion.Core.Tests/Formatting/DateFormatterTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/DateFormatterTests.cs @@ -1,7 +1,7 @@ using System.Globalization; using GitVersion.Formatting; -namespace GitVersion.Tests.Formatting; +namespace GitVersion.Core.Tests.Formatting; [TestFixture] public class DateFormatterTests diff --git a/src/GitVersion.Core.Tests/Formatting/LegacyFormatterProblemTests.cs b/src/GitVersion.Core.Tests/Formatting/LegacyFormatterProblemTests.cs new file mode 100644 index 0000000000..e501e10d0e --- /dev/null +++ b/src/GitVersion.Core.Tests/Formatting/LegacyFormatterProblemTests.cs @@ -0,0 +1,163 @@ +using GitVersion.Core.Tests.Helpers; +using GitVersion.Formatting; + +namespace GitVersion.Core.Tests.Formatting; + +[TestFixture] +public class LegacyFormatterProblemTests +{ + private TestEnvironment environment; + + [SetUp] + public void Setup() => environment = new TestEnvironment(); + + // ========================================== + // PROBLEM 1: Non-existent properties + // ========================================== + + [Test] + [Category("Problem2")] + public void Problem2_NullValue_ShouldUseZeroSection() + { + var testObject = new { Value = (int?)null }; + const string template = "{Value:positive;negative;zero}"; + const string expected = "zero"; + + var actual = template.FormatWith(testObject, environment); + actual.ShouldBe(expected, "Null values should use zero section without transformation"); + } + + [Test] + [Category("Problem1")] + public void Problem1_MissingProperty_ShouldFailGracefully() + { + // Test tries to use {MajorMinorPatch} on SemanticVersion but that property doesn't exist + var semanticVersion = new SemanticVersion + { + Major = 1, + Minor = 2, + Patch = 3 + }; + + const string template = "{MajorMinorPatch}"; // This property doesn't exist on SemanticVersion + + // Currently this will throw or behave unexpectedly + // Should either throw meaningful error or handle gracefully + Assert.Throws(() => template.FormatWith(semanticVersion, environment)); + } + + // ========================================== + // PROBLEM 2: Double negative handling + // ========================================== + + [Test] + [Category("Problem2")] + public void Problem2_NegativeValue_ShouldNotDoubleNegative() + { + var testObject = new { Value = -5 }; + const string template = "{Value:positive;negative;zero}"; + + // EXPECTED: "negative" (just the literal text from section 2) + // ACTUAL: "-negative" (the negative sign from -5 plus the literal "negative") + const string expected = "negative"; + + var actual = template.FormatWith(testObject, environment); + + // This will currently fail - we get "-negative" instead of "negative" + actual.ShouldBe(expected, "Negative values should use section text without the negative sign"); + } + + [Test] + [Category("Problem2")] + public void Problem2_PositiveValue_ShouldFormatCorrectly() + { + var testObject = new { Value = 5 }; + const string template = "{Value:positive;negative;zero}"; + const string expected = "positive"; + + var actual = template.FormatWith(testObject, environment); + actual.ShouldBe(expected); + } + + [Test] + [Category("Problem2")] + public void Problem2_ZeroValue_ShouldUseZeroSection() + { + var testObject = new { Value = 0 }; + const string template = "{Value:positive;negative;zero}"; + const string expected = "zero"; + + var actual = template.FormatWith(testObject, environment); + actual.ShouldBe(expected); + } + + // ========================================== + // PROBLEM 3: Insufficient formatting logic + // ========================================== + + [Test] + [Category("Problem3")] + public void Problem3_NumericFormatting_AllSectionsShouldFormat() + { + // Test that numeric formatting works in ALL sections, not just first + var testObject = new { Value = -42 }; + const string template = "{Value:0000;0000;0000}"; // All sections should pad with zeros + + // EXPECTED: "0042" (absolute value 42, formatted with 0000 in negative section) + // ACTUAL: "0000" (literal text instead of formatted value) + const string expected = "0042"; + + var actual = template.FormatWith(testObject, environment); + actual.ShouldBe(expected, "Negative section should format the absolute value, not return literal"); + } + + [Test] + [Category("Problem3")] + public void Problem3_FirstSectionWorks_OthersDont() + { + // Demonstrate that first section works but others don't + var positiveObject = new { Value = 42 }; + var negativeObject = new { Value = -42 }; + + const string template = "{Value:0000;WRONG;WRONG}"; + + // First section (positive) should work correctly + var positiveResult = template.FormatWith(positiveObject, environment); + positiveResult.ShouldBe("0042", "First section should format correctly"); + + // Second section (negative) should return literal when invalid format provided + var negativeResult = template.FormatWith(negativeObject, environment); + // Invalid format "WRONG" should return literal to give user feedback about their error + negativeResult.ShouldBe("WRONG", "Invalid format should return literal to indicate user error"); + } + + // ========================================== + // VERIFY #4654 FIX STILL WORKS + // ========================================== + + [Test] + [Category("Issue4654")] + public void Issue4654_LegacySyntax_ShouldStillWork() + { + // Verify the original #4654 fix still works + var testObject = new { CommitsSinceVersionSource = 2 }; + const string template = "{CommitsSinceVersionSource:0000;;''}"; + const string expected = "0002"; + + var actual = template.FormatWith(testObject, environment); + actual.ShouldBe(expected, "Issue #4654 fix must be preserved"); + } + + [Test] + [Category("Issue4654")] + public void Issue4654_ZeroValue_ShouldUseEmptyString() + { + // Zero values should use the third section (empty string) + var testObject = new { CommitsSinceVersionSource = 0 }; + const string template = "{CommitsSinceVersionSource:0000;;''}"; + const string expected = ""; + + var actual = template.FormatWith(testObject, environment); + actual.ShouldBe(expected, "Zero values should use third section (empty)"); + } +} diff --git a/src/GitVersion.Core.Tests/Formatting/LegacyFormattingSyntaxTests.cs b/src/GitVersion.Core.Tests/Formatting/LegacyFormattingSyntaxTests.cs new file mode 100644 index 0000000000..a63ddbe2ef --- /dev/null +++ b/src/GitVersion.Core.Tests/Formatting/LegacyFormattingSyntaxTests.cs @@ -0,0 +1,138 @@ +using System.Globalization; +using GitVersion.Core.Tests.Helpers; +using GitVersion.Formatting; + +namespace GitVersion.Core.Tests.Formatting; + +[TestFixture] +public class LegacyFormattingSyntaxTests +{ + [Test] + public void FormatWith_LegacyZeroFallbackSyntax_ShouldWork() + { + var semanticVersion = new SemanticVersion + { + Major = 6, + Minor = 13, + Patch = 54, + PreReleaseTag = new SemanticVersionPreReleaseTag("gv6", 1, true), + BuildMetaData = new SemanticVersionBuildMetaData() + { + Branch = "feature/gv6", + VersionSourceSha = "versionSourceSha", + Sha = "489a0c0ab425214def918e36399f3cc3c9a9c42d", + ShortSha = "489a0c0", + CommitsSinceVersionSource = 2, + CommitDate = DateTimeOffset.Parse("2025-08-12", CultureInfo.InvariantCulture) + } + }; + + const string template = "{MajorMinorPatch}{PreReleaseLabelWithDash}{CommitsSinceVersionSource:0000;;''}"; + const string expected = "6.13.54-gv60002"; + + var actual = template.FormatWith(semanticVersion, new TestEnvironment()); + + actual.ShouldBe(expected); + } + + [Test] + public void FormatWith_LegacyThreeSectionSyntax_ShouldWork() + { + var testObject = new { Value = -5 }; + const string template = "{Value:positive;negative;zero}"; + const string expected = "negative"; + + var actual = template.FormatWith(testObject, new TestEnvironment()); + + actual.ShouldBe(expected); + } + + [Test] + public void FormatWith_LegacyTwoSectionSyntax_ShouldWork() + { + var testObject = new { Value = -10 }; + const string template = "{Value:positive;negative}"; + const string expected = "negative"; + + var actual = template.FormatWith(testObject, new TestEnvironment()); + + actual.ShouldBe(expected); + } + + [Test] + public void FormatWith_LegacyZeroValue_ShouldUseThirdSection() + { + var testObject = new { Value = 0 }; + const string template = "{Value:pos;neg;ZERO}"; + const string expected = "ZERO"; + + var actual = template.FormatWith(testObject, new TestEnvironment()); + + actual.ShouldBe(expected); + } + + [Test] + public void FormatWith_MixedLegacyAndNewSyntax_ShouldWork() + { + var testObject = new + { + OldStyle = 0, + NewStyle = 42, + RegularProp = "test" + }; + const string template = "{OldStyle:pos;neg;''}{NewStyle:0000 ?? 'fallback'}{RegularProp}"; + const string expected = "0042test"; + + var actual = template.FormatWith(testObject, new TestEnvironment()); + + actual.ShouldBe(expected); + } + + [Test] + public void FormatWith_LegacyWithStandardFormatSpecifiers_ShouldWork() + { + var testObject = new { Amount = 1234.56 }; + const string template = "{Amount:C2;(C2);'No Amount'}"; + const string expected = "¤1,234.56"; + + var actual = template.FormatWith(testObject, new TestEnvironment()); + + actual.ShouldBe(expected); + } + + [Test] + public void FormatWith_Issue4654ExactCase_ShouldWork() + { + var semanticVersion = new SemanticVersion + { + Major = 6, + Minor = 13, + Patch = 54, + PreReleaseTag = new SemanticVersionPreReleaseTag("gv6", 1, true), + BuildMetaData = new SemanticVersionBuildMetaData("Branch.feature-gv6") + { + CommitsSinceVersionSource = 2 + } + }; + + var mainBranchVersion = new SemanticVersion + { + Major = 6, + Minor = 13, + Patch = 54, + PreReleaseTag = new SemanticVersionPreReleaseTag(string.Empty, 0, true), + BuildMetaData = new SemanticVersionBuildMetaData() + { + CommitsSinceVersionSource = 0 + } + }; + + const string template = "{MajorMinorPatch}{PreReleaseLabelWithDash}{CommitsSinceVersionSource:0000;;''}"; + + var featureResult = template.FormatWith(semanticVersion, new TestEnvironment()); + featureResult.ShouldBe("6.13.54-gv60002"); + + var mainResult = template.FormatWith(mainBranchVersion, new TestEnvironment()); + mainResult.ShouldBe("6.13.54"); + } +} diff --git a/src/GitVersion.Core.Tests/Formatting/StringFormatterTests.cs b/src/GitVersion.Core.Tests/Formatting/StringFormatterTests.cs index b9f3c0a27d..7c9024fe79 100644 --- a/src/GitVersion.Core.Tests/Formatting/StringFormatterTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/StringFormatterTests.cs @@ -1,6 +1,6 @@ using GitVersion.Formatting; -namespace GitVersion.Tests.Formatting; +namespace GitVersion.Core.Tests.Formatting; [TestFixture] public class StringFormatterTests diff --git a/src/GitVersion.Core.Tests/Formatting/ValueFormatterTests.cs b/src/GitVersion.Core.Tests/Formatting/ValueFormatterTests.cs index 9950cac172..675377215e 100644 --- a/src/GitVersion.Core.Tests/Formatting/ValueFormatterTests.cs +++ b/src/GitVersion.Core.Tests/Formatting/ValueFormatterTests.cs @@ -1,7 +1,7 @@ using System.Globalization; using GitVersion.Formatting; -namespace GitVersion.Tests.Formatting; +namespace GitVersion.Core.Tests.Formatting; [TestFixture] public class ValueFormatterTests diff --git a/src/GitVersion.Core.Tests/Issues/Issue4654Tests.cs b/src/GitVersion.Core.Tests/Issues/Issue4654Tests.cs new file mode 100644 index 0000000000..fd9bbc52c3 --- /dev/null +++ b/src/GitVersion.Core.Tests/Issues/Issue4654Tests.cs @@ -0,0 +1,130 @@ +using System.Globalization; +using GitVersion.Core.Tests.Helpers; +using GitVersion.Formatting; + +namespace GitVersion.Core.Tests.Issues; + +[TestFixture] +public class Issue4654Tests +{ + private const string TestVersion = "6.13.54"; + private const string TestVersionWithPreRelease = "6.13.54-gv60002"; + private const string TestPreReleaseLabel = "gv6"; + private const string TestPreReleaseLabelWithDash = "-gv6"; + + [Test] + [Category("Issue4654")] + public void Issue4654_ExactReproduction_ShouldFormatCorrectly() + { + var semanticVersion = new SemanticVersion + { + Major = 6, + Minor = 13, + Patch = 54, + PreReleaseTag = new SemanticVersionPreReleaseTag(TestPreReleaseLabel, 1, true), + BuildMetaData = new SemanticVersionBuildMetaData() + { + Branch = "feature/gv6", + VersionSourceSha = "21d7e26e6ff58374abd3daf2177be4b7a9c49040", + Sha = "489a0c0ab425214def918e36399f3cc3c9a9c42d", + ShortSha = "489a0c0", + CommitsSinceVersionSource = 2, + CommitDate = DateTimeOffset.Parse("2025-08-12", CultureInfo.InvariantCulture), + UncommittedChanges = 0 + } + }; + + var extendedVersion = new + { + semanticVersion.Major, + semanticVersion.Minor, + semanticVersion.Patch, + semanticVersion.BuildMetaData.CommitsSinceVersionSource, + MajorMinorPatch = $"{semanticVersion.Major}.{semanticVersion.Minor}.{semanticVersion.Patch}", + PreReleaseLabel = semanticVersion.PreReleaseTag.Name, + PreReleaseLabelWithDash = string.IsNullOrEmpty(semanticVersion.PreReleaseTag.Name) + ? "" + : $"-{semanticVersion.PreReleaseTag.Name}", + AssemblySemFileVer = TestVersion + ".0", + AssemblySemVer = TestVersion + ".0", + BranchName = "feature/gv6", + EscapedBranchName = "feature-gv6", + FullSemVer = "6.13.54-gv6.1+2", + SemVer = "6.13.54-gv6.1" + }; + + const string template = "{MajorMinorPatch}{PreReleaseLabelWithDash}{CommitsSinceVersionSource:0000;;''}"; + const string expected = TestVersionWithPreRelease; + + var actual = template.FormatWith(extendedVersion, new TestEnvironment()); + + actual.ShouldBe(expected, "The legacy ;;'' syntax should format CommitsSinceVersionSource as 0002, not as literal text"); + } + + [Test] + [Category("Issue4654")] + public void Issue4654_WithoutLegacySyntax_ShouldStillWork() + { + var testData = new + { + MajorMinorPatch = TestVersion, + PreReleaseLabelWithDash = TestPreReleaseLabelWithDash, + CommitsSinceVersionSource = 2 + }; + + const string template = "{MajorMinorPatch}{PreReleaseLabelWithDash}{CommitsSinceVersionSource:0000}"; + const string expected = TestVersionWithPreRelease; + + var actual = template.FormatWith(testData, new TestEnvironment()); + + actual.ShouldBe(expected, "New format syntax should work correctly"); + } + + [Test] + [Category("Issue4654")] + [Category("CurrentBehavior")] + public void Issue4654_CurrentBrokenBehavior_DocumentsActualOutput() + { + var testData = new + { + MajorMinorPatch = TestVersion, + PreReleaseLabelWithDash = TestPreReleaseLabelWithDash, + CommitsSinceVersionSource = 2 + }; + + const string template = "{MajorMinorPatch}{PreReleaseLabelWithDash}{CommitsSinceVersionSource:0000;;''}"; + const string shouldBe = TestVersionWithPreRelease; + + var actual = template.FormatWith(testData, new TestEnvironment()); + + if (actual == shouldBe) + { + Assert.Pass("The issue has been fixed!"); + } + else + { + Console.WriteLine($"Current broken output: {actual}"); + Console.WriteLine($"Expected output: {shouldBe}"); + actual.ShouldContain("CommitsSinceVersionSource"); + } + } + + [Test] + [Category("Issue4654")] + public void Issue4654_ZeroValueWithLegacySyntax_ShouldUseEmptyFallback() + { + var mainBranchData = new + { + MajorMinorPatch = TestVersion, + PreReleaseLabelWithDash = "", + CommitsSinceVersionSource = 0 + }; + + const string template = "{MajorMinorPatch}{PreReleaseLabelWithDash}{CommitsSinceVersionSource:0000;;''}"; + const string expected = TestVersion; + + var actual = template.FormatWith(mainBranchData, new TestEnvironment()); + + actual.ShouldBe(expected, "Zero values should use the third section (empty string) in legacy ;;'' syntax"); + } +} diff --git a/src/GitVersion.Core/Core/RegexPatterns.cs b/src/GitVersion.Core/Core/RegexPatterns.cs index a84161731d..ba5408e0a3 100644 --- a/src/GitVersion.Core/Core/RegexPatterns.cs +++ b/src/GitVersion.Core/Core/RegexPatterns.cs @@ -93,7 +93,7 @@ internal static partial class Common | # OR (?[A-Za-z_][A-Za-z0-9_]*) # member/property name (?: # Optional format specifier - :(?[A-Za-z0-9\.\-,]+) # Colon followed by format string (no spaces, ?, or }), format cannot contain colon + :(?[A-Za-z0-9\.\-,;'"]+) # Colon followed by format string (including semicolons and quotes) )? # Format is optional ) # End group for env or member (?: # Optional fallback group diff --git a/src/GitVersion.Core/Formatting/LegacyCompositeFormatter.cs b/src/GitVersion.Core/Formatting/LegacyCompositeFormatter.cs new file mode 100644 index 0000000000..d28c9573e2 --- /dev/null +++ b/src/GitVersion.Core/Formatting/LegacyCompositeFormatter.cs @@ -0,0 +1,164 @@ +using System.Globalization; + +namespace GitVersion.Formatting; + +internal class LegacyCompositeFormatter : IValueFormatter +{ + public int Priority => 1; + + public bool TryFormat(object? value, string format, out string result) => + TryFormat(value, format, CultureInfo.InvariantCulture, out result); + + public bool TryFormat(object? value, string format, CultureInfo cultureInfo, out string result) + { + result = string.Empty; + + if (!HasLegacySyntax(format)) + return false; + + var sections = ParseSections(format); + var index = GetSectionIndex(value, sections.Length); + + if (index >= sections.Length) + return true; + + var section = sections[index]; + + // FIX: Use absolute value for negative numbers in negative section to prevent double negatives + var valueToFormat = (index == 1 && value != null && IsNumeric(value) && Convert.ToDouble(value) < 0) + ? Math.Abs(Convert.ToDouble(value)) + : value; + + result = IsQuotedLiteral(section) + ? UnquoteString(section) + : FormatWithSection(valueToFormat, section, cultureInfo, sections, index); + + return true; + } + + private static bool HasLegacySyntax(string format) => + !string.IsNullOrEmpty(format) && format.Contains(';') && !format.Contains("??"); + + private static string[] ParseSections(string format) + { + var sections = new List(); + var current = new StringBuilder(); + var inQuotes = false; + var quoteChar = '\0'; + + foreach (var c in format) + { + if (!inQuotes && (c == '\'' || c == '"')) + { + inQuotes = true; + quoteChar = c; + } + else if (inQuotes && c == quoteChar) + { + inQuotes = false; + } + else if (!inQuotes && c == ';') + { + sections.Add(current.ToString()); + current.Clear(); + continue; + } + + current.Append(c); + } + + sections.Add(current.ToString()); + return [.. sections]; + } + + private static int GetSectionIndex(object? value, int sectionCount) + { + if (sectionCount == 1) return 0; + if (value == null) return sectionCount >= 3 ? 2 : 0; + + if (!IsNumeric(value)) return 0; + + var num = Convert.ToDouble(value); + return num switch + { + > 0 => 0, + < 0 when sectionCount >= 2 => 1, + 0 when sectionCount >= 3 => 2, + _ => 0 + }; + } + + private static bool IsNumeric(object value) => + value is byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal; + + private static bool IsQuotedLiteral(string section) + { + if (string.IsNullOrEmpty(section)) return true; + var trimmed = section.Trim(); + return (trimmed.StartsWith('\'') && trimmed.EndsWith('\'')) || + (trimmed.StartsWith('"') && trimmed.EndsWith('"')); + } + + private static string UnquoteString(string section) + { + if (string.IsNullOrEmpty(section)) return string.Empty; + var trimmed = section.Trim(); + + // FIX: Handle empty quoted strings like '' and "" + if (trimmed == "''" || trimmed == "\"\"") + return string.Empty; + + return IsQuoted(trimmed) && trimmed.Length > 2 + ? trimmed[1..^1] + : trimmed; + + static bool IsQuoted(string s) => + (s.StartsWith('\'') && s.EndsWith('\'')) || (s.StartsWith('"') && s.EndsWith('"')); + } + + private static string FormatWithSection(object? value, string section, IFormatProvider formatProvider, string[]? sections = null, int index = 0) + { + if (string.IsNullOrEmpty(section)) return string.Empty; + if (IsQuotedLiteral(section)) return UnquoteString(section); + + try + { + return value switch + { + IFormattable formattable => formattable.ToString(section, formatProvider), + not null when IsValidFormatString(section) => + string.Format(formatProvider, "{0:" + section + "}", value), + not null when index > 0 && sections != null && sections.Length > 0 && IsValidFormatString(sections[0]) => + // FIX: For invalid formats in non-first sections, use first section format + string.Format(formatProvider, "{0:" + sections[0] + "}", value), + not null => value.ToString() ?? string.Empty, + _ => section // Only for null values without valid format + }; + } + catch (FormatException) + { + // FIX: On format exception, try first section format or return value string + if (index > 0 && sections != null && sections.Length > 0 && IsValidFormatString(sections[0])) + { + try + { + return string.Format(formatProvider, "{0:" + sections[0] + "}", value); + } + catch + { + return value?.ToString() ?? section; + } + } + return value?.ToString() ?? section; + } + } + + private static bool IsValidFormatString(string format) + { + if (string.IsNullOrEmpty(format)) return false; + + var firstChar = char.ToUpperInvariant(format[0]); + return "CDEFGNPXR".Contains(firstChar) || + format.All(c => "0123456789.,#".Contains(c)); + } +} diff --git a/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs index 0f061c4f0b..4f770ff656 100644 --- a/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs +++ b/src/GitVersion.Core/Formatting/StringFormatWithExtension.cs @@ -84,7 +84,8 @@ private static string EvaluateMember(T source, string member, string? format, var getter = ExpressionCompiler.CompileGetter(source.GetType(), memberPath); var value = getter(source); - if (value is null) + // Only return early for null if format doesn't use legacy syntax + if (value is null && !HasLegacySyntax(format)) return fallback ?? string.Empty; if (format is not null && ValueFormatter.Default.TryFormat( @@ -95,6 +96,9 @@ private static string EvaluateMember(T source, string member, string? format, return formatted; } - return value.ToString() ?? fallback ?? string.Empty; + return value?.ToString() ?? fallback ?? string.Empty; } + + private static bool HasLegacySyntax(string? format) => + !string.IsNullOrEmpty(format) && format.Contains(';') && !format.Contains("??"); } diff --git a/src/GitVersion.Core/Formatting/ValueFormatter.cs b/src/GitVersion.Core/Formatting/ValueFormatter.cs index 0e0be49645..58989c80b6 100644 --- a/src/GitVersion.Core/Formatting/ValueFormatter.cs +++ b/src/GitVersion.Core/Formatting/ValueFormatter.cs @@ -13,6 +13,7 @@ internal class ValueFormatter : InvariantFormatter, IValueFormatterCombiner internal ValueFormatter() => formatters = [ + new LegacyCompositeFormatter(), new StringFormatter(), new FormattableFormatter(), new NumericFormatter(), @@ -22,17 +23,20 @@ internal ValueFormatter() public override bool TryFormat(object? value, string format, CultureInfo cultureInfo, out string result) { result = string.Empty; - if (value is null) - { - return false; - } + // Allow formatters to handle null values (e.g., legacy composite formatter for zero sections) foreach (var formatter in formatters.OrderBy(f => f.Priority)) { if (formatter.TryFormat(value, format, out result)) return true; } + // Only return false if no formatter could handle it + if (value is null) + { + return false; + } + return false; }