Skip to content

Commit 6ed2e39

Browse files
claude9swampy
authored andcommitted
Fix legacy composite format syntax handling (Issue #4654)
This commit fixes the bug where legacy .NET composite format syntax like {CommitsSinceVersionSource:0000;;''} was not working correctly. The issue was that the formatter would output literal text instead of properly formatted values. For example, it would output: "6.13.54-gv6-CommitsSinceVersionSource-0000-----" instead of: "6.13.54-gv60002" Changes: 1. Updated RegexPatterns.cs to allow semicolons and quotes in format strings by changing the format pattern from [A-Za-z0-9\.\-,]+ to [A-Za-z0-9\.\-,;'"]+ 2. Added LegacyCompositeFormatter.cs to properly handle the three-section composite format syntax (positive;negative;zero) with support for: - Zero-suppression using empty string ('') - Proper handling of negative values (no double negative) - Numeric formatting in all sections 3. Updated ValueFormatter.cs to register LegacyCompositeFormatter with priority 1 and allow null values to be processed by formatters 4. Updated StringFormatWithExtension.cs to not return early for null values when legacy syntax is detected 5. Added Issue4654Tests.cs to verify the fix works correctly The fix ensures backward compatibility with legacy .NET composite format syntax while maintaining support for modern format strings.
1 parent 767d4bf commit 6ed2e39

5 files changed

Lines changed: 325 additions & 52 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
using System.Globalization;
2+
using GitVersion.Core.Tests.Helpers;
3+
using GitVersion.Formatting;
4+
5+
namespace GitVersion.Core.Tests.Issues;
6+
7+
[TestFixture]
8+
public class Issue4654Tests
9+
{
10+
private const string TestVersion = "6.13.54";
11+
private const string TestVersionWithPreRelease = "6.13.54-gv60002";
12+
private const string TestPreReleaseLabel = "gv6";
13+
private const string TestPreReleaseLabelWithDash = "-gv6";
14+
15+
[Test]
16+
[Category("Issue4654")]
17+
public void Issue4654_ExactReproduction_ShouldFormatCorrectly()
18+
{
19+
var semanticVersion = new SemanticVersion
20+
{
21+
Major = 6,
22+
Minor = 13,
23+
Patch = 54,
24+
PreReleaseTag = new SemanticVersionPreReleaseTag(TestPreReleaseLabel, 1, true),
25+
BuildMetaData = new SemanticVersionBuildMetaData()
26+
{
27+
Branch = "feature/gv6",
28+
VersionSourceSha = "21d7e26e6ff58374abd3daf2177be4b7a9c49040",
29+
Sha = "489a0c0ab425214def918e36399f3cc3c9a9c42d",
30+
ShortSha = "489a0c0",
31+
CommitsSinceVersionSource = 2,
32+
CommitDate = DateTimeOffset.Parse("2025-08-12", CultureInfo.InvariantCulture),
33+
UncommittedChanges = 0
34+
}
35+
};
36+
37+
var extendedVersion = new
38+
{
39+
semanticVersion.Major,
40+
semanticVersion.Minor,
41+
semanticVersion.Patch,
42+
semanticVersion.BuildMetaData.CommitsSinceVersionSource,
43+
MajorMinorPatch = $"{semanticVersion.Major}.{semanticVersion.Minor}.{semanticVersion.Patch}",
44+
PreReleaseLabel = semanticVersion.PreReleaseTag.Name,
45+
PreReleaseLabelWithDash = string.IsNullOrEmpty(semanticVersion.PreReleaseTag.Name)
46+
? ""
47+
: $"-{semanticVersion.PreReleaseTag.Name}",
48+
AssemblySemFileVer = TestVersion + ".0",
49+
AssemblySemVer = TestVersion + ".0",
50+
BranchName = "feature/gv6",
51+
EscapedBranchName = "feature-gv6",
52+
FullSemVer = "6.13.54-gv6.1+2",
53+
SemVer = "6.13.54-gv6.1"
54+
};
55+
56+
const string template = "{MajorMinorPatch}{PreReleaseLabelWithDash}{CommitsSinceVersionSource:0000;;''}";
57+
const string expected = TestVersionWithPreRelease;
58+
59+
var actual = template.FormatWith(extendedVersion, new TestEnvironment());
60+
61+
actual.ShouldBe(expected, "The legacy ;;'' syntax should format CommitsSinceVersionSource as 0002, not as literal text");
62+
}
63+
64+
[Test]
65+
[Category("Issue4654")]
66+
public void Issue4654_WithoutLegacySyntax_ShouldStillWork()
67+
{
68+
var testData = new
69+
{
70+
MajorMinorPatch = TestVersion,
71+
PreReleaseLabelWithDash = TestPreReleaseLabelWithDash,
72+
CommitsSinceVersionSource = 2
73+
};
74+
75+
const string template = "{MajorMinorPatch}{PreReleaseLabelWithDash}{CommitsSinceVersionSource:0000}";
76+
const string expected = TestVersionWithPreRelease;
77+
78+
var actual = template.FormatWith(testData, new TestEnvironment());
79+
80+
actual.ShouldBe(expected, "New format syntax should work correctly");
81+
}
82+
83+
[Test]
84+
[Category("Issue4654")]
85+
public void Issue4654_ZeroValueWithLegacySyntax_ShouldUseEmptyFallback()
86+
{
87+
var mainBranchData = new
88+
{
89+
MajorMinorPatch = TestVersion,
90+
PreReleaseLabelWithDash = "",
91+
CommitsSinceVersionSource = 0
92+
};
93+
94+
const string template = "{MajorMinorPatch}{PreReleaseLabelWithDash}{CommitsSinceVersionSource:0000;;''}";
95+
const string expected = TestVersion;
96+
97+
var actual = template.FormatWith(mainBranchData, new TestEnvironment());
98+
99+
actual.ShouldBe(expected, "Zero values should use the third section (empty string) in legacy ;;'' syntax");
100+
}
101+
}

src/GitVersion.Core/Core/RegexPatterns.cs

Lines changed: 46 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -10,52 +10,52 @@ internal static partial class RegexPatterns
1010
private const RegexOptions Options = RegexOptions.IgnoreCase | RegexOptions.Compiled;
1111
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(2); // unified timeout for non-GeneratedRegex fallbacks
1212

13-
[StringSyntax(StringSyntaxAttribute.Regex)]
13+
[StringSyntax(StringSyntaxAttribute.Regex)]
1414
private const string SwitchArgumentRegexPattern = @"/\w+:";
1515

16-
[StringSyntax(StringSyntaxAttribute.Regex)]
16+
[StringSyntax(StringSyntaxAttribute.Regex)]
1717
private const string ObscurePasswordRegexPattern = "(https?://)(.+)(:.+@)";
1818

19-
[StringSyntax(StringSyntaxAttribute.Regex)]
19+
[StringSyntax(StringSyntaxAttribute.Regex)]
2020
private const string ExpandTokensRegexPattern =
2121
"""
22-
\{ # Opening brace
23-
(?: # Start of either env or member expression
24-
env:(?!env:)(?<envvar>[A-Za-z_][A-Za-z0-9_]*) # Only a single env: prefix, not followed by another env:
25-
| # OR
26-
(?<member>[A-Za-z_][A-Za-z0-9_]*) # member/property name
27-
(?: # Optional format specifier
28-
:(?<format>[A-Za-z0-9\.\-,]+) # Colon followed by format string (no spaces, ?, or }), format cannot contain colon
29-
)? # Format is optional
30-
) # End group for env or member
31-
(?: # Optional fallback group
32-
\s*\?\?\s+ # '??' operator with optional whitespace: exactly two question marks for fallback
33-
(?: # Fallback value alternatives:
34-
(?<fallback>\w+) # A single word fallback
35-
| # OR
36-
"(?<fallback>[^"]*)" # A quoted string fallback
37-
)
38-
)? # Fallback is optional
39-
\}
40-
""";
41-
42-
/// <summary>
43-
/// Allow alphanumeric, underscore, colon (for custom format specification), hyphen, and dot
44-
/// </summary>
45-
[StringSyntax(StringSyntaxAttribute.Regex, Options)]
46-
internal const string SanitizeEnvVarNameRegexPattern = @"^[A-Za-z0-9_:\-\.]+$";
47-
48-
/// <summary>
49-
/// Allow alphanumeric, underscore, and dot for property/field access
50-
/// </summary>
51-
[StringSyntax(StringSyntaxAttribute.Regex, Options)]
52-
internal const string SanitizeMemberNameRegexPattern = @"^[A-Za-z0-9_\.]+$";
53-
54-
[StringSyntax(StringSyntaxAttribute.Regex, Options)]
55-
internal const string SanitizeNameRegexPattern = "[^a-zA-Z0-9-]";
56-
57-
#if NET9_0_OR_GREATER
58-
[GeneratedRegex(SwitchArgumentRegexPattern, Options)]
22+
\{ # Opening brace
23+
(?: # Start of either env or member expression
24+
env:(?!env:)(?<envvar>[A-Za-z_][A-Za-z0-9_]*) # Only a single env: prefix, not followed by another env:
25+
| # OR
26+
(?<member>[A-Za-z_][A-Za-z0-9_]*) # member/property name
27+
(?: # Optional format specifier
28+
:(?<format>[A-Za-z0-9\.\-,;'"]+) # Colon followed by format string (including semicolons and quotes for legacy composite format)
29+
)? # Format is optional
30+
) # End group for env or member
31+
(?: # Optional fallback group
32+
\s*\?\?\s+ # '??' operator with optional whitespace: exactly two question marks for fallback
33+
(?: # Fallback value alternatives:
34+
(?<fallback>\w+) # A single word fallback
35+
| # OR
36+
"(?<fallback>[^"]*)" # A quoted string fallback
37+
)
38+
)? # Fallback is optional
39+
\}
40+
""";
41+
42+
/// <summary>
43+
/// Allow alphanumeric, underscore, colon (for custom format specification), hyphen, and dot
44+
/// </summary>
45+
[StringSyntax(StringSyntaxAttribute.Regex, Options)]
46+
internal const string SanitizeEnvVarNameRegexPattern = @"^[A-Za-z0-9_:\-\.]+$";
47+
48+
/// <summary>
49+
/// Allow alphanumeric, underscore, and dot for property/field access
50+
/// </summary>
51+
[StringSyntax(StringSyntaxAttribute.Regex, Options)]
52+
internal const string SanitizeMemberNameRegexPattern = @"^[A-Za-z0-9_\.]+$";
53+
54+
[StringSyntax(StringSyntaxAttribute.Regex, Options)]
55+
internal const string SanitizeNameRegexPattern = "[^a-zA-Z0-9-]";
56+
57+
#if NET9_0_OR_GREATER
58+
[GeneratedRegex(SwitchArgumentRegexPattern, Options)]
5959
public static partial Regex SwitchArgumentRegex { get; }
6060
#else
6161
[GeneratedRegex(SwitchArgumentRegexPattern, Options)]
@@ -65,7 +65,7 @@ internal static partial class RegexPatterns
6565
#endif
6666

6767
#if NET9_0_OR_GREATER
68-
[GeneratedRegex(ObscurePasswordRegexPattern, Options)]
68+
[GeneratedRegex(ObscurePasswordRegexPattern, Options)]
6969
public static partial Regex ObscurePasswordRegex { get; }
7070
#else
7171
[GeneratedRegex(ObscurePasswordRegexPattern, Options)]
@@ -75,7 +75,7 @@ internal static partial class RegexPatterns
7575
#endif
7676

7777
#if NET9_0_OR_GREATER
78-
[GeneratedRegex(ExpandTokensRegexPattern, RegexOptions.IgnorePatternWhitespace | Options)]
78+
[GeneratedRegex(ExpandTokensRegexPattern, RegexOptions.IgnorePatternWhitespace | Options)]
7979
public static partial Regex ExpandTokensRegex { get; }
8080
#else
8181
[GeneratedRegex(ExpandTokensRegexPattern, RegexOptions.IgnorePatternWhitespace | Options)]
@@ -85,7 +85,7 @@ internal static partial class RegexPatterns
8585
#endif
8686

8787
#if NET9_0_OR_GREATER
88-
[GeneratedRegex(SanitizeEnvVarNameRegexPattern, Options)]
88+
[GeneratedRegex(SanitizeEnvVarNameRegexPattern, Options)]
8989
public static partial Regex SanitizeEnvVarNameRegex { get; }
9090
#else
9191
[GeneratedRegex(SanitizeEnvVarNameRegexPattern, Options)]
@@ -95,7 +95,7 @@ internal static partial class RegexPatterns
9595
#endif
9696

9797
#if NET9_0_OR_GREATER
98-
[GeneratedRegex(SanitizeMemberNameRegexPattern, Options)]
98+
[GeneratedRegex(SanitizeMemberNameRegexPattern, Options)]
9999
public static partial Regex SanitizeMemberNameRegex { get; }
100100
#else
101101
[GeneratedRegex(SanitizeMemberNameRegexPattern, Options)]
@@ -105,7 +105,7 @@ internal static partial class RegexPatterns
105105
#endif
106106

107107
#if NET9_0_OR_GREATER
108-
[GeneratedRegex(SanitizeNameRegexPattern, Options)]
108+
[GeneratedRegex(SanitizeNameRegexPattern, Options)]
109109
public static partial Regex SanitizeNameRegex { get; }
110110
#else
111111
[GeneratedRegex(SanitizeNameRegexPattern, Options)]
@@ -126,7 +126,7 @@ public static Regex GetOrAdd([StringSyntax(StringSyntaxAttribute.Regex)] string
126126
KnownRegexes.TryGetValue(key, out var regex)
127127
? regex
128128
: new Regex(key, Options, DefaultTimeout)); // now uses timeout for safety
129-
}
129+
}
130130

131131
// Descriptor used to centralize pattern + compiled regex instance. Extendable with options/timeout metadata later.
132132
private readonly record struct RegexDescriptor(string Pattern, Regex Regex);

0 commit comments

Comments
 (0)