Skip to content

Format no format #4657

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
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
24 changes: 24 additions & 0 deletions docs/input/docs/reference/custom-formatting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Comment on lines +94 to +98
Copy link
Member

Choose a reason for hiding this comment

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

Could these last two examples be modified to be more practical and real-world relevant, with a line showing the result, as in the first code example?

```

**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.
Copy link
Member

Choose a reason for hiding this comment

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

How would ;; and ?? be combined? Can we give a code example for this as well?


## Examples

Based on actual test cases from the implementation:
Expand Down
35 changes: 35 additions & 0 deletions src/GitVersion.Core.Tests/Formatting/BackwardCompatibilityTests.cs
Copy link
Member

Choose a reason for hiding this comment

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

I love the added tests! 🤩 Could we please have a test for the combination of ;; and ?? as well?

Original file line number Diff line number Diff line change
@@ -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");
}
}
2 changes: 1 addition & 1 deletion src/GitVersion.Core.Tests/Formatting/DateFormatterTests.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using System.Globalization;
using GitVersion.Formatting;

namespace GitVersion.Tests.Formatting;
namespace GitVersion.Core.Tests.Formatting;
Copy link
Member

Choose a reason for hiding this comment

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

GitVersion.Core should not be a namespace. :)

Suggested change
namespace GitVersion.Core.Tests.Formatting;
namespace GitVersion.Tests.Formatting;


[TestFixture]
public class DateFormatterTests
Expand Down
163 changes: 163 additions & 0 deletions src/GitVersion.Core.Tests/Formatting/LegacyFormatterProblemTests.cs
Original file line number Diff line number Diff line change
@@ -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<ArgumentException>(() => template.FormatWith(semanticVersion, environment));
Copy link
Member

Choose a reason for hiding this comment

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

I think it's a good practice to test the returned ArgumentException to ensure that the right guard clause is throwing it. This just ensures that an ArgumentException is being thrown anywhere by the code path, not necessarily by our own code.

}

// ==========================================
// 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)");
}
}
138 changes: 138 additions & 0 deletions src/GitVersion.Core.Tests/Formatting/LegacyFormattingSyntaxTests.cs
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using GitVersion.Formatting;

namespace GitVersion.Tests.Formatting;
namespace GitVersion.Core.Tests.Formatting;
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
namespace GitVersion.Core.Tests.Formatting;
namespace GitVersion.Tests.Formatting;


[TestFixture]
public class StringFormatterTests
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using System.Globalization;
using GitVersion.Formatting;

namespace GitVersion.Tests.Formatting;
namespace GitVersion.Core.Tests.Formatting;
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
namespace GitVersion.Core.Tests.Formatting;
namespace GitVersion.Tests.Formatting;


[TestFixture]
public class ValueFormatterTests
Expand Down
Loading
Loading