From 7acc30c2710e0c7ee3447b0174b7a4c2d0e65d2f Mon Sep 17 00:00:00 2001 From: Phil Asmar Date: Wed, 2 Sep 2026 12:42:23 -0400 Subject: [PATCH] feat: make the release tag and release name formats configurable in autover.json --- .../c53b9f7c-bc00-47f1-9596-6da2c951a860.json | 15 + README.md | 280 +++++- src/AutoVer/Commands/VersionCommand.cs | 9 +- src/AutoVer/Models/ThreePartVersion.cs | 61 +- src/AutoVer/Models/UserConfiguration.cs | 50 +- src/AutoVer/Models/VersionTag.cs | 48 + src/AutoVer/Models/VersionTagFormat.cs | 704 ++++++++++++++ src/AutoVer/Services/ConfigurationManager.cs | 73 ++ src/AutoVer/Services/GitHandler.cs | 55 ++ src/AutoVer/Services/IConfigurationManager.cs | 7 + src/AutoVer/Services/IGitHandler.cs | 15 + .../ProjectFiles/CsprojNuspecFileHandler.cs | 4 + .../ProjectFiles/DockerfileFileHandler.cs | 4 + src/AutoVer/Services/VersionHandler.cs | 291 ++++-- .../TagFormatCommandTests.cs | 861 ++++++++++++++++++ .../Utilities/GitUtilities.cs | 33 + .../CsprojNuspecFileHandlerTest.cs | 4 +- .../AutoVer.UnitTests/ThreePartVersionTest.cs | 69 ++ .../Utilities/GitUtilities.cs | 28 +- .../AutoVer.UnitTests/VersionTagFormatTest.cs | 208 +++++ 20 files changed, 2691 insertions(+), 128 deletions(-) create mode 100644 .autover/changes/c53b9f7c-bc00-47f1-9596-6da2c951a860.json create mode 100644 src/AutoVer/Models/VersionTag.cs create mode 100644 src/AutoVer/Models/VersionTagFormat.cs create mode 100644 test/AutoVer.IntegrationTests/TagFormatCommandTests.cs create mode 100644 test/AutoVer.UnitTests/ThreePartVersionTest.cs create mode 100644 test/AutoVer.UnitTests/VersionTagFormatTest.cs diff --git a/.autover/changes/c53b9f7c-bc00-47f1-9596-6da2c951a860.json b/.autover/changes/c53b9f7c-bc00-47f1-9596-6da2c951a860.json new file mode 100644 index 0000000..be4d3ab --- /dev/null +++ b/.autover/changes/c53b9f7c-bc00-47f1-9596-6da2c951a860.json @@ -0,0 +1,15 @@ +{ + "Projects": [ + { + "Name": "AutoVer", + "Type": "Minor", + "ChangelogMessages": [ + "Add configurable TagFormat and ReleaseNameFormat, so a release can be tagged by version (e.g. v1.4.0) instead of only by date. Formats are built from placeholders with optional groups, and are validated for invertibility and against git's ref grammar when the configuration is read", + "Order prerelease versions by SemVer 2.0.0 precedence, so beta.2 sorts below beta.10 and 1.4.0 sorts above 1.4.0-beta.1", + "Keep a hyphenated prerelease label such as 1.0.0-alpha-1 intact when parsing a version, instead of dropping it", + "Identify the current release as the nearest release tag reachable from HEAD, so a backport's changelog no longer describes the highest version instead of the release just cut", + "Fix the release name falling back to a tag-shaped string on the first release of a new date" + ] + } + ] +} diff --git a/README.md b/README.md index 429cbb7..f441898 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,258 @@ # AutoVer -**Notice:** _This project is currently in development and is not ready for use beyond experimentation._ +**AutoVer** is a .NET CLI tool that automates versioning, tagging and changelog +generation for a git repository. -**AutoVer** is a .NET CLI tool that is used for automating versioning of .NET projects. +```bash +dotnet tool install -g AutoVer +``` + +Requires the .NET 10 SDK or later. The installed command is `autover`. + +## What it does + +* Versions one or many projects in a single git repository, with independent or + shared version numbers +* Versions `.csproj`, `.nuspec` and `Dockerfile` projects - the latter through the + `org.opencontainers.image.version` label +* Applies Patch, Minor or Major increments, chosen per release or per change +* Commits the version bump and tags the release +* Generates a `CHANGELOG.md` from conventional commit messages or from + hand-written change files +* Reports the release name and tag for a CI job to create a GitHub or GitLab + release from +* Lets you configure the shape of the tag and release name a release produces + +## Getting started + +Describe the projects to version in `.autover/autover.json` at the repository root: + +```json +{ + "Projects": [ + { "Name": "MyLibrary", "Path": "src/MyLibrary/MyLibrary.csproj" } + ], + "UseCommitsForChangelog": false, + "ChangeFilesDetermineIncrementType": true +} +``` + +Record a change as part of the work that made it: + +```bash +autover change --project-name MyLibrary --increment-type Minor -m "Add a widget API" +``` + +That writes a change file under `.autover/changes/`. Commit it alongside your +code. When you're ready to release: + +```bash +autover version # bumps the version, commits, and tags the release +autover changelog # writes CHANGELOG.md, consumes the change files, commits +``` + +Then push, tags included: + +```bash +git push && git push origin --tags +``` + +## Commands + +### `autover version` + +Performs the versioning: bumps each configured project, commits the change and +tags the release. + +| Option | Purpose | +|---|---| +| `--project-path ` | Repository or project directory to act on. Defaults to the current directory. | +| `--increment-type ` | `Major`, `Minor`, `Patch` or `None`. Overrides the configured default. | +| `--use-version ` | Release a specific version instead of an increment. | +| `--current` | Print the current version without changing anything. | +| `--skip-version-tag-check` | Don't require each project to already carry a version. | +| `--no-commit` | Bump the version without committing (implies no tag). | +| `--no-tag` | Commit the bump without tagging. | +| `--verbose` | Include full exception detail on failure. | + +`--current` prints a bare value for a single project, so it can be captured +directly: `VERSION=$(autover version --current)`. + +### `autover changelog` + +Generates `CHANGELOG.md` for the current release and commits it. + +| Option | Purpose | +|---|---| +| `--project-path ` | Repository or project directory to act on. | +| `--output-to-console` | Print the changelog instead of writing the file. | +| `--release-name` | Print the current release's name. | +| `--tag-name` | Print the current release's tag. | +| `--verbose` | Include full exception detail on failure. | + +`--release-name`, `--tag-name` and `--output-to-console` print to stdout for +shell capture; warnings and errors go to stderr. + +### `autover change` + +Creates a change file describing work in progress, to be committed with it and +consumed by the next `autover changelog`. + +| Option | Purpose | +|---|---| +| `--project-name ` | The configured project the change belongs to. | +| `--increment-type ` | `Major`, `Minor`, `Patch` or `None`. | +| `--message, -m ` | The changelog message. | +| `--project-path ` | Repository or project directory to act on. | + +## Configuration + +`.autover/autover.json`: + +| Setting | Default | Purpose | +|---|---|---| +| `Projects` | `[]` | The projects to version. Omit to auto-discover. | +| `UseCommitsForChangelog` | `true` | Build the changelog from conventional commit messages. Set `false` to use change files. | +| `ChangeFilesDetermineIncrementType` | `false` | Let each change file's increment type decide the bump. | +| `UseSameVersionForAllProjects` | `false` | Move every project to one shared version. | +| `DefaultIncrementType` | `Patch` | The increment used when nothing else specifies one. | +| `ChangelogCategories` | none | Map a commit type (e.g. `feat`) to a changelog heading. | +| `TagFormat` | `release_{date}[_{iteration}]` | The git tag a release is tagged with. | +| `ReleaseNameFormat` | follows `TagFormat` | The human-readable release name. | -The tool is meant to support the following capabilities: -* Auto version multiple project with different version numbers in a single git repository -* Specify Patch, Minor or Major version types -* Create git tags and GitHub releases -* Generate changelogs based on git commit messages as well as hand-written ones. +Each entry in `Projects` takes: + +| Field | Purpose | +|---|---| +| `Name` | The name used by `autover change --project-name` and in the changelog. | +| `Path` | The project file to version. | +| `Paths` | Several project files that share one version, instead of `Path`. | +| `IncrementType` | An increment specific to this project. | +| `PrereleaseLabel` | A prerelease label to apply, e.g. `beta.1`. | + +## Tag and release name formats + +By default AutoVer tags a release by date - `release_2026-09-02`, or +`release_2026-09-02_2` for a second release the same day - and names it +`Release 2026-09-02`. Both are configurable: + +```json +{ + "TagFormat": "v{major}.{minor}.{patch}[-{prerelease}]", + "ReleaseNameFormat": "Release {major}.{minor}.{patch}[-{prerelease}]" +} +``` + +A version-based tag is what lets a consumer pin an immutable, meaningful ref - a +shared CI templates repository included at `ref: v1.4.0`, for instance, where a +date tells you when a release happened but not whether adopting it will break you. + +`ReleaseNameFormat` is optional: left unset, it follows `TagFormat`'s family, so +setting only `TagFormat` is enough. + +Text outside braces is literal. `{{`, `}}`, `[[` and `]]` are escapes for the +corresponding literal character. + +| Placeholder | Family | Renders | +|---|---|---| +| `{major}` `{minor}` `{patch}` | version | The project's version components | +| `{prerelease}` | version | The prerelease label (e.g. `beta.1`), empty when unset | +| `{date}` | date | The release date, `yyyy-MM-dd` by default | +| `{year}` `{month}` `{day}` | date | Individual date components | +| `{iteration}` | either | 2, 3, ... for a repeat release of the same version/date; 1 for the first | + +A format draws from one family only (see the rules below); `{iteration}` belongs +to neither and can be used with both. + +Date placeholders accept a .NET date format: `{date:yyyyMMdd}`, `{year:yy}`, +`{month:MMM}`. Supported specifiers are `yyyy`, `yy`, `MMMM`, `MMM`, `MM`, `M`, +`dd` and `d`, plus the separators `-` `_` `.` `/` and space - a specifier that +renders but couldn't be read back would silently drop tags from release history. + +### Optional groups + +A `[...]` group renders only when the placeholders inside it have a value, which +is what lets one format cover a release both with and without a prerelease, or +both the first and a repeat release: + +| Format | Renders | +|---|---| +| `v{major}.{minor}.{patch}[-{prerelease}]` | `v1.4.0`, or `v1.4.0-beta.1` | +| `release_{date}[_{iteration}]` | `release_2026-09-02`, or `release_2026-09-02_2` | + +Only `{prerelease}` and `{iteration}` can be absent, so only those are meaningful +inside a group. + +### Rules + +A tag isn't only written - it's read back to work out which release was most +recent, and from that the commit range a changelog covers. So a format has to +yield an unambiguous sort order, which these rules enforce at load time rather +than part-way through a release: + +* **One family per format.** Use version placeholders or date placeholders, never + both. The two orderings disagree the moment a release isn't strictly linear (a + backport, or an explicit `--use-version`), leaving no single correct answer for + which release came last. A date is also recoverable from the tagged commit + regardless, so a date inside a version tag adds nothing git doesn't already know. +* **A complete ordering key.** All of `{major}`, `{minor}` and `{patch}`, or a full + date. A partial key collides on every release that only changes an omitted part. +* **An unambiguous layout.** A format has to read back what it renders, so + adjacent variable-width placeholders are rejected: `{major}{minor}{patch}` + renders `1.23.456` as `123456`, which reads back as `1234.5.6`. +* **`TagFormat` and `ReleaseNameFormat` must use the same family**, since a release + name is rendered from the components of the tag it describes. +* **A version-based format needs one version to represent.** If a repository has + several projects that can hold different versions, either set + `UseSameVersionForAllProjects` to `true` or use a date-based format. +* **Repeat releases need somewhere to go.** Releasing the same version twice with + no `{iteration}` in the format is an error rather than a duplicate tag. +* **`TagFormat` has to produce a name git accepts**: no space, `~`, `^`, `:`, + `?`, `*`, `[`, `\`, `..` or `@{`, no trailing `.` or `.lock`, no empty path + segment. Validated against git's own ref grammar when the configuration is + read, before any project file is written, and again on the finished tag name. + `ReleaseNameFormat` is free text and unrestricted. + +**If any project sets `PrereleaseLabel`, include `{prerelease}` in a +version-based `TagFormat`.** Without it, `1.4.0-beta.1` and `1.4.0` both render +as `v1.4.0`, and releasing the second one is reported as a collision. + +**A format with no distinctive literal text matches more than you might expect.** +`{major}.{minor}.{patch}` matches any tag shaped like `1.4.0`, including one +created by hand before AutoVer was adopted, which would then count as release +history. Prefixing the format (`v{major}.{minor}.{patch}`) narrows it. + +**The release AutoVer describes is the nearest one reachable from HEAD**, not the +highest-ordering tag - the same notion as `git describe --tags --abbrev=0`. The +two differ after a backport - release `2.0.0`, then cut `1.9.1` from the older +line - where building a changelog for the highest tag would describe the wrong +release and cover the wrong commit range. If no release tag is reachable at all +(an unmerged branch, or a shallow clone), the highest-ordering tag is used. + +### Switching the format on an existing repository + +The version lives in the project file, not in the tag, so releases carry on +normally - `0.5.0` still becomes `0.5.1`, now tagged `v0.5.1`. Tags written under +the old format simply stop matching: AutoVer ignores what it can't parse, and says +so once on stderr when that leaves no release history at all. Old tags and +releases are left untouched. + +Two things to know before switching: + +* **Commit the `autover.json` change before releasing.** `autover changelog` loads + the configuration as of the release tag, so an uncommitted switch leaves it + reading the old format back and titling the release with a date. +* **A commits-based changelog loses its range boundary on the first release after + the switch.** With no version-based release before it, the range reaches back to + the start of the repository. Tagging the previous release's commit with a + version-based name first (`git tag v0.5.0 release_2026-09-01`) bridges the gap. + Repositories using change files are unaffected - their changelog comes from the + change files, not from a commit range. ## CI/CD -**AutoVer** could be used as part of your CI/CD workflow. The following is a sample GitHub Workflow to version a .NET project as well as create the CHANGELOG.md file. +AutoVer is designed to run in a CI/CD pipeline. The following GitHub Workflow +versions a project and updates its `CHANGELOG.md`: ```yaml name: Prepare Release @@ -30,6 +270,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup .NET 10.0 uses: actions/setup-dotnet@v4 with: @@ -42,18 +284,32 @@ jobs: git config --global user.name "GitHub User" - name: Increment Version run: autover version - - name: Update Chagelog + - name: Update Changelog run: autover changelog - name: Push Changes run: git push && git push origin --tags ``` -**_Note_**: Make sure to replace `github-user@email.com` and `GitHub User`. +**_Note_**: Replace `github-user@email.com` and `GitHub User` with the identity +the release commits should be attributed to. `fetch-depth: 0` matters - AutoVer +reads tag history to determine the current and previous release. + +To create a release from the same pipeline, read the name and tag back: + +```yaml + - name: Create Release + run: | + tag=$(autover changelog --tag-name) + name=$(autover changelog --release-name) + notes=$(autover changelog --output-to-console) + gh release create "$tag" --title "$name" --notes "$notes" +``` ## Getting Help -If you have any feature requests, feel free to submit then to this repo for me to take into consideration as I build this tool out. +Bug reports and feature requests are welcome as +[issues](https://github.com/philasmar/autover/issues) on this repository. This project takes inspiration from the following projects: * [Versionize](https://github.com/versionize/versionize) - * [AWS .NET deployment tool](https://github.com/aws/aws-dotnet-deploy) \ No newline at end of file + * [AWS .NET deployment tool](https://github.com/aws/aws-dotnet-deploy) diff --git a/src/AutoVer/Commands/VersionCommand.cs b/src/AutoVer/Commands/VersionCommand.cs index 138eaee..16149e2 100644 --- a/src/AutoVer/Commands/VersionCommand.cs +++ b/src/AutoVer/Commands/VersionCommand.cs @@ -145,12 +145,17 @@ public async Task ExecuteAsync( if (!optionNoCommit) { + // Resolved before committing, deliberately: a tag name that git would reject, or that + // collides with an existing release, must fail while the repository is still untouched + // rather than leaving behind a release commit with no tag on it. + var tagName = optionNoTag ? null : versionHandler.GetNewVersionTag(userConfiguration); + if (gitHandler.HasStagedChanges(userConfiguration)) gitHandler.CommitChanges(userConfiguration, versionHandler.GetNewReleaseName(userConfiguration)); - if (!optionNoTag) + if (tagName is not null) { - gitHandler.AddTag(userConfiguration, versionHandler.GetNewVersionTag(userConfiguration)); + gitHandler.AddTag(userConfiguration, tagName); } } } diff --git a/src/AutoVer/Models/ThreePartVersion.cs b/src/AutoVer/Models/ThreePartVersion.cs index c9c2b42..da83da4 100644 --- a/src/AutoVer/Models/ThreePartVersion.cs +++ b/src/AutoVer/Models/ThreePartVersion.cs @@ -21,8 +21,14 @@ public override string ToString() public static ThreePartVersion Parse(string? version) { - var fullVersionParts = version?.Split("-"); - var prereleaseLabel = (fullVersionParts?.Length == 2) ? fullVersionParts[1] : null; + // Per SemVer the prerelease is everything after the *first* hyphen and may itself contain + // hyphens (1.0.0-alpha-1). Splitting on every hyphen produced more than two parts for those + // and dropped the label altogether - which a version-based tag format would then bake into + // the tag it renders. + var fullVersionParts = version?.Split('-', 2); + var prereleaseLabel = fullVersionParts?.Length == 2 && !string.IsNullOrEmpty(fullVersionParts[1]) + ? fullVersionParts[1] + : null; var versionParts = fullVersionParts?[0].Split("."); if (versionParts?.Length != 3) throw new Exception("The provided version number is not a valid 3 part version."); @@ -73,7 +79,56 @@ public int CompareTo(ThreePartVersion? other) result = Patch.CompareTo(other.Patch); if (result != 0) return result; - return string.Compare(PrereleaseLabel, other.PrereleaseLabel, StringComparison.Ordinal); + return ComparePrerelease(PrereleaseLabel, other.PrereleaseLabel); + } + + /// + /// Prerelease precedence per SemVer 2.0.0 §11: a version carrying a prerelease label ranks + /// below the same version without one, and labels compare identifier-by-identifier (split on + /// '.') with numeric identifiers compared numerically. A plain string comparison gets both + /// wrong - it puts 1.4.0 below 1.4.0-beta.1, and beta.10 below beta.2. + /// + private static int ComparePrerelease(string? left, string? right) + { + var leftMissing = string.IsNullOrEmpty(left); + var rightMissing = string.IsNullOrEmpty(right); + + if (leftMissing && rightMissing) return 0; + if (leftMissing) return 1; + if (rightMissing) return -1; + + var leftIdentifiers = left!.Split('.'); + var rightIdentifiers = right!.Split('.'); + + for (var i = 0; i < Math.Max(leftIdentifiers.Length, rightIdentifiers.Length); i++) + { + // "A larger set of pre-release fields has a higher precedence than a smaller set, if + // all of the preceding identifiers are equal" - so beta.1 ranks below beta.1.1. + if (i >= leftIdentifiers.Length) return -1; + if (i >= rightIdentifiers.Length) return 1; + + var leftIdentifier = leftIdentifiers[i]; + var rightIdentifier = rightIdentifiers[i]; + + var leftIsNumeric = long.TryParse(leftIdentifier, out var leftNumber); + var rightIsNumeric = long.TryParse(rightIdentifier, out var rightNumber); + + if (leftIsNumeric && rightIsNumeric) + { + var numeric = leftNumber.CompareTo(rightNumber); + if (numeric != 0) return numeric; + continue; + } + + // "Numeric identifiers always have lower precedence than non-numeric identifiers." + if (leftIsNumeric) return -1; + if (rightIsNumeric) return 1; + + var ordinal = string.CompareOrdinal(leftIdentifier, rightIdentifier); + if (ordinal != 0) return ordinal; + } + + return 0; } public static bool operator >(ThreePartVersion? left, ThreePartVersion? right) diff --git a/src/AutoVer/Models/UserConfiguration.cs b/src/AutoVer/Models/UserConfiguration.cs index 317f25e..c10f2ff 100644 --- a/src/AutoVer/Models/UserConfiguration.cs +++ b/src/AutoVer/Models/UserConfiguration.cs @@ -6,16 +6,64 @@ namespace AutoVer.Models; public class UserConfiguration { + /// + /// Reproduces the tag shape AutoVer used before the format was configurable, so a repo that + /// doesn't set keeps tagging exactly as it always has. The iteration + /// sits in an optional group because it only appears from the second release of a day onward. + /// + public const string DefaultTagFormat = "release_{date}[_{iteration}]"; + + public const string DefaultReleaseNameFormat = "Release {date}[ #{iteration}]"; + + /// + /// Used when is unset and is + /// version-based. Setting one of the two options shouldn't oblige a user to set the other, and a + /// date-based release name alongside a version-based tag is rejected as a family mismatch. + /// + public const string DefaultSemverReleaseNameFormat = "Release {major}.{minor}.{patch}[-{prerelease}][ #{iteration}]"; + [JsonIgnore] public string GitRoot { get; set; } = string.Empty; internal bool PersistConfiguration { get; set; } public List Projects { get; set; } = []; public bool UseCommitsForChangelog { get; set; } = true; public bool UseSameVersionForAllProjects { get; set; } = false; - + [JsonConverter(typeof(JsonStringEnumConverter))] public IncrementType DefaultIncrementType { get; set; } = IncrementType.Patch; public Dictionary? ChangelogCategories { get; set; } public bool ChangeFilesDetermineIncrementType { get; set; } = false; + + /// + /// Format for the git tag a release is tagged with, e.g. v{major}.{minor}.{patch}. + /// Left null (rather than defaulted in place) so that rewriting this file doesn't stamp a + /// value into the config of every repo that never asked for one. + /// + public string? TagFormat { get; set; } + + /// + /// Format for the human-readable release name, kept separate from so a + /// friendly name can differ from the raw tag. + /// + public string? ReleaseNameFormat { get; set; } + + [JsonIgnore] + public string EffectiveTagFormat => + string.IsNullOrWhiteSpace(TagFormat) ? DefaultTagFormat : TagFormat; + + /// + /// The release name format to use, falling back to a default drawn from the same family as the + /// tag format so that an unset never clashes with a configured + /// . + /// + public string ResolveReleaseNameFormat(TagFormatFamily tagFamily) + { + if (!string.IsNullOrWhiteSpace(ReleaseNameFormat)) + return ReleaseNameFormat; + + return tagFamily == TagFormatFamily.Semver + ? DefaultSemverReleaseNameFormat + : DefaultReleaseNameFormat; + } } public class ProjectContainer : IJsonOnDeserialized diff --git a/src/AutoVer/Models/VersionTag.cs b/src/AutoVer/Models/VersionTag.cs new file mode 100644 index 0000000..653e990 --- /dev/null +++ b/src/AutoVer/Models/VersionTag.cs @@ -0,0 +1,48 @@ +namespace AutoVer.Models; + +/// +/// A git tag that matched the repository's configured tag format, decomposed into the components +/// that order it. Ordering matters beyond presentation: the most recent tag identifies the current +/// release, and the one before it bounds the commit range a changelog covers. +/// +public sealed class VersionTag( + string raw, + TagFormatFamily family, + ThreePartVersion? version, + DateTime? date, + int iteration) : IComparable +{ + /// + /// The tag exactly as it exists in git. + /// + public string Raw { get; } = raw; + + public TagFormatFamily Family { get; } = family; + + /// + /// Set for a tag. + /// + public ThreePartVersion? Version { get; } = version; + + /// + /// Set for a tag. + /// + public DateTime? Date { get; } = date; + + /// + /// 1 for the first release of a given version/date, incrementing only to break a collision. + /// + public int Iteration { get; } = iteration; + + public int CompareTo(VersionTag? other) + { + if (other is null) + return 1; + + var result = Family == TagFormatFamily.Semver + ? Comparer.Default.Compare(Version, other.Version) + : Nullable.Compare(Date, other.Date); + + return result != 0 ? result : Iteration.CompareTo(other.Iteration); + } +} diff --git a/src/AutoVer/Models/VersionTagFormat.cs b/src/AutoVer/Models/VersionTagFormat.cs new file mode 100644 index 0000000..8d03899 --- /dev/null +++ b/src/AutoVer/Models/VersionTagFormat.cs @@ -0,0 +1,704 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using AutoVer.Exceptions; + +namespace AutoVer.Models; + +/// +/// Which ordering key a tag format carries. A tag isn't only written by AutoVer - it's read +/// back to work out which release was the most recent (and, from that, the commit range a +/// changelog covers), so every format has to yield an unambiguous sort order. Semver and date +/// orderings disagree the moment a release isn't strictly linear (a backport, or an explicit +/// --use-version), which is why a single format may carry one family or the other, never both. +/// +public enum TagFormatFamily +{ + Semver, + Date +} + +internal enum TagPlaceholder +{ + Major, + Minor, + Patch, + Prerelease, + Year, + Month, + Day, + Date, + Iteration +} + +/// +/// A parsed, validated tag (or release name) format such as +/// v{major}.{minor}.{patch}[-{prerelease}] or release_{date}[_{iteration}]. +/// +/// Text outside braces is literal. {{, }}, [[ and ]] are escapes for +/// the corresponding literal character. A [...] group renders only when every placeholder +/// inside it has a value - which is what lets one format string cover both +/// release_2026-09-02 and release_2026-09-02_5, or both v1.4.0 and +/// v1.4.0-beta.1, instead of needing a separate format per case. +/// +public sealed class VersionTagFormat +{ + // Only these two carry an "absent" state worth eliding, so only these two are meaningful + // inside an optional group: a prerelease label can be unset, and an iteration is implicitly + // 1 for the first release of a given version/date. Major/minor/patch/date components always + // have a value, so wrapping one in [...] would be silently pointless. + private static readonly TagPlaceholder[] OptionalCapablePlaceholders = + [TagPlaceholder.Prerelease, TagPlaceholder.Iteration]; + + private static readonly TagPlaceholder[] SemverPlaceholders = + [TagPlaceholder.Major, TagPlaceholder.Minor, TagPlaceholder.Patch, TagPlaceholder.Prerelease]; + + private static readonly TagPlaceholder[] DatePlaceholders = + [TagPlaceholder.Year, TagPlaceholder.Month, TagPlaceholder.Day, TagPlaceholder.Date]; + + private static readonly Dictionary PlaceholderNames = + new(StringComparer.OrdinalIgnoreCase) + { + ["major"] = TagPlaceholder.Major, + ["minor"] = TagPlaceholder.Minor, + ["patch"] = TagPlaceholder.Patch, + ["prerelease"] = TagPlaceholder.Prerelease, + ["year"] = TagPlaceholder.Year, + ["month"] = TagPlaceholder.Month, + ["day"] = TagPlaceholder.Day, + ["date"] = TagPlaceholder.Date, + ["iteration"] = TagPlaceholder.Iteration + }; + + private static readonly Dictionary DefaultDateFormats = + new() + { + [TagPlaceholder.Year] = "yyyy", + [TagPlaceholder.Month] = "MM", + [TagPlaceholder.Day] = "dd", + [TagPlaceholder.Date] = "yyyy-MM-dd" + }; + + private abstract record Segment; + + private sealed record LiteralSegment(string Text) : Segment; + + private sealed record PlaceholderSegment(TagPlaceholder Kind, string? DateFormat) : Segment; + + private sealed record OptionalSegment(List Children) : Segment; + + private readonly List _segments; + private readonly Regex _matcher; + + /// + /// The date placeholders in the order they appear, paired with the .NET format each one + /// renders with. Concatenating the captured text of all of them - and, separately, their + /// formats - reconstitutes one composite value/format pair that DateTime.ParseExact can + /// invert, which is how a date survives a round-trip through an arbitrary layout. + /// + private readonly List<(string GroupName, string DateFormat)> _dateGroups; + + private readonly HashSet _kinds; + + // Deliberately multi-digit and distinctive: values like 1.1.1 would round-trip even through an + // ambiguous format, hiding exactly the defect the self-check exists to find. A single-digit month + // beside a two-digit day exposes the same ambiguity in variable-width date specifiers. + private static readonly ThreePartVersion ProbeVersion = + new() { Major = 1, Minor = 23, Patch = 456, PrereleaseLabel = "rc.7" }; + + private static readonly DateTime ProbeDate = new(2026, 1, 12); + + private const int ProbeIteration = 89; + + /// + /// A two-digit year is only invertible inside a fixed 100-year window, and the invariant + /// culture's ends at 2049 - so a 2050 release using {year:yy} would render "50" and read back as + /// 1950, sorting below every existing release and taking the changelog range with it. Pinning + /// the window to 2000-2099 keeps two-digit years invertible for as long as they're plausible. + /// + private static readonly CultureInfo DateCulture = CreateDateCulture(); + + private static CultureInfo CreateDateCulture() + { + var culture = (CultureInfo)CultureInfo.InvariantCulture.Clone(); + culture.DateTimeFormat.Calendar.TwoDigitYearMax = 2099; + return culture; + } + + public string Format { get; } + + public TagFormatFamily Family { get; } + + /// + /// Whether the format can represent a second-or-later release of the same version/date. If it + /// can't, a collision has nowhere to go and is reported as an error rather than silently + /// producing a duplicate tag. + /// + public bool SupportsIteration { get; } + + private VersionTagFormat( + string format, + List segments, + TagFormatFamily family, + bool supportsIteration, + Regex matcher, + List<(string GroupName, string DateFormat)> dateGroups, + HashSet kinds) + { + _kinds = kinds; + Format = format; + _segments = segments; + Family = family; + SupportsIteration = supportsIteration; + _matcher = matcher; + _dateGroups = dateGroups; + } + + public static VersionTagFormat Parse(string format, string settingName) + { + if (string.IsNullOrWhiteSpace(format)) + throw new InvalidUserConfigurationException($"'{settingName}' cannot be empty."); + + var segments = Tokenize(format, settingName); + var placeholders = Flatten(segments) + .OfType() + .ToList(); + + var duplicate = placeholders + .GroupBy(placeholder => placeholder.Kind) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + throw new InvalidUserConfigurationException( + $"'{settingName}' uses the '{{{duplicate.Key.ToString().ToLowerInvariant()}}}' placeholder more than once in '{format}'. " + + "Each placeholder may appear at most once, so that a tag can be read back unambiguously."); + + var kinds = placeholders.Select(placeholder => placeholder.Kind).ToHashSet(); + var hasSemver = SemverPlaceholders.Any(kinds.Contains); + var hasDate = DatePlaceholders.Any(kinds.Contains); + + if (hasSemver && hasDate) + throw new InvalidUserConfigurationException( + $"'{settingName}' mixes version and date placeholders in '{format}'. A format may use " + + "{major}/{minor}/{patch}/{prerelease} or {year}/{month}/{day}/{date}, but not both - the two " + + "sort orders disagree whenever a release isn't strictly linear (e.g. a backport), leaving no " + + "single correct answer for which release was the most recent."); + + if (!hasSemver && !hasDate) + throw new InvalidUserConfigurationException( + $"'{settingName}' has no version or date placeholder in '{format}'. Add either a full " + + "{major}.{minor}.{patch} or a date ({date}, or all of {year}/{month}/{day}) so releases can be ordered."); + + TagFormatFamily family; + if (hasSemver) + { + var missing = new[] { TagPlaceholder.Major, TagPlaceholder.Minor, TagPlaceholder.Patch } + .Where(kind => !kinds.Contains(kind)) + .Select(kind => $"{{{kind.ToString().ToLowerInvariant()}}}") + .ToList(); + if (missing.Count > 0) + throw new InvalidUserConfigurationException( + $"'{settingName}' is missing {string.Join(", ", missing)} in '{format}'. A version-based format " + + "needs all of {major}, {minor} and {patch} - a partial version would collide on every release " + + "that only changes an omitted component."); + + family = TagFormatFamily.Semver; + } + else + { + // {date} is the shorthand for a whole date, so pairing it with individual components + // would build a composite parse format with the same specifier repeated (e.g. + // "yyyy-MM-dd" + "yyyy"), which renders fine but can't be read back - the tag would + // silently drop out of release history. + var components = new[] { TagPlaceholder.Year, TagPlaceholder.Month, TagPlaceholder.Day } + .Where(kinds.Contains) + .Select(kind => $"{{{kind.ToString().ToLowerInvariant()}}}") + .ToList(); + if (kinds.Contains(TagPlaceholder.Date) && components.Count > 0) + throw new InvalidUserConfigurationException( + $"'{settingName}' combines {{date}} with {string.Join(", ", components)} in '{format}'. " + + "{date} already covers the whole date - use either {date} on its own (optionally with a " + + "format, e.g. {date:yyyyMMdd}) or the individual components."); + + if (!kinds.Contains(TagPlaceholder.Date) && + !(kinds.Contains(TagPlaceholder.Year) && kinds.Contains(TagPlaceholder.Month) && kinds.Contains(TagPlaceholder.Day))) + throw new InvalidUserConfigurationException( + $"'{settingName}' has an incomplete date in '{format}'. Use {{date}}, or all of {{year}}, " + + "{month} and {day} - a partial date can't order releases."); + + family = TagFormatFamily.Date; + } + + var dateGroups = placeholders + .Where(placeholder => DatePlaceholders.Contains(placeholder.Kind)) + .Select(placeholder => ( + GroupName: GroupName(placeholder.Kind), + DateFormat: placeholder.DateFormat ?? DefaultDateFormats[placeholder.Kind])) + .ToList(); + + if (family == TagFormatFamily.Date) + { + // Guards against a format that looks complete but resolves to less than day + // granularity - {date:yyyy} satisfies "has {date}" while still collapsing every + // release in a year onto one tag. + var composite = string.Concat(dateGroups.Select(group => group.DateFormat)); + var missingParts = new List(); + if (!composite.Contains('y')) missingParts.Add("year"); + if (!composite.Contains('M')) missingParts.Add("month"); + if (!composite.Contains('d')) missingParts.Add("day"); + if (missingParts.Count > 0) + throw new InvalidUserConfigurationException( + $"'{settingName}' resolves to a date with no {string.Join(" or ", missingParts)} component in " + + $"'{format}'. The date placeholders must together specify a year, month and day."); + } + + var matcher = new Regex(BuildPattern(segments), RegexOptions.CultureInvariant); + + var tagFormat = new VersionTagFormat( + format, + segments, + family, + kinds.Contains(TagPlaceholder.Iteration), + matcher, + dateGroups, + kinds); + + tagFormat.EnsureRoundTrips(settingName); + + return tagFormat; + } + + /// + /// Proves the format is invertible: what it renders has to read back as the same components. + /// Checked by construction rather than by enumerating the ways a format can be ambiguous, since + /// that list is easy to get wrong - adjacent variable-width placeholders are one case + /// ({major}{minor}{patch} renders 1.23.456 as "123456", which reads back as 1234.5.6), + /// and a format that can't be read back silently drops its own tags out of release history. + /// + private void EnsureRoundTrips(string settingName) + { + var probe = Render(ProbeVersion, ProbeDate, ProbeIteration); + + string? problem = null; + + if (!TryParseTag(probe, out var readBack)) + { + problem = "cannot be read back at all"; + } + else if (_kinds.Contains(TagPlaceholder.Major) && + (readBack!.Version?.Major != ProbeVersion.Major || + readBack.Version?.Minor != ProbeVersion.Minor || + readBack.Version?.Patch != ProbeVersion.Patch)) + { + problem = $"reads back as version {readBack!.Version?.Major}.{readBack.Version?.Minor}.{readBack.Version?.Patch} " + + $"instead of {ProbeVersion.Major}.{ProbeVersion.Minor}.{ProbeVersion.Patch}"; + } + else if (_kinds.Contains(TagPlaceholder.Prerelease) && + readBack!.Version?.PrereleaseLabel != ProbeVersion.PrereleaseLabel) + { + problem = $"reads back the prerelease as '{readBack!.Version?.PrereleaseLabel}' instead of '{ProbeVersion.PrereleaseLabel}'"; + } + else if (Family == TagFormatFamily.Date && readBack!.Date != ProbeDate) + { + problem = $"reads back as the date {readBack!.Date:yyyy-MM-dd} instead of {ProbeDate:yyyy-MM-dd}"; + } + else if (_kinds.Contains(TagPlaceholder.Iteration) && readBack!.Iteration != ProbeIteration) + { + problem = $"reads back the iteration as {readBack!.Iteration} instead of {ProbeIteration}"; + } + + if (problem is not null) + throw new InvalidUserConfigurationException( + $"'{settingName}' ('{Format}') is ambiguous: it renders '{probe}', which {problem}. " + + "Separate adjacent placeholders with literal text (e.g. '.' or '-'), or use fixed-width " + + "date specifiers, so a rendered value can be read back unchanged."); + } + + /// + /// Renders the tag for a release. is 1 for the first release of a + /// given version/date and increments only to break a collision. + /// + public string Render(ThreePartVersion? version, DateTime date, int iteration) + { + if (Family == TagFormatFamily.Semver && version is null) + throw new InvalidVersionTagException( + $"The format '{Format}' needs a project version to render, but none was available."); + + var builder = new StringBuilder(); + RenderSegments(_segments, version, date, iteration, builder); + return builder.ToString(); + } + + /// + /// The inverse of - recovers a tag's ordering components, or returns false + /// for any tag this format didn't produce (which is how tags left over from a previous format, + /// or unrelated tags in the repo, get excluded from release history). + /// + public bool TryParseTag(string tag, out VersionTag? parsed) + { + parsed = null; + + var match = _matcher.Match(tag); + if (!match.Success) + return false; + + var iteration = 1; + var iterationGroup = match.Groups[GroupName(TagPlaceholder.Iteration)]; + if (iterationGroup.Success && !int.TryParse(iterationGroup.Value, out iteration)) + return false; + + ThreePartVersion? version = null; + DateTime? date = null; + + if (Family == TagFormatFamily.Semver) + { + if (!int.TryParse(match.Groups[GroupName(TagPlaceholder.Major)].Value, out var major) || + !int.TryParse(match.Groups[GroupName(TagPlaceholder.Minor)].Value, out var minor) || + !int.TryParse(match.Groups[GroupName(TagPlaceholder.Patch)].Value, out var patch)) + return false; + + var prereleaseGroup = match.Groups[GroupName(TagPlaceholder.Prerelease)]; + + version = new ThreePartVersion + { + Major = major, + Minor = minor, + Patch = patch, + PrereleaseLabel = prereleaseGroup.Success && !string.IsNullOrEmpty(prereleaseGroup.Value) + ? prereleaseGroup.Value + : null + }; + } + else + { + var compositeFormat = string.Concat(_dateGroups.Select(group => group.DateFormat)); + var compositeValue = string.Concat(_dateGroups.Select(group => match.Groups[group.GroupName].Value)); + + if (!DateTime.TryParseExact( + compositeValue, + compositeFormat, + DateCulture, + DateTimeStyles.None, + out var parsedDate)) + return false; + + date = parsedDate; + } + + parsed = new VersionTag(tag, Family, version, date, iteration); + return true; + } + + private void RenderSegments( + IEnumerable segments, + ThreePartVersion? version, + DateTime date, + int iteration, + StringBuilder builder) + { + foreach (var segment in segments) + { + switch (segment) + { + case LiteralSegment literal: + builder.Append(literal.Text); + break; + + case PlaceholderSegment placeholder: + builder.Append(RenderPlaceholder(placeholder, version, date, iteration)); + break; + + case OptionalSegment optional: + if (Flatten(optional.Children) + .OfType() + .All(child => HasValue(child.Kind, version, iteration))) + RenderSegments(optional.Children, version, date, iteration, builder); + break; + } + } + } + + private static bool HasValue(TagPlaceholder kind, ThreePartVersion? version, int iteration) => + kind switch + { + TagPlaceholder.Prerelease => !string.IsNullOrEmpty(version?.PrereleaseLabel), + TagPlaceholder.Iteration => iteration > 1, + _ => true + }; + + private static string RenderPlaceholder( + PlaceholderSegment placeholder, + ThreePartVersion? version, + DateTime date, + int iteration) + { + switch (placeholder.Kind) + { + case TagPlaceholder.Major: + return version!.Major.ToString(CultureInfo.InvariantCulture); + case TagPlaceholder.Minor: + return version!.Minor.ToString(CultureInfo.InvariantCulture); + case TagPlaceholder.Patch: + return version!.Patch.ToString(CultureInfo.InvariantCulture); + case TagPlaceholder.Prerelease: + return version?.PrereleaseLabel ?? string.Empty; + case TagPlaceholder.Iteration: + return iteration.ToString(CultureInfo.InvariantCulture); + default: + var format = placeholder.DateFormat ?? DefaultDateFormats[placeholder.Kind]; + // A one-character custom format string would otherwise be read as one of .NET's + // standard date patterns ("d" meaning short-date, not day-of-month); "%" forces + // the custom-specifier reading. + return date.ToString(format.Length == 1 ? $"%{format}" : format, DateCulture); + } + } + + private static List Tokenize(string format, string settingName) + { + var root = new List(); + List? optional = null; + var literal = new StringBuilder(); + + void FlushLiteral() + { + if (literal.Length == 0) + return; + + (optional ?? root).Add(new LiteralSegment(literal.ToString())); + literal.Clear(); + } + + var index = 0; + while (index < format.Length) + { + var current = format[index]; + var next = index + 1 < format.Length ? format[index + 1] : '\0'; + + // Doubling is the escape for all four structural characters. + if ((current is '{' or '}' or '[' or ']') && next == current) + { + literal.Append(current); + index += 2; + continue; + } + + switch (current) + { + case '[': + if (optional is not null) + throw new InvalidUserConfigurationException( + $"'{settingName}' nests optional groups in '{format}'. Nested '[...]' groups aren't supported."); + FlushLiteral(); + optional = []; + index++; + continue; + + case ']': + if (optional is null) + throw new InvalidUserConfigurationException( + $"'{settingName}' has a ']' with no matching '[' in '{format}'."); + FlushLiteral(); + if (!optional.OfType().Any()) + throw new InvalidUserConfigurationException( + $"'{settingName}' has an optional group with no placeholder in '{format}'. A '[...]' group " + + "is elided based on whether the placeholders inside it have a value, so it needs at least one."); + root.Add(new OptionalSegment(optional)); + optional = null; + index++; + continue; + + case '}': + throw new InvalidUserConfigurationException( + $"'{settingName}' has a '}}' with no matching '{{' in '{format}'. Use '}}}}' for a literal '}}'."); + + case '{': + var close = format.IndexOf('}', index + 1); + if (close < 0) + throw new InvalidUserConfigurationException( + $"'{settingName}' has an unclosed '{{' in '{format}'."); + + FlushLiteral(); + + var token = format.Substring(index + 1, close - index - 1); + var separator = token.IndexOf(':'); + var name = separator < 0 ? token : token[..separator]; + var dateFormat = separator < 0 ? null : token[(separator + 1)..]; + + if (!PlaceholderNames.TryGetValue(name.Trim(), out var kind)) + throw new InvalidUserConfigurationException( + $"'{settingName}' uses an unknown placeholder '{{{token}}}' in '{format}'. Valid placeholders are " + + $"{string.Join(", ", PlaceholderNames.Keys.Select(key => $"{{{key}}}"))}."); + + if (separator >= 0) + { + // Not the same as omitting the suffix: .NET reads an empty format string as + // the general date/time pattern, which would render something this format + // could never read back. + if (string.IsNullOrEmpty(dateFormat)) + throw new InvalidUserConfigurationException( + $"'{settingName}' has an empty format after ':' in '{{{token}}}' in '{format}'. " + + $"Either give a date format (e.g. '{{{name}:yyyy-MM-dd}}') or drop the ':'."); + + if (!DatePlaceholders.Contains(kind)) + throw new InvalidUserConfigurationException( + $"'{settingName}' gives a format to '{{{name}}}' in '{format}', but only date placeholders " + + "({date}, {year}, {month}, {day}) accept one."); + + // Validated eagerly so a bad specifier is reported against the setting that + // contains it, rather than surfacing later as an unmatchable tag. + DateFormatToPattern(dateFormat, name, settingName, format); + } + + if (optional is not null && !OptionalCapablePlaceholders.Contains(kind)) + throw new InvalidUserConfigurationException( + $"'{settingName}' puts '{{{name}}}' inside an optional group in '{format}'. Only " + + $"{string.Join(" and ", OptionalCapablePlaceholders.Select(placeholder => $"{{{placeholder.ToString().ToLowerInvariant()}}}"))} " + + "can be absent, so only those are meaningful in a '[...]' group."); + + (optional ?? root).Add(new PlaceholderSegment(kind, dateFormat)); + index = close + 1; + continue; + + default: + literal.Append(current); + index++; + continue; + } + } + + if (optional is not null) + throw new InvalidUserConfigurationException( + $"'{settingName}' has a '[' with no matching ']' in '{format}'."); + + FlushLiteral(); + return root; + } + + private static IEnumerable Flatten(IEnumerable segments) + { + foreach (var segment in segments) + { + yield return segment; + + if (segment is OptionalSegment optional) + foreach (var child in Flatten(optional.Children)) + yield return child; + } + } + + private static string BuildPattern(IEnumerable segments) + { + var pattern = new StringBuilder("^"); + AppendPattern(segments, pattern); + pattern.Append('$'); + return pattern.ToString(); + } + + private static void AppendPattern(IEnumerable segments, StringBuilder pattern) + { + foreach (var segment in segments) + { + switch (segment) + { + case LiteralSegment literal: + pattern.Append(Regex.Escape(literal.Text)); + break; + + case PlaceholderSegment placeholder: + pattern.Append(PlaceholderPattern(placeholder)); + break; + + case OptionalSegment optional: + pattern.Append("(?:"); + AppendPattern(optional.Children, pattern); + pattern.Append(")?"); + break; + } + } + } + + private static string PlaceholderPattern(PlaceholderSegment placeholder) + { + var group = GroupName(placeholder.Kind); + + return placeholder.Kind switch + { + TagPlaceholder.Major or TagPlaceholder.Minor or TagPlaceholder.Patch or TagPlaceholder.Iteration => + $@"(?<{group}>\d+)", + // Lazy so that a trailing optional group still gets a chance to match: in + // "v1.4.0-beta.1-2" a greedy prerelease would swallow "-2" and report iteration 1. + // Allowed to match nothing so that a {prerelease} placed outside an optional group - + // which renders as empty for a version that has no label - still reads back. + TagPlaceholder.Prerelease => + $"(?<{group}>[0-9A-Za-z.-]*?)", + _ => + $"(?<{group}>{DateFormatToPattern(placeholder.DateFormat ?? DefaultDateFormats[placeholder.Kind], placeholder.Kind.ToString().ToLowerInvariant(), string.Empty, string.Empty)})" + }; + } + + /// + /// Turns a .NET date format into a regex that matches what it renders, so a laid-out date can + /// be found inside a tag. Deliberately covers a bounded set of specifiers and rejects the rest + /// outright - a specifier that renders but can't be matched back would silently drop tags from + /// release history. + /// + private static string DateFormatToPattern(string dateFormat, string placeholderName, string settingName, string format) + { + var pattern = new StringBuilder(); + var index = 0; + + while (index < dateFormat.Length) + { + var current = dateFormat[index]; + var run = 1; + while (index + run < dateFormat.Length && dateFormat[index + run] == current) + run++; + + var token = new string(current, run); + + switch (token) + { + case "yyyy": + pattern.Append(@"\d{4}"); + break; + case "yy": + pattern.Append(@"\d{2}"); + break; + case "MMMM": + pattern.Append("[A-Za-z]+"); + break; + case "MMM": + pattern.Append("[A-Za-z]{3}"); + break; + case "MM": + pattern.Append(@"\d{2}"); + break; + case "M": + pattern.Append(@"\d{1,2}"); + break; + case "dd": + pattern.Append(@"\d{2}"); + break; + case "d": + pattern.Append(@"\d{1,2}"); + break; + default: + if (current is '-' or '_' or '.' or '/' or ' ') + { + pattern.Append(Regex.Escape(token)); + break; + } + + throw new InvalidUserConfigurationException( + $"'{settingName}' uses an unsupported date specifier '{token}' in '{{{placeholderName}:{dateFormat}}}'" + + (string.IsNullOrEmpty(format) ? "" : $" in '{format}'") + + ". Supported specifiers are yyyy, yy, MMMM, MMM, MM, M, dd and d, plus the separators - _ . / and space."); + } + + index += run; + } + + return pattern.ToString(); + } + + private static string GroupName(TagPlaceholder kind) => kind.ToString().ToLowerInvariant(); +} diff --git a/src/AutoVer/Services/ConfigurationManager.cs b/src/AutoVer/Services/ConfigurationManager.cs index d8d3eee..5cd0dd0 100644 --- a/src/AutoVer/Services/ConfigurationManager.cs +++ b/src/AutoVer/Services/ConfigurationManager.cs @@ -125,9 +125,82 @@ public async Task RetrieveUserConfiguration(string? projectPa if (string.IsNullOrEmpty(userConfiguration.GitRoot)) throw new InvalidProjectException("The project path you have specified is not a valid git repository."); + ValidateTagFormats(userConfiguration); + return userConfiguration; } + public UserConfiguration? LoadRepositorySettings(string gitRoot) + { + var configPath = pathManager.Combine(gitRoot, ConfigurationConstants.ConfigFolderName, ConfigurationConstants.ConfigFileName); + if (!fileManager.Exists(configPath)) + return null; + + try + { + // Deliberately deserialized without UserConfigurationConverter: callers here only need + // repository-level settings, and must not trigger project-file loading (or the path + // validation that comes with it) as a side effect of reading one. + return JsonSerializer.Deserialize(fileManager.ReadAllText(configPath)); + } + catch (Exception ex) + { + throw new InvalidUserConfigurationException( + $"There was an issue loading the user configuration at '{configPath}'.", + ex); + } + } + + /// + /// Fails fast on a tag/release-name format that can't work, so a bad format is reported when the + /// configuration is read rather than part-way through a release. + /// + private void ValidateTagFormats(UserConfiguration userConfiguration) + { + var tagFormat = VersionTagFormat.Parse( + userConfiguration.EffectiveTagFormat, + nameof(UserConfiguration.TagFormat)); + var releaseNameFormat = VersionTagFormat.Parse( + userConfiguration.ResolveReleaseNameFormat(tagFormat.Family), + nameof(UserConfiguration.ReleaseNameFormat)); + + // Rendered with stand-in values purely to test the format's own literal text against git's + // ref grammar, using git itself rather than a hand-maintained copy of the rules. Done here, + // before any project file is written, so a format git would refuse doesn't leave the working + // tree half-updated. VersionHandler repeats the check on the real tag name, where an illegal + // character could still arrive through a placeholder value such as a prerelease label. + var probeTagName = tagFormat.Render( + new ThreePartVersion { Major = 1, Minor = 0, Patch = 0, PrereleaseLabel = "0" }, + new DateTime(2000, 1, 1), + 2); + if (!gitHandler.IsValidTagName(probeTagName)) + throw new InvalidUserConfigurationException( + $"'{nameof(UserConfiguration.TagFormat)}' ('{tagFormat.Format}') produces tag names git will not " + + $"accept, such as '{probeTagName}'. A tag name cannot contain a space, '~', '^', ':', '?', '*', " + + "'[', '\\', '..' or '@{', cannot end with '.' or '.lock', and cannot have an empty path segment."); + + // A release name is rendered from the components of the tag it describes, so it can only + // ask for components the tag actually carries - a date-based name has nothing to render + // from once the tag holds a version instead of a date. + if (tagFormat.Family != releaseNameFormat.Family) + throw new InvalidUserConfigurationException( + $"'{nameof(UserConfiguration.TagFormat)}' is {tagFormat.Family.ToString().ToLowerInvariant()}-based " + + $"('{tagFormat.Format}') while '{nameof(UserConfiguration.ReleaseNameFormat)}' is " + + $"{releaseNameFormat.Family.ToString().ToLowerInvariant()}-based ('{releaseNameFormat.Format}'). " + + "Both must use the same family of placeholders."); + + if (tagFormat.Family == TagFormatFamily.Semver && + !userConfiguration.UseSameVersionForAllProjects && + userConfiguration.Projects.Count > 1) + throw new InvalidUserConfigurationException( + $"'{nameof(UserConfiguration.TagFormat)}' is version-based ('{tagFormat.Format}') but this repository " + + $"has {userConfiguration.Projects.Count} projects that can hold different versions " + + $"('{nameof(UserConfiguration.UseSameVersionForAllProjects)}' is false), so there's no single version " + + "for the tag to represent. Either set 'UseSameVersionForAllProjects' to true, or use a date-based " + + $"'{nameof(UserConfiguration.TagFormat)}'."); + } + + public async Task ResetUserConfiguration(UserConfiguration userConfiguration, UserConfigurationResetRequest resetRequest) { var configPath = pathManager.Combine(userConfiguration.GitRoot, ConfigurationConstants.ConfigFolderName, ConfigurationConstants.ConfigFileName); diff --git a/src/AutoVer/Services/GitHandler.cs b/src/AutoVer/Services/GitHandler.cs index 6d5b4ba..581345c 100644 --- a/src/AutoVer/Services/GitHandler.cs +++ b/src/AutoVer/Services/GitHandler.cs @@ -98,6 +98,61 @@ public List GetTags(string gitRoot) return gitRepository.Tags.Select(x => x.FriendlyName).ToList(); } + public bool IsValidTagName(string tagName) => + !string.IsNullOrEmpty(tagName) && Reference.IsValidName($"refs/tags/{tagName}"); + + public List FindNearestReachableTags(string gitRoot, IReadOnlyCollection candidateTagNames) + { + if (candidateTagNames.Count == 0) + return []; + + using var gitRepository = new Repository(gitRoot); + + var head = gitRepository.Head.Tip; + if (head is null) + return []; + + var candidates = candidateTagNames.ToHashSet(StringComparer.Ordinal); + var tagsByCommit = new Dictionary>(StringComparer.Ordinal); + + foreach (var tag in gitRepository.Tags) + { + if (!candidates.Contains(tag.FriendlyName)) + continue; + + // PeeledTarget resolves an annotated tag through its annotation object to the commit + // itself; Target would be the annotation and would never match a commit sha. + var commitSha = (tag.PeeledTarget ?? tag.Target)?.Sha; + if (commitSha is null) + continue; + + if (!tagsByCommit.TryGetValue(commitSha, out var names)) + tagsByCommit[commitSha] = names = []; + + names.Add(tag.FriendlyName); + } + + if (tagsByCommit.Count == 0) + return []; + + // Walked newest-first from HEAD and stopped at the first tagged commit, so the result is the + // nearest release rather than the highest-sorting one - and the walk costs only the distance + // to that release, not the whole history. + var filter = new CommitFilter + { + IncludeReachableFrom = head, + SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time + }; + + foreach (var commit in gitRepository.Commits.QueryBy(filter)) + { + if (tagsByCommit.TryGetValue(commit.Sha, out var names)) + return names; + } + + return []; + } + public List GetVersionCommits(UserConfiguration userConfiguration, string? lastVersionTag = null) { using var gitRepository = new Repository(userConfiguration.GitRoot); diff --git a/src/AutoVer/Services/IConfigurationManager.cs b/src/AutoVer/Services/IConfigurationManager.cs index 743e1c0..156c90f 100644 --- a/src/AutoVer/Services/IConfigurationManager.cs +++ b/src/AutoVer/Services/IConfigurationManager.cs @@ -6,4 +6,11 @@ public interface IConfigurationManager { Task RetrieveUserConfiguration(string? projectPath, IncrementType incrementType, string? tagName = null); Task ResetUserConfiguration(UserConfiguration userConfiguration, UserConfigurationResetRequest resetRequest); + + /// + /// Reads only the repository-level settings from autover.json, without loading or validating + /// any project file. For the callers that need a setting (such as the tag format) before a + /// full configuration can be built. Returns null when the repository has no config file. + /// + UserConfiguration? LoadRepositorySettings(string gitRoot); } \ No newline at end of file diff --git a/src/AutoVer/Services/IGitHandler.cs b/src/AutoVer/Services/IGitHandler.cs index 03867f9..a18f544 100644 --- a/src/AutoVer/Services/IGitHandler.cs +++ b/src/AutoVer/Services/IGitHandler.cs @@ -11,6 +11,21 @@ public interface IGitHandler void CommitChanges(UserConfiguration userConfiguration, string commitMessage); void AddTag(UserConfiguration userConfiguration, string tagName); List GetTags(string gitRoot); + + /// + /// Whether git would accept as a tag. Defers to git's own ref + /// grammar rather than reimplementing it - the rules cover more than illegal characters + /// (no '..', no trailing '.', no '.lock' suffix, no '@{', no empty path segment). + /// + bool IsValidTagName(string tagName); + + /// + /// Of , the ones on the nearest commit reachable from HEAD + /// that carries any of them - i.e. the release the current working state belongs to, the same + /// notion as `git describe --tags --abbrev=0`. Empty when none is reachable (an unmerged branch, + /// or a shallow clone whose history doesn't reach one). + /// + List FindNearestReachableTags(string gitRoot, IReadOnlyCollection candidateTagNames); List GetVersionCommits(UserConfiguration userConfiguration, string? lastVersionTag = null); string? GetFileByTag(string gitRoot, string tagName, string filePath); List GetFolderByTag(string gitRoot, string tagName, string folderPath); diff --git a/src/AutoVer/Services/ProjectFiles/CsprojNuspecFileHandler.cs b/src/AutoVer/Services/ProjectFiles/CsprojNuspecFileHandler.cs index 76ab2a0..18cb1f3 100644 --- a/src/AutoVer/Services/ProjectFiles/CsprojNuspecFileHandler.cs +++ b/src/AutoVer/Services/ProjectFiles/CsprojNuspecFileHandler.cs @@ -63,6 +63,10 @@ public void UpdateVersion(ProjectDefinition projectDefinition, IncrementType inc } } + // Keep the in-memory definition in step with what was just written - a version-based tag + // format is rendered from this after every project has been updated. + projectDefinition.Version = versionTag.InnerText; + using var stream = fileManager.OpenWrite(projectDefinition.ProjectPath); xmlProjectFile.Save(stream); } diff --git a/src/AutoVer/Services/ProjectFiles/DockerfileFileHandler.cs b/src/AutoVer/Services/ProjectFiles/DockerfileFileHandler.cs index 19016f7..18b9781 100644 --- a/src/AutoVer/Services/ProjectFiles/DockerfileFileHandler.cs +++ b/src/AutoVer/Services/ProjectFiles/DockerfileFileHandler.cs @@ -83,6 +83,10 @@ public void UpdateVersion(ProjectDefinition projectDefinition, IncrementType inc $"{match.Groups["prefix"].Value}{newVersion}{match.Groups["suffix"].Value}"); } + // Keep the in-memory definition in step with what was just written - a version-based tag + // format is rendered from this after every project has been updated. + projectDefinition.Version = newVersion; + fileManager.WriteAllText(projectDefinition.ProjectPath, string.Join('\n', lines)); } diff --git a/src/AutoVer/Services/VersionHandler.cs b/src/AutoVer/Services/VersionHandler.cs index dd9511a..3b717a4 100644 --- a/src/AutoVer/Services/VersionHandler.cs +++ b/src/AutoVer/Services/VersionHandler.cs @@ -1,145 +1,240 @@ -using System.Globalization; using AutoVer.Exceptions; using AutoVer.Models; namespace AutoVer.Services; public class VersionHandler( - IGitHandler gitHandler) : IVersionHandler + IGitHandler gitHandler, + IConfigurationManager configurationManager, + IToolInteractiveService toolInteractiveService) : IVersionHandler { - private readonly DateTime _newVersion = DateTime.UtcNow; - private readonly Dictionary> _versionNumbersCache = new(); + private readonly DateTime _releaseDate = DateTime.UtcNow; + private readonly Dictionary> _versionTagsCache = new(); + private readonly Dictionary _formatCache = new(); + private readonly Dictionary _currentTagCache = new(); public string GetNewVersionTag(UserConfiguration configuration) { - var versionNumbers = GetVersionNumbers(configuration.GitRoot); - if (versionNumbers.Count == 0) - return $"release_{_newVersion:yyyy-MM-dd}"; - else - { - var currentVersion = versionNumbers[0]; - if ($"{_newVersion:yyyy-MM-dd}".Equals($"{currentVersion.Date:yyyy-MM-dd}")) - return $"release_{currentVersion.Date:yyyy-MM-dd}_{currentVersion.Count + 1}"; - else - return $"release_{_newVersion:yyyy-MM-dd}"; - } + var format = GetTagFormat(configuration); + var version = GetReleaseVersion(configuration); + var iteration = GetNextIteration(configuration, format, version); + + // Only a tag has to be unique, so this is enforced here rather than inside + // GetNextIteration: a release name that repeats the previous one is untidy at worst, and a + // run that isn't going to create a tag at all (--no-tag) shouldn't be blocked by it. + if (iteration > 1 && !format.SupportsIteration) + throw new InvalidVersionTagException( + $"The tag '{format.Render(version, _releaseDate, 1)}' already exists and the format '{format.Format}' " + + "has no {iteration} placeholder to distinguish a repeat release. Either increment the version " + + "before releasing again, or add an optional iteration group to 'TagFormat' (e.g. " + + $"'{format.Format}[-{{iteration}}]')."); + + var tagName = format.Render(version, _releaseDate, iteration); + + // Checked against git's own grammar rather than a hand-maintained copy of it, and against + // the rendered name rather than the format, so that an illegal character arriving through a + // placeholder value (a prerelease label, say) is caught too. VersionCommand resolves the tag + // name before it commits, so this fails while the repository is still untouched. + if (!gitHandler.IsValidTagName(tagName)) + throw new InvalidVersionTagException( + $"'{nameof(UserConfiguration.TagFormat)}' produced '{tagName}', which git will not accept as a tag " + + $"name. Check '{format.Format}' - a tag name cannot contain a space, '~', '^', ':', '?', '*', '[', " + + "'\\', '..' or '@{', cannot end with '.' or '.lock', and cannot have an empty path segment."); + + return tagName; } public string GetNewReleaseName(UserConfiguration configuration) { - var versionNumbers = GetVersionNumbers(configuration.GitRoot); - if (versionNumbers.Count == 0) - return $"Release {_newVersion:yyyy-MM-dd}"; - else - { - var currentVersion = versionNumbers[0]; - if ($"{_newVersion:yyyy-MM-dd}".Equals($"{currentVersion.Date:yyyy-MM-dd}")) - return $"Release {currentVersion.Date:yyyy-MM-dd} #{currentVersion.Count + 1}"; - else - return $"release_{_newVersion:yyyy-MM-dd}"; - } + var tagFormat = GetTagFormat(configuration); + var version = GetReleaseVersion(configuration); + + // The iteration is a property of the release itself, so it's derived from the tag format + // (the tag is what identifies a release) and then reused here. Deriving it from the + // release-name format instead would let a name drift out of step with its own tag. + return GetReleaseNameFormat(configuration) + .Render(version, _releaseDate, GetNextIteration(configuration, tagFormat, version)); } public string GetCurrentVersionTag(string projectPath) { var gitRoot = gitHandler.FindGitRootDirectory(projectPath); - var versionNumbers = GetVersionNumbers(gitRoot); - if (versionNumbers.Count == 0) - throw new InvalidVersionTagException($"The Git repository '{gitRoot}' does not have a valid version tag. Please run 'autover version' first."); - - var currentVersionDate = versionNumbers[0]; - return currentVersionDate.ToTagName(); - } - public string GetCurrentVersionTag(UserConfiguration configuration) - { - var versionNumbers = GetVersionNumbers(configuration.GitRoot); - if (versionNumbers.Count == 0) - throw new InvalidVersionTagException($"The Git repository '{configuration.GitRoot}' does not have a valid version tag. Please run 'autover version' first."); - - var currentVersionDate = versionNumbers[0]; - return currentVersionDate.ToTagName(); + // Reached before a full UserConfiguration exists: the changelog needs the current tag in + // order to load the configuration as it was *at* that tag, but finding the tag needs the + // tag format. Read just the repository-level settings from disk to break the cycle. + var settings = configurationManager.LoadRepositorySettings(gitRoot); + var format = GetFormat( + settings?.EffectiveTagFormat ?? UserConfiguration.DefaultTagFormat, + nameof(UserConfiguration.TagFormat)); + + return GetCurrentTag(gitRoot, format).Raw; } + public string GetCurrentVersionTag(UserConfiguration configuration) => + GetCurrentTag(configuration.GitRoot, GetTagFormat(configuration)).Raw; + public string GetCurrentReleaseName(UserConfiguration configuration) { - var versionNumbers = GetVersionNumbers(configuration.GitRoot); - if (versionNumbers.Count == 0) - throw new InvalidVersionTagException($"The Git repository '{configuration.GitRoot}' does not have a valid version tag. Please run 'autover version' first."); - - var currentVersionDate = versionNumbers[0]; - return currentVersionDate.ToReleaseName(); + var current = GetCurrentTag(configuration.GitRoot, GetTagFormat(configuration)); + + // Configuration validation keeps both formats in the same family, so a date-based name + // can only be asked for when the tag it describes actually carries a date. + return GetReleaseNameFormat(configuration) + .Render(current.Version, current.Date ?? _releaseDate, current.Iteration); } public string? GetLastVersionTag(UserConfiguration configuration) { - var versionNumbers = GetVersionNumbers(configuration.GitRoot); - if (versionNumbers.Count == 0) - throw new InvalidVersionTagException($"The Git repository '{configuration.GitRoot}' does not have a valid version tag. Please run 'autover version' first."); + var format = GetTagFormat(configuration); + var versionTags = GetVersionTags(configuration.GitRoot, format); + + if (versionTags.Count == 0) + throw new InvalidVersionTagException( + $"The Git repository '{configuration.GitRoot}' does not have a valid version tag. Please run 'autover version' first."); + + // The release before the current one, which isn't necessarily the second-newest overall: + // when the current release is a backport, the releases ordering above it belong to a newer + // line and the range has to start from the one below it instead. + var current = GetCurrentTag(configuration.GitRoot, format); + var currentIndex = versionTags.FindIndex(tag => tag.Raw.Equals(current.Raw, StringComparison.Ordinal)); + + return currentIndex >= 0 && currentIndex + 1 < versionTags.Count + ? versionTags[currentIndex + 1].Raw + : null; + } + + private VersionTag GetCurrentTag(string gitRoot, VersionTagFormat format) + { + // Generating a changelog asks for the current release several times over - its name, its + // tag, and the release before it - and resolving it walks history through a freshly opened + // repository each time. Cached because the answer can't change mid-command (a changelog + // commits only once every read is done), and because keeping the number of concurrently + // opened libgit2 repositories down matters: it races on its own lazy initialization, which + // is why the git-touching tests are serialized and retried. + var cacheKey = $"{gitRoot}\n{format.Format}"; + if (_currentTagCache.TryGetValue(cacheKey, out var cachedCurrentTag)) + return cachedCurrentTag; + + var versionTags = GetVersionTags(gitRoot, format); - if (versionNumbers.Count > 1) + if (versionTags.Count == 0) + throw new InvalidVersionTagException( + $"The Git repository '{gitRoot}' does not have a valid version tag. Please run 'autover version' first."); + + // The nearest release reachable from HEAD is the one the current working state belongs to. + // Highest-ordering is only an inference, and version ordering makes it the wrong one after a + // backport: with 2.0.0 released and 1.9.1 then cut from the older line, the highest tag isn't + // the release being described, and a changelog built for it covers the wrong range. Nearest + // rather than "on HEAD" because `autover changelog` commits the changelog itself, moving HEAD + // off the tagged commit before the release name and tag are read back. Date-based tags never + // showed any of this, since newest-by-date was always the release just cut. + var nearest = gitHandler + .FindNearestReachableTags(gitRoot, versionTags.Select(tag => tag.Raw).ToList()) + .ToHashSet(StringComparer.Ordinal); + + // versionTags is ordered newest-first, so this takes the highest of that commit's tags. + var currentTag = versionTags.FirstOrDefault(tag => nearest.Contains(tag.Raw)) ?? versionTags[0]; + + _currentTagCache[cacheKey] = currentTag; + return currentTag; + } + + /// + /// How many releases of this same version/date have already been tagged, plus one. 1 for the + /// first, which is the value at which an optional [{iteration}] group renders as nothing. + /// + private int GetNextIteration(UserConfiguration configuration, VersionTagFormat format, ThreePartVersion? version) + { + // Whether an existing tag belongs to the release being cut is decided by the format itself: + // re-render at that tag's iteration and compare the result to the tag verbatim. Comparing + // parsed components instead would be lossy - anything the format doesn't render is simply + // absent from the tag, so e.g. a format without {prerelease} renders both 1.0.1-beta and + // 1.0.1-rc as "v1.0.1". Those are different versions but the same tag, and treating them as + // distinct releases would render a name that already exists. + var iteration = 1; + + foreach (var tag in GetVersionTags(configuration.GitRoot, format)) { - var lastVersionDate = versionNumbers[1]; - return lastVersionDate.ToTagName(); + if (string.Equals(format.Render(version, _releaseDate, tag.Iteration), tag.Raw, StringComparison.Ordinal)) + iteration = Math.Max(iteration, tag.Iteration + 1); } - - return null; + + return iteration; } - private List GetVersionNumbers(string gitRoot) + /// + /// The version a version-based tag is built from. Validation rejects the configurations in which + /// several projects could disagree here, so taking the highest is a safety net rather than a + /// policy - it only ever sees projects that were just set to the same version. + /// + private static ThreePartVersion? GetReleaseVersion(UserConfiguration configuration) { - if (!_versionNumbersCache.TryGetValue(gitRoot, out var versionNumbers)) + ThreePartVersion? highest = null; + + foreach (var project in configuration.Projects.SelectMany(container => container.Projects)) { - var tags = gitHandler.GetTags(gitRoot); - versionNumbers = tags - .Where(x => x.StartsWith("release_")) - .Select(x => new VersionTag(x)) - .OrderByDescending(x => x.Date) - .ThenByDescending(x => x.Count) - .ToList(); - _versionNumbersCache[gitRoot] = versionNumbers; + if (!ThreePartVersion.TryParse(project.ProjectDefinition.Version, out var version)) + continue; + + if (highest is null || version > highest) + highest = version; } - return versionNumbers; + return highest; } - public class VersionTag + private List GetVersionTags(string gitRoot, VersionTagFormat format) { - public string Prefix { get; set; } - public DateTime Date { get; set; } - public int Count { get; set; } + var cacheKey = $"{gitRoot}\n{format.Format}"; + if (_versionTagsCache.TryGetValue(cacheKey, out var cached)) + return cached; + + var versionTags = new List(); + var skipped = 0; - public VersionTag(string tag) + foreach (var tag in gitHandler.GetTags(gitRoot)) { - var parts = tag.Split("_"); - if (parts.Length < 2) - throw new InvalidVersionTagException($"The tag '{tag}' is not supported."); - Prefix = parts[0]; - Date = DateTime.ParseExact(parts[1], "yyyy-MM-dd", CultureInfo.InvariantCulture); - if (parts.Length == 3) - Count = int.Parse(parts[2]); + if (format.TryParseTag(tag, out var parsed)) + versionTags.Add(parsed!); else - Count = 1; + skipped++; } - public string ToTagName() - { - if (Count > 1) - { - return $"{Prefix}_{Date:yyyy-MM-dd}_{Count}"; - } - - return $"{Prefix}_{Date:yyyy-MM-dd}"; - } + versionTags.Sort((left, right) => right.CompareTo(left)); - public string ToReleaseName() - { - if (Count > 1) - { - return $"Release {Date:yyyy-MM-dd} #{Count}"; - } - - return $"Release {Date:yyyy-MM-dd}"; - } + // A format change orphans every tag written under the old one, and AutoVer uses tag history + // to decide which commits a changelog covers - so silently seeing zero tags would quietly + // produce a changelog for the wrong range. Only worth saying when nothing matched at all: + // a repository that has matching tags alongside unrelated ones (a hand-made tag, a tag from + // before it adopted AutoVer) is working exactly as intended and shouldn't be nagged on every + // run. Goes to stderr because stdout carries values meant for shell capture, e.g. + // TAG=$(autover changelog --tag-name). + if (skipped > 0 && versionTags.Count == 0) + toolInteractiveService.WriteErrorLine( + $"Warning: ignored {skipped} tag(s) that don't match the configured tag format '{format.Format}', " + + "and found no release history. This release will be treated as the repository's first."); + + _versionTagsCache[cacheKey] = versionTags; + return versionTags; + } + + private VersionTagFormat GetTagFormat(UserConfiguration configuration) => + GetFormat(configuration.EffectiveTagFormat, nameof(UserConfiguration.TagFormat)); + + private VersionTagFormat GetReleaseNameFormat(UserConfiguration configuration) => + GetFormat( + configuration.ResolveReleaseNameFormat(GetTagFormat(configuration).Family), + nameof(UserConfiguration.ReleaseNameFormat)); + + private VersionTagFormat GetFormat(string format, string settingName) + { + var cacheKey = $"{settingName}\n{format}"; + if (_formatCache.TryGetValue(cacheKey, out var cached)) + return cached; + + var parsed = VersionTagFormat.Parse(format, settingName); + _formatCache[cacheKey] = parsed; + return parsed; } -} \ No newline at end of file +} diff --git a/test/AutoVer.IntegrationTests/TagFormatCommandTests.cs b/test/AutoVer.IntegrationTests/TagFormatCommandTests.cs new file mode 100644 index 0000000..b9d5fe4 --- /dev/null +++ b/test/AutoVer.IntegrationTests/TagFormatCommandTests.cs @@ -0,0 +1,861 @@ +using AutoVer.Constants; +using AutoVer.IntegrationTests.Utilities; +using AutoVer.Models; +using LibGit2Sharp; + +namespace AutoVer.IntegrationTests; + +/// +/// End-to-end coverage for a configurable TagFormat/ReleaseNameFormat: a repository whose releases +/// are identified by version rather than by date, which is what lets a consumer pin an immutable +/// semver ref (e.g. a shared CI templates repo included at `ref: v1.4.0`). +/// +[Retry(3)] +public class TagFormatCommandTests +{ + private const string SemverTagFormat = "v{major}.{minor}.{patch}[-{iteration}]"; + private const string SemverReleaseNameFormat = "Release {major}.{minor}.{patch}[ #{iteration}]"; + + private string _tempDir = string.Empty; + + [Before(Test)] + public void Before() + { + _tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_tempDir); + Repository.Init(_tempDir); + using var repo = new Repository(_tempDir); + _tempDir = repo.Info.WorkingDirectory; + IOUtilities.AddGitignore(_tempDir); + } + + [After(Test)] + public void After() + { + try + { + if (!string.IsNullOrEmpty(_tempDir) && Directory.Exists(_tempDir)) + { + IOUtilities.RemoveReadOnly(_tempDir); + Directory.Delete(_tempDir, true); + } + } + catch (Exception ex) + { + Assert.Fail(ex.Message); + } + } + + [Test] + public async Task SemverTagFormat_TagsAndNamesTheReleaseByVersion() + { + var csprojPath = await SetUpSingleProject(SemverTagFormat, SemverReleaseNameFormat); + await AddChangeFile(IncrementType.Minor, "A new feature"); + + var exitCode = await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(await IOUtilities.GetProjectVersion(csprojPath)).IsEqualTo("1.1.0"); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).Contains("v1.1.0"); + await Assert.That(GitUtilities.GetLastCommitMessage(_tempDir)).IsEqualTo("Release 1.1.0"); + } + + // Successive releases are distinguished by the version itself, so the iteration group stays + // elided - the date-based default's "_2 for the second release today" has no analogue here. + [Test] + public async Task SemverTagFormat_SuccessiveReleases_AreDistinguishedByVersionNotIteration() + { + await SetUpSingleProject(SemverTagFormat, SemverReleaseNameFormat); + + await AddChangeFile(IncrementType.Patch, "First fix"); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + await AddChangeFile(IncrementType.Patch, "Second fix"); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + var tags = GitUtilities.GetAllTags(_tempDir); + await Assert.That(tags).Contains("v1.0.1"); + await Assert.That(tags).Contains("v1.0.2"); + } + + // Re-releasing the same version has nowhere to go except the iteration group. + [Test] + public async Task SemverTagFormat_RepeatOfTheSameVersion_FallsBackToTheIteration() + { + await SetUpSingleProject(SemverTagFormat, SemverReleaseNameFormat); + + await Assert.That(await AutoVerUtilities.InitializeApp() + .Run(["version", "--project-path", _tempDir, "--use-version", "1.0.0"])) + .IsEqualTo(CommandReturnCodes.Success); + await Assert.That(await AutoVerUtilities.InitializeApp() + .Run(["version", "--project-path", _tempDir, "--use-version", "1.0.0"])) + .IsEqualTo(CommandReturnCodes.Success); + + var tags = GitUtilities.GetAllTags(_tempDir); + await Assert.That(tags).Contains("v1.0.0"); + await Assert.That(tags).Contains("v1.0.0-2"); + } + + [Test] + public async Task SemverTagFormat_WithoutIterationGroup_RepeatVersionFailsCleanly() + { + await SetUpSingleProject("v{major}.{minor}.{patch}", SemverReleaseNameFormat); + + await Assert.That(await AutoVerUtilities.InitializeApp() + .Run(["version", "--project-path", _tempDir, "--use-version", "1.0.0"])) + .IsEqualTo(CommandReturnCodes.Success); + + var (exitCode, _, error) = await AutoVerUtilities.RunCapturingOutput( + ["version", "--project-path", _tempDir, "--use-version", "1.0.0"]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.UserError); + await Assert.That(error).Contains("v1.0.0"); + await Assert.That(error).Contains("iteration"); + await Assert.That(error).DoesNotContain("at AutoVer."); + } + + [Test] + public async Task CustomDateFormat_IsHonoredWhenTagging() + { + await SetUpSingleProject("release_{date:yyyyMMdd}[_{iteration}]", "Release {date:yyyyMMdd}[ #{iteration}]"); + await AddChangeFile(IncrementType.Patch, "A fix"); + + var exitCode = await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).Contains($"release_{DateTime.UtcNow:yyyyMMdd}"); + } + + [Test] + public async Task InvalidTagFormat_FailsCleanlyWithUserError() + { + await SetUpSingleProject("v{major}.{minor}", SemverReleaseNameFormat); + + var (exitCode, _, error) = await AutoVerUtilities.RunCapturingOutput(["version", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.UserError); + await Assert.That(error).Contains("TagFormat"); + await Assert.That(error).DoesNotContain("at AutoVer."); + } + + [Test] + public async Task TagAndReleaseNameFormatsFromDifferentFamilies_FailCleanlyWithUserError() + { + await SetUpSingleProject(SemverTagFormat, "Release {date}"); + + var (exitCode, _, error) = await AutoVerUtilities.RunCapturingOutput(["version", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.UserError); + await Assert.That(error).Contains("ReleaseNameFormat"); + await Assert.That(error).DoesNotContain("at AutoVer."); + } + + // A version-based tag has no single version to represent once projects can drift apart. + [Test] + public async Task SemverTagFormat_WithIndependentlyVersionedProjects_FailsCleanlyWithUserError() + { + await IOUtilities.CreateProject(_tempDir, "src", "Project1"); + await IOUtilities.SetProjectVersion(Path.Combine(_tempDir, "src", "Project1", "Project1.csproj"), "1.0.0"); + await IOUtilities.CreateProject(_tempDir, "src", "Project2"); + await IOUtilities.SetProjectVersion(Path.Combine(_tempDir, "src", "Project2", "Project2.csproj"), "2.0.0"); + + await IOUtilities.AddAutoVerFile(_tempDir, + $@"{{ + ""Projects"": [ + {{ ""Name"": ""Project1"", ""Path"": ""src/Project1/Project1.csproj"" }}, + {{ ""Name"": ""Project2"", ""Path"": ""src/Project2/Project2.csproj"" }} + ], + ""UseCommitsForChangelog"": false, + ""UseSameVersionForAllProjects"": false, + ""ChangeFilesDetermineIncrementType"": true, + ""TagFormat"": ""{SemverTagFormat}"", + ""ReleaseNameFormat"": ""{SemverReleaseNameFormat}"" +}}"); + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Initial Commit"); + + var (exitCode, _, error) = await AutoVerUtilities.RunCapturingOutput(["version", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.UserError); + await Assert.That(error).Contains("UseSameVersionForAllProjects"); + await Assert.That(error).DoesNotContain("at AutoVer."); + } + + [Test] + public async Task Changelog_UsesTheConfiguredReleaseNameAndTag() + { + await SetUpSingleProject(SemverTagFormat, SemverReleaseNameFormat); + await AddChangeFile(IncrementType.Minor, "A new feature"); + + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + var (releaseNameExitCode, releaseName, _) = await AutoVerUtilities.RunCapturingOutput( + ["changelog", "--project-path", _tempDir, "--release-name"]); + await Assert.That(releaseNameExitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(releaseName.Trim()).IsEqualTo("Release 1.1.0"); + + var (tagNameExitCode, tagName, _) = await AutoVerUtilities.RunCapturingOutput( + ["changelog", "--project-path", _tempDir, "--tag-name"]); + await Assert.That(tagNameExitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(tagName.Trim()).IsEqualTo("v1.1.0"); + } + + // Unrelated tags are common (hand-made tags, tags predating AutoVer). They must neither be + // mistaken for release history nor provoke a warning - and above all must not reach stdout, + // which carries values meant for shell capture like TAG=$(autover changelog --tag-name). + [Test] + public async Task UnrelatedTags_AreIgnoredWithoutPollutingCapturedOutput() + { + await SetUpSingleProject(SemverTagFormat, SemverReleaseNameFormat); + using (var repo = new Repository(_tempDir)) + { + repo.ApplyTag("not-a-release"); + repo.ApplyTag("v1.0.0-rc1-extra"); + } + + await AddChangeFile(IncrementType.Minor, "A new feature"); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + var (exitCode, output, error) = await AutoVerUtilities.RunCapturingOutput( + ["changelog", "--project-path", _tempDir, "--tag-name"]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(output.Trim()).IsEqualTo("v1.1.0"); + await Assert.That(error).DoesNotContain("Warning"); + } + + // Regression: the first release on a *new* date in a repository that already had tags used to + // fall through to a tag-shaped release name ("release_2026-09-02") instead of the human-readable + // "Release 2026-09-02" every other path produced - visible in the release commit message and the + // GitHub/GitLab release title. Only the no-prior-tags and same-date paths had coverage before. + [Test] + public async Task DefaultFormats_FirstReleaseOnANewDate_UsesTheHumanReadableReleaseName() + { + await IOUtilities.CreateProject(_tempDir, "src", "Project1"); + await IOUtilities.SetProjectVersion(Path.Combine(_tempDir, "src", "Project1", "Project1.csproj"), "1.0.0"); + await IOUtilities.AddAutoVerFile(_tempDir, + @"{ + ""Projects"": [ + { ""Name"": ""Project1"", ""Path"": ""src/Project1/Project1.csproj"" } + ], + ""UseCommitsForChangelog"": false, + ""ChangeFilesDetermineIncrementType"": true +}"); + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Initial Commit"); + + // A prior release, on a date that can't be today. + using (var repo = new Repository(_tempDir)) + { + repo.ApplyTag("release_2020-01-01"); + } + + await AddChangeFile(IncrementType.Patch, "A fix"); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + await Assert.That(GitUtilities.GetLastCommitMessage(_tempDir)) + .IsEqualTo($"Release {DateTime.UtcNow:yyyy-MM-dd}"); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).Contains($"release_{DateTime.UtcNow:yyyy-MM-dd}"); + } + + // Switching an established repository's format orphans every tag written under the old one. + // The release still has to work, but AutoVer has lost the history it uses to bound a changelog + // range, so it says so on stderr rather than quietly treating the repo as brand new. + [Test] + public async Task ChangingFormatOnAnEstablishedRepo_StillReleasesAndWarnsAboutOrphanedTags() + { + await SetUpSingleProject(SemverTagFormat, SemverReleaseNameFormat); + using (var repo = new Repository(_tempDir)) + { + repo.ApplyTag("release_2020-01-01"); + repo.ApplyTag("release_2020-01-01_2"); + } + + await AddChangeFile(IncrementType.Patch, "A fix"); + var (exitCode, output, error) = await AutoVerUtilities.RunCapturingOutput(["version", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).Contains("v1.0.1"); + await Assert.That(error).Contains("ignored 2 tag(s)"); + await Assert.That(output).DoesNotContain("ignored"); + } + + // The release commit is created before the tag, so a format git would refuse has to be caught + // up front - otherwise the repository ends up with a version bump and a release commit but no + // tag, and a non-zero exit code to untangle. These cases go beyond illegal characters on + // purpose: git's ref grammar also forbids '..', a trailing '.', a '.lock' suffix, '@{' and an + // empty path segment, which is why the check defers to git rather than to a list kept here. + [Test] + [Arguments("v{major}.{minor}.{patch} rc")] + [Arguments("v{major}.{minor}.{patch}:{iteration}")] + [Arguments("v{major}.{minor}.{patch}^")] + [Arguments("v{major}.{minor}.{patch}[[{iteration}]]")] + [Arguments("v{major}.{minor}..{patch}")] + [Arguments("v{major}.{minor}.{patch}.")] + [Arguments("v{major}.{minor}.{patch}.lock")] + [Arguments("v{major}.{minor}.{patch}@{{{iteration}}}")] + [Arguments("v{major}.{minor}.{patch}//{iteration}")] + public async Task TagFormatGitWouldRefuse_FailsBeforeAnythingIsCommitted(string tagFormat) + { + await SetUpSingleProject(tagFormat, SemverReleaseNameFormat); + await AddChangeFile(IncrementType.Patch, "A fix"); + + var commitsBefore = GitUtilities.GetCommitCount(_tempDir); + var (exitCode, _, error) = await AutoVerUtilities.RunCapturingOutput(["version", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.UserError); + await Assert.That(error).Contains("TagFormat"); + await Assert.That(error).DoesNotContain("at AutoVer."); + await Assert.That(GitUtilities.GetCommitCount(_tempDir)).IsEqualTo(commitsBefore); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).IsEmpty(); + await Assert.That(await IOUtilities.GetProjectVersion( + Path.Combine(_tempDir, "src", "Project1", "Project1.csproj"))).IsEqualTo("1.0.0"); + } + + // A format that omits {prerelease} renders 1.0.1-beta and 1.0.1-rc as the same "v1.0.1". That + // has to be reported as a collision, even though the two versions aren't equal - the tag is what + // identifies a release, so whatever the format doesn't carry can't be used to tell them apart. + [Test] + public async Task SemverTagFormatOmittingPrerelease_TreatsIndistinguishableVersionsAsACollision() + { + // --use-version only takes effect when the increment type isn't derived from change files. + await SetUpSingleProject("v{major}.{minor}.{patch}", SemverReleaseNameFormat, + changeFilesDetermineIncrementType: false); + + await Assert.That(await AutoVerUtilities.InitializeApp() + .Run(["version", "--project-path", _tempDir, "--use-version", "1.0.1-beta"])) + .IsEqualTo(CommandReturnCodes.Success); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).Contains("v1.0.1"); + + var (exitCode, _, error) = await AutoVerUtilities.RunCapturingOutput( + ["version", "--project-path", _tempDir, "--use-version", "1.0.1-rc"]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.UserError); + await Assert.That(error).Contains("v1.0.1"); + await Assert.That(error).DoesNotContain("at AutoVer."); + } + + // A tag collision has to be detected before the release commit is made, not after it. + [Test] + public async Task CollidingTag_FailsBeforeAnythingIsCommitted() + { + await SetUpSingleProject("v{major}.{minor}.{patch}", SemverReleaseNameFormat, + changeFilesDetermineIncrementType: false); + + await Assert.That(await AutoVerUtilities.InitializeApp() + .Run(["version", "--project-path", _tempDir, "--use-version", "1.0.1"])) + .IsEqualTo(CommandReturnCodes.Success); + + var commitsBefore = GitUtilities.GetCommitCount(_tempDir); + var (exitCode, _, _) = await AutoVerUtilities.RunCapturingOutput( + ["version", "--project-path", _tempDir, "--use-version", "1.0.1"]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.UserError); + await Assert.That(GitUtilities.GetCommitCount(_tempDir)).IsEqualTo(commitsBefore); + } + + // --no-tag isn't going to create a tag, so a would-be tag collision must not block it. + [Test] + public async Task NoTag_IsNotBlockedByAWouldBeTagCollision() + { + await SetUpSingleProject("v{major}.{minor}.{patch}", SemverReleaseNameFormat, + changeFilesDetermineIncrementType: false); + + await Assert.That(await AutoVerUtilities.InitializeApp() + .Run(["version", "--project-path", _tempDir, "--use-version", "1.0.1"])) + .IsEqualTo(CommandReturnCodes.Success); + + var commitsBefore = GitUtilities.GetCommitCount(_tempDir); + var exitCode = await AutoVerUtilities.InitializeApp() + .Run(["version", "--project-path", _tempDir, "--use-version", "1.0.2", "--no-tag"]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(GitUtilities.GetCommitCount(_tempDir)).IsEqualTo(commitsBefore + 1); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).DoesNotContain("v1.0.2"); + } + + // The Dockerfile handler is the path the image repositories use. A version-based tag is rendered + // from the in-memory project definition after it's written, so this proves the tag carries the + // new version rather than the one the file held when it was loaded. + [Test] + public async Task SemverTagFormat_WithADockerfileProject_TagsTheNewVersion() + { + var dockerfilePath = await IOUtilities.CreateDockerfile(_tempDir); + await IOUtilities.SetDockerfileVersion(dockerfilePath, "1.0.0"); + + await IOUtilities.AddAutoVerFile(_tempDir, + $@"{{ + ""Projects"": [ + {{ ""Name"": ""Image"", ""Path"": ""Dockerfile"" }} + ], + ""UseCommitsForChangelog"": false, + ""ChangeFilesDetermineIncrementType"": true, + ""TagFormat"": ""{SemverTagFormat}"", + ""ReleaseNameFormat"": ""{SemverReleaseNameFormat}"" +}}"); + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Initial Commit"); + + var changeFilePath = await IOUtilities.AddChangeFile("Image", IncrementType.Minor, "A feature", _tempDir); + GitUtilities.StageChanges(_tempDir, changeFilePath); + GitUtilities.CommitChanges(_tempDir, "A feature"); + + var exitCode = await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(await IOUtilities.GetDockerfileVersion(dockerfilePath)).IsEqualTo("1.1.0"); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).Contains("v1.1.0"); + await Assert.That(GitUtilities.GetLastCommitMessage(_tempDir)).IsEqualTo("Release 1.1.0"); + } + + // Setting one option shouldn't require setting the other. TagFormat alone is the obvious first + // thing a user reaches for, and the release name should follow it rather than clashing with it. + [Test] + public async Task SemverTagFormatAlone_DerivesAMatchingReleaseName() + { + await IOUtilities.CreateProject(_tempDir, "src", "Project1"); + await IOUtilities.SetProjectVersion(Path.Combine(_tempDir, "src", "Project1", "Project1.csproj"), "1.0.0"); + await IOUtilities.AddAutoVerFile(_tempDir, + $@"{{ + ""Projects"": [ + {{ ""Name"": ""Project1"", ""Path"": ""src/Project1/Project1.csproj"" }} + ], + ""UseCommitsForChangelog"": false, + ""ChangeFilesDetermineIncrementType"": true, + ""TagFormat"": ""{SemverTagFormat}"" +}}"); + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Initial Commit"); + + await AddChangeFile(IncrementType.Minor, "A new feature"); + var (exitCode, _, error) = await AutoVerUtilities.RunCapturingOutput(["version", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(error).DoesNotContain("ReleaseNameFormat"); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).Contains("v1.1.0"); + await Assert.That(GitUtilities.GetLastCommitMessage(_tempDir)).IsEqualTo("Release 1.1.0"); + } + + // The commits-based changelog resolves the previous release by looking its tag up in the repo + // (Tags.First(...)), so the tag AutoVer reports as "last" has to be the tag verbatim. Bounds the + // range too: the second release's changelog must not re-list the first release's commits. + [Test] + public async Task CommitsBasedChangelog_WithSemverTags_CoversOnlyTheLatestRelease() + { + await IOUtilities.CreateProject(_tempDir, "src", "Project1"); + await IOUtilities.SetProjectVersion(Path.Combine(_tempDir, "src", "Project1", "Project1.csproj"), "1.0.0"); + await IOUtilities.AddAutoVerFile(_tempDir, + $@"{{ + ""Projects"": [ + {{ ""Name"": ""Project1"", ""Path"": ""src/Project1/Project1.csproj"" }} + ], + ""UseCommitsForChangelog"": true, + ""ChangeFilesDetermineIncrementType"": false, + ""TagFormat"": ""{SemverTagFormat}"" +}}"); + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Initial Commit"); + + var readmePath = Path.Combine(_tempDir, "README.md"); + await File.WriteAllTextAsync(readmePath, "# one"); + GitUtilities.StageChanges(_tempDir, readmePath); + GitUtilities.CommitChanges(_tempDir, "feat: the first feature"); + + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).Contains("v1.0.1"); + + await File.WriteAllTextAsync(readmePath, "# two"); + GitUtilities.StageChanges(_tempDir, readmePath); + GitUtilities.CommitChanges(_tempDir, "feat: the second feature"); + + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).Contains("v1.0.2"); + + var (exitCode, _, error) = await AutoVerUtilities.RunCapturingOutput(["changelog", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(error).DoesNotContain("at AutoVer."); + + var changelog = await IOUtilities.GetChangelog(_tempDir); + await Assert.That(changelog).Contains("the second feature"); + await Assert.That(changelog).DoesNotContain("the first feature"); + } + + // Version ordering answers "which release is highest", not "which release was just cut". After a + // backport those differ, and a changelog built for the highest tag would describe the wrong + // release and cover the wrong commit range. The tag on HEAD settles it. + [Test] + public async Task BackportRelease_IsReportedAsTheCurrentReleaseDespiteALowerVersion() + { + await SetUpSingleProject("v{major}.{minor}.{patch}", SemverReleaseNameFormat, + changeFilesDetermineIncrementType: false); + + foreach (var version in new[] { "1.9.0", "2.0.0", "1.9.1" }) + { + await Assert.That(await AutoVerUtilities.InitializeApp() + .Run(["version", "--project-path", _tempDir, "--use-version", version])) + .IsEqualTo(CommandReturnCodes.Success); + } + + var tags = GitUtilities.GetAllTags(_tempDir); + await Assert.That(tags).Contains("v2.0.0"); + await Assert.That(tags).Contains("v1.9.1"); + + var (tagExitCode, tagName, _) = await AutoVerUtilities.RunCapturingOutput( + ["changelog", "--project-path", _tempDir, "--tag-name"]); + await Assert.That(tagExitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(tagName.Trim()).IsEqualTo("v1.9.1"); + + var (nameExitCode, releaseName, _) = await AutoVerUtilities.RunCapturingOutput( + ["changelog", "--project-path", _tempDir, "--release-name"]); + await Assert.That(nameExitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(releaseName.Trim()).IsEqualTo("Release 1.9.1"); + } + + // With ChangeFilesDetermineIncrementType off, `version` rewrites autover.json to reset the + // increment type. Anything that rewrite drops would silently revert on the next release - so a + // configured format has to survive the round-trip, and the second release proves it did. + [Test] + public async Task ConfigRewrite_PreservesTheConfiguredFormats() + { + await SetUpSingleProject(SemverTagFormat, SemverReleaseNameFormat, + changeFilesDetermineIncrementType: false); + + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + var config = await File.ReadAllTextAsync( + Path.Combine(_tempDir, ConfigurationConstants.ConfigFolderName, ConfigurationConstants.ConfigFileName)); + await Assert.That(config).Contains(SemverTagFormat); + await Assert.That(config).Contains(SemverReleaseNameFormat); + // The computed helpers must not leak into the user's file. + await Assert.That(config).DoesNotContain("Effective"); + + // Still version-based on the release after the rewrite, rather than reverting to dates. + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + var tags = GitUtilities.GetAllTags(_tempDir); + await Assert.That(tags).Contains("v1.0.1"); + await Assert.That(tags).Contains("v1.0.2"); + } + + // Re-releasing the same version bumps only the iteration, and since nothing changed on disk + // there's no new commit - so both tags end up on the same commit. Picking "the current release" + // from among several tags on HEAD has to take the latest iteration, not an arbitrary one. + [Test] + public async Task SeveralTagsOnTheSameCommit_ReportTheLatestIterationAsCurrent() + { + await SetUpSingleProject(SemverTagFormat, SemverReleaseNameFormat, + changeFilesDetermineIncrementType: false); + + for (var i = 0; i < 2; i++) + { + await Assert.That(await AutoVerUtilities.InitializeApp() + .Run(["version", "--project-path", _tempDir, "--use-version", "1.0.0"])) + .IsEqualTo(CommandReturnCodes.Success); + } + + var tags = GitUtilities.GetAllTags(_tempDir); + await Assert.That(tags).Contains("v1.0.0"); + await Assert.That(tags).Contains("v1.0.0-2"); + + var (exitCode, tagName, _) = await AutoVerUtilities.RunCapturingOutput( + ["changelog", "--project-path", _tempDir, "--tag-name"]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(tagName.Trim()).IsEqualTo("v1.0.0-2"); + } + + // `change` is the command run most often, and it now loads a configuration that validates the + // tag formats. A configured format must not get in its way. + [Test] + public async Task ChangeCommand_WorksWithAConfiguredTagFormat() + { + await SetUpSingleProject(SemverTagFormat, SemverReleaseNameFormat); + + var (exitCode, _, error) = await AutoVerUtilities.RunCapturingOutput( + ["change", "--project-path", _tempDir, "--project-name", "Project1", + "--increment-type", "Minor", "-m", "A new feature"]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(error).DoesNotContain("at AutoVer."); + await Assert.That(IOUtilities.GetChangeFileCount(_tempDir)).IsEqualTo(1); + } + + // The real release flow is `version` then `changelog` - and `changelog` commits the CHANGELOG + // itself, moving HEAD off the tagged commit before the release name and tag get read back for the + // GitHub/GitLab release. So the current release has to be found by walking back from HEAD, not + // just by looking at HEAD, or a backport reverts to reporting the highest version. + [Test] + public async Task BackportRelease_IsStillCurrentAfterTheChangelogCommitMovesHead() + { + await SetUpSingleProject("v{major}.{minor}.{patch}", SemverReleaseNameFormat, + changeFilesDetermineIncrementType: false); + + foreach (var version in new[] { "2.0.0", "1.9.1" }) + { + await Assert.That(await AutoVerUtilities.InitializeApp() + .Run(["version", "--project-path", _tempDir, "--use-version", version])) + .IsEqualTo(CommandReturnCodes.Success); + } + + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["changelog", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + // The changelog commit is now HEAD, and it carries no tag. + await Assert.That(GitUtilities.GetLastCommitMessage(_tempDir)).IsEqualTo("Updated changelog"); + + var (exitCode, tagName, _) = await AutoVerUtilities.RunCapturingOutput( + ["changelog", "--project-path", _tempDir, "--tag-name"]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(tagName.Trim()).IsEqualTo("v1.9.1"); + } + + // The whole point of version-based tags: the release-branch workflow a shared CI templates repo + // would use. Cut a release branch, version + changelog on it, merge it back with a merge commit, + // then read the release name and tag from the merge commit - which is what the sync job does to + // create the GitHub/GitLab release. This is a branching topology none of the linear tests cover. + [Test] + public async Task ReleaseBranchMergedBack_ReportsTheReleaseFromTheMergeCommit() + { + await SetUpSingleProject(SemverTagFormat, SemverReleaseNameFormat); + var defaultBranch = GitUtilities.GetCurrentBranch(_tempDir); + + // An earlier release, so the repo has history to pick the wrong answer from. The changelog + // run matters: it consumes the change file, so the next release sees only its own. + await AddChangeFile(IncrementType.Minor, "An earlier feature"); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["changelog", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + await AddChangeFile(IncrementType.Patch, "A fix for the next release"); + + GitUtilities.CreateAndCheckoutBranch(_tempDir, "releases/next-release"); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["changelog", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + GitUtilities.CheckoutBranch(_tempDir, defaultBranch); + GitUtilities.MergeNoFastForward(_tempDir, "releases/next-release"); + + var tags = GitUtilities.GetAllTags(_tempDir); + await Assert.That(tags).Contains("v1.1.0"); + await Assert.That(tags).Contains("v1.1.1"); + + // Reading from the merge commit, exactly as the sync job does. + var (tagExitCode, tagName, tagError) = await AutoVerUtilities.RunCapturingOutput( + ["changelog", "--project-path", _tempDir, "--tag-name"]); + await Assert.That(tagExitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(tagError).DoesNotContain("at AutoVer."); + await Assert.That(tagName.Trim()).IsEqualTo("v1.1.1"); + + var (nameExitCode, releaseName, _) = await AutoVerUtilities.RunCapturingOutput( + ["changelog", "--project-path", _tempDir, "--release-name"]); + await Assert.That(nameExitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(releaseName.Trim()).IsEqualTo("Release 1.1.1"); + } + + // A prerelease label may contain hyphens. The tag is rendered from the parsed project version, + // so any part of the label lost in parsing would be baked into an immutable tag name. + [Test] + public async Task HyphenatedPrereleaseLabel_ReachesTheTagIntact() + { + await IOUtilities.CreateProject(_tempDir, "src", "Project1"); + await IOUtilities.SetProjectVersion(Path.Combine(_tempDir, "src", "Project1", "Project1.csproj"), "1.0.0"); + await IOUtilities.AddAutoVerFile(_tempDir, + @"{ + ""Projects"": [ + { + ""Name"": ""Project1"", + ""Path"": ""src/Project1/Project1.csproj"", + ""PrereleaseLabel"": ""alpha-1"" + } + ], + ""UseCommitsForChangelog"": false, + ""ChangeFilesDetermineIncrementType"": false, + ""TagFormat"": ""v{major}.{minor}.{patch}[-{prerelease}]"" +}"); + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Initial Commit"); + + var exitCode = await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + + var version = await IOUtilities.GetProjectVersion( + Path.Combine(_tempDir, "src", "Project1", "Project1.csproj")); + await Assert.That(version).Contains("alpha-1"); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).Contains($"v{version}"); + } + + // Migrating an established repo from the date-based default to version-based tags. The version + // lives in the project file, not the tag, so the release itself is unaffected; and with change + // files the changelog is built from those files rather than a commit range, so the orphaned + // date tags don't affect its contents either. + [Test] + public async Task SwitchingFromDateToSemver_ContinuesNormallyWithChangeFiles() + { + var csprojPath = await SetUpSingleProject( + UserConfiguration.DefaultTagFormat, UserConfiguration.DefaultReleaseNameFormat); + + await AddChangeFile(IncrementType.Patch, "The last date-tagged fix"); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["changelog", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).Contains($"release_{DateTime.UtcNow:yyyy-MM-dd}"); + + // The switch: same repo, same project file, new tag format. Committed, because `changelog` + // loads the configuration as of the release tag - an uncommitted switch would leave it + // reading the old format back and titling the release with a date. + await RewriteConfigWithFormats(SemverTagFormat, SemverReleaseNameFormat, commit: true); + + await AddChangeFile(IncrementType.Patch, "The first version-tagged fix"); + var (exitCode, _, error) = await AutoVerUtilities.RunCapturingOutput(["version", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(await IOUtilities.GetProjectVersion(csprojPath)).IsEqualTo("1.0.2"); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).Contains("v1.0.2"); + // Said once, on stderr, and only because no version-based history existed yet. + await Assert.That(error).Contains("ignored"); + + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["changelog", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + var changelog = await IOUtilities.GetChangelog(_tempDir); + await Assert.That(changelog).Contains("The first version-tagged fix"); + // The previous release's entry is still there - nothing was lost by the switch. + await Assert.That(changelog).Contains("The last date-tagged fix"); + + // From here on it's ordinary: the next release sees the previous version-based one. + await AddChangeFile(IncrementType.Patch, "A later fix"); + var (nextExitCode, _, nextError) = await AutoVerUtilities.RunCapturingOutput( + ["version", "--project-path", _tempDir]); + + await Assert.That(nextExitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).Contains("v1.0.3"); + // No longer warns, because version-based history now exists. + await Assert.That(nextError).DoesNotContain("ignored"); + } + + // The one thing the switch costs a commits-based changelog: with no version-based history, the + // first post-switch release has no previous release to bound its commit range, so it reaches back + // to the start of the repo. Tagging the previous release's commit with a version-based tag first + // bridges the gap and keeps the range correct. + [Test] + [Arguments(false, true)] + [Arguments(true, false)] + public async Task SwitchingFromDateToSemver_CommitsChangelogRangeNeedsABridgeTag( + bool addBridgeTag, + bool expectOldCommitInChangelog) + { + await IOUtilities.CreateProject(_tempDir, "src", "Project1"); + await IOUtilities.SetProjectVersion(Path.Combine(_tempDir, "src", "Project1", "Project1.csproj"), "1.0.0"); + await RewriteConfigWithFormats(UserConfiguration.DefaultTagFormat, UserConfiguration.DefaultReleaseNameFormat, + useCommitsForChangelog: true); + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Initial Commit"); + + var readmePath = Path.Combine(_tempDir, "README.md"); + await File.WriteAllTextAsync(readmePath, "# old"); + GitUtilities.StageChanges(_tempDir, readmePath); + GitUtilities.CommitChanges(_tempDir, "feat: the date-tagged feature"); + + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + var dateTag = $"release_{DateTime.UtcNow:yyyy-MM-dd}"; + await Assert.That(GitUtilities.GetAllTags(_tempDir)).Contains(dateTag); + + await RewriteConfigWithFormats(SemverTagFormat, SemverReleaseNameFormat, useCommitsForChangelog: true); + + if (addBridgeTag) + { + // Give the previous release a version-based name on the same commit. + using var repo = new Repository(_tempDir); + repo.ApplyTag("v1.0.1", repo.Tags[dateTag].Target.Sha); + } + + await File.WriteAllTextAsync(readmePath, "# new"); + GitUtilities.StageChanges(_tempDir, readmePath); + GitUtilities.CommitChanges(_tempDir, "feat: the version-tagged feature"); + + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["changelog", "--project-path", _tempDir])) + .IsEqualTo(CommandReturnCodes.Success); + + var changelog = await IOUtilities.GetChangelog(_tempDir); + await Assert.That(changelog).Contains("the version-tagged feature"); + await Assert.That(changelog.Contains("the date-tagged feature")).IsEqualTo(expectOldCommitInChangelog); + } + + private async Task RewriteConfigWithFormats( + string tagFormat, + string releaseNameFormat, + bool useCommitsForChangelog = false, + bool commit = false) + { + await IOUtilities.AddAutoVerFile(_tempDir, + $@"{{ + ""Projects"": [ + {{ ""Name"": ""Project1"", ""Path"": ""src/Project1/Project1.csproj"" }} + ], + ""UseCommitsForChangelog"": {useCommitsForChangelog.ToString().ToLowerInvariant()}, + ""ChangeFilesDetermineIncrementType"": {(!useCommitsForChangelog).ToString().ToLowerInvariant()}, + ""TagFormat"": ""{tagFormat}"", + ""ReleaseNameFormat"": ""{releaseNameFormat}"" +}}"); + + if (!commit) + return; + + GitUtilities.StageChanges(_tempDir, Path.Combine( + ConfigurationConstants.ConfigFolderName, ConfigurationConstants.ConfigFileName)); + GitUtilities.CommitChanges(_tempDir, "Switch to version-based tags"); + } + + private async Task SetUpSingleProject( + string tagFormat, + string releaseNameFormat, + bool changeFilesDetermineIncrementType = true) + { + await IOUtilities.CreateProject(_tempDir, "src", "Project1"); + var csprojPath = Path.Combine(_tempDir, "src", "Project1", "Project1.csproj"); + await IOUtilities.SetProjectVersion(csprojPath, "1.0.0"); + + await IOUtilities.AddAutoVerFile(_tempDir, + $@"{{ + ""Projects"": [ + {{ ""Name"": ""Project1"", ""Path"": ""src/Project1/Project1.csproj"" }} + ], + ""UseCommitsForChangelog"": false, + ""UseSameVersionForAllProjects"": false, + ""ChangeFilesDetermineIncrementType"": {changeFilesDetermineIncrementType.ToString().ToLowerInvariant()}, + ""TagFormat"": ""{tagFormat}"", + ""ReleaseNameFormat"": ""{releaseNameFormat}"" +}}"); + + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Initial Commit"); + + return csprojPath; + } + + private async Task AddChangeFile(IncrementType incrementType, string message) + { + var changeFilePath = await IOUtilities.AddChangeFile("Project1", incrementType, message, _tempDir); + GitUtilities.StageChanges(_tempDir, changeFilePath); + GitUtilities.CommitChanges(_tempDir, message); + } +} diff --git a/test/AutoVer.IntegrationTests/Utilities/GitUtilities.cs b/test/AutoVer.IntegrationTests/Utilities/GitUtilities.cs index 79143b8..d4e65a8 100644 --- a/test/AutoVer.IntegrationTests/Utilities/GitUtilities.cs +++ b/test/AutoVer.IntegrationTests/Utilities/GitUtilities.cs @@ -54,4 +54,37 @@ public static void CommitChanges(string gitRepositoryPath, string commitMessage) var signature = gitRepository.Config.BuildSignature(DateTimeOffset.Now); gitRepository.Commit(commitMessage, signature, signature); } + + public static string GetCurrentBranch(string gitRepositoryPath) + { + using var repo = new Repository(gitRepositoryPath); + return repo.Head.FriendlyName; + } + + public static void CreateAndCheckoutBranch(string gitRepositoryPath, string branchName) + { + using var repo = new Repository(gitRepositoryPath); + LibGit2Sharp.Commands.Checkout(repo, repo.CreateBranch(branchName)); + } + + public static void CheckoutBranch(string gitRepositoryPath, string branchName) + { + using var repo = new Repository(gitRepositoryPath); + LibGit2Sharp.Commands.Checkout(repo, repo.Branches[branchName]); + } + + /// + /// Merges into the current branch, always producing a merge commit + /// - the strategy the release workflow requires, and the one that makes the release's own commits + /// reachable from the target branch rather than replayed onto it. + /// + public static void MergeNoFastForward(string gitRepositoryPath, string branchName) + { + using var repo = new Repository(gitRepositoryPath); + var signature = repo.Config.BuildSignature(DateTimeOffset.Now); + repo.Merge(repo.Branches[branchName], signature, new MergeOptions + { + FastForwardStrategy = FastForwardStrategy.NoFastForward + }); + } } diff --git a/test/AutoVer.UnitTests/ProjectFiles/CsprojNuspecFileHandlerTest.cs b/test/AutoVer.UnitTests/ProjectFiles/CsprojNuspecFileHandlerTest.cs index b3dad15..4bfebc9 100644 --- a/test/AutoVer.UnitTests/ProjectFiles/CsprojNuspecFileHandlerTest.cs +++ b/test/AutoVer.UnitTests/ProjectFiles/CsprojNuspecFileHandlerTest.cs @@ -119,7 +119,9 @@ public async Task UpdateVersion_Csproj_DefaultPatchIncrement_PersistsToDisk() var definition = handler.Load(projectPath, await File.ReadAllTextAsync(projectPath)); handler.UpdateVersion(definition, IncrementType.Patch); - await Assert.That(definition.Version).IsEqualTo("1.2.3"); + // The in-memory definition tracks what was written rather than going stale - a + // version-based TagFormat is rendered from it once every project has been updated. + await Assert.That(definition.Version).IsEqualTo("1.2.4"); var reloaded = handler.Load(projectPath, await File.ReadAllTextAsync(projectPath)); await Assert.That(reloaded.Version).IsEqualTo("1.2.4"); diff --git a/test/AutoVer.UnitTests/ThreePartVersionTest.cs b/test/AutoVer.UnitTests/ThreePartVersionTest.cs new file mode 100644 index 0000000..7033d0e --- /dev/null +++ b/test/AutoVer.UnitTests/ThreePartVersionTest.cs @@ -0,0 +1,69 @@ +using AutoVer.Models; + +namespace AutoVer.UnitTests; + +public class ThreePartVersionTest +{ + private static ThreePartVersion Parse(string version) => ThreePartVersion.Parse(version); + + [Test] + [Arguments("1.0.0", "2.0.0")] + [Arguments("2.0.0", "2.1.0")] + [Arguments("2.1.0", "2.1.1")] + public async Task CompareTo_OrdersByMajorThenMinorThenPatch(string lower, string higher) + { + await Assert.That(Parse(lower).CompareTo(Parse(higher))).IsLessThan(0); + await Assert.That(Parse(higher).CompareTo(Parse(lower))).IsGreaterThan(0); + } + + // SemVer 2.0.0 §11. A plain ordinal string comparison gets the first two of these backwards, + // which previously made GetNextMaxVersion pick the wrong "highest" version. + [Test] + [Arguments("1.4.0-beta.2", "1.4.0-beta.10")] + [Arguments("1.4.0-beta.1", "1.4.0")] + [Arguments("1.0.0-alpha", "1.0.0-alpha.1")] + [Arguments("1.0.0-alpha.1", "1.0.0-alpha.beta")] + [Arguments("1.0.0-alpha.beta", "1.0.0-beta")] + [Arguments("1.0.0-beta", "1.0.0-beta.2")] + [Arguments("1.0.0-beta.2", "1.0.0-beta.11")] + [Arguments("1.0.0-beta.11", "1.0.0-rc.1")] + [Arguments("1.0.0-rc.1", "1.0.0")] + public async Task CompareTo_FollowsSemVerPrereleasePrecedence(string lower, string higher) + { + await Assert.That(Parse(lower).CompareTo(Parse(higher))).IsLessThan(0); + await Assert.That(Parse(higher).CompareTo(Parse(lower))).IsGreaterThan(0); + await Assert.That(Parse(lower) < Parse(higher)).IsTrue(); + await Assert.That(Parse(higher) > Parse(lower)).IsTrue(); + } + + [Test] + [Arguments("1.4.0")] + [Arguments("1.4.0-beta.1")] + public async Task CompareTo_IsZeroForEqualVersions(string version) + { + await Assert.That(Parse(version).CompareTo(Parse(version))).IsEqualTo(0); + } + + // A prerelease label may itself contain hyphens (SemVer: everything after the first '-'), and a + // version-based tag is rendered from this parse - so dropping part of the label would put the + // wrong name on an immutable tag. + [Test] + [Arguments("1.0.0", null)] + [Arguments("1.0.0-rc.1", "rc.1")] + [Arguments("1.0.0-alpha-1", "alpha-1")] + [Arguments("1.0.0-alpha.beta-2", "alpha.beta-2")] + public async Task Parse_KeepsTheWholePrereleaseLabel(string version, string? expectedLabel) + { + var parsed = Parse(version); + + await Assert.That(parsed.PrereleaseLabel).IsEqualTo(expectedLabel); + await Assert.That(parsed.ToString()).IsEqualTo(version); + } + + [Test] + public async Task CompareTo_TreatsNumericPrereleaseIdentifiersNumerically() + { + // Large enough that a digit-by-digit string comparison would disagree with the numeric one. + await Assert.That(Parse("1.0.0-9").CompareTo(Parse("1.0.0-100"))).IsLessThan(0); + } +} diff --git a/test/AutoVer.UnitTests/Utilities/GitUtilities.cs b/test/AutoVer.UnitTests/Utilities/GitUtilities.cs index e4394e0..ff881a7 100644 --- a/test/AutoVer.UnitTests/Utilities/GitUtilities.cs +++ b/test/AutoVer.UnitTests/Utilities/GitUtilities.cs @@ -1,22 +1,28 @@ -using LibGit2Sharp; -using static AutoVer.Services.VersionHandler; +using AutoVer.Models; +using LibGit2Sharp; namespace AutoVer.UnitTests.Utilities; internal static class GitUtilities { - public static string GetLastTag(string gitRepositoryPath) + public static string GetLastTag(string gitRepositoryPath, string? tagFormat = null) { + var format = VersionTagFormat.Parse( + tagFormat ?? UserConfiguration.DefaultTagFormat, + nameof(UserConfiguration.TagFormat)); + using (var repo = new Repository(gitRepositoryPath)) { - return repo.Tags - .Select(x => x.FriendlyName) - .Where(x => x.StartsWith("release_")) - .Select(x => new VersionTag(x)) - .OrderByDescending(x => x.Date) - .ThenByDescending(x => x.Count) - .FirstOrDefault()? - .ToTagName() ?? string.Empty; + var versionTags = new List(); + foreach (var friendlyName in repo.Tags.Select(tag => tag.FriendlyName)) + { + if (format.TryParseTag(friendlyName, out var parsed)) + versionTags.Add(parsed!); + } + + versionTags.Sort((left, right) => right.CompareTo(left)); + + return versionTags.Count > 0 ? versionTags[0].Raw : string.Empty; } } diff --git a/test/AutoVer.UnitTests/VersionTagFormatTest.cs b/test/AutoVer.UnitTests/VersionTagFormatTest.cs new file mode 100644 index 0000000..472a115 --- /dev/null +++ b/test/AutoVer.UnitTests/VersionTagFormatTest.cs @@ -0,0 +1,208 @@ +using AutoVer.Exceptions; +using AutoVer.Models; + +namespace AutoVer.UnitTests; + +public class VersionTagFormatTest +{ + private static readonly DateTime ReleaseDate = new(2026, 9, 2, 13, 45, 0, DateTimeKind.Utc); + + private static ThreePartVersion Version(int major, int minor, int patch, string? prereleaseLabel = null) => + new() { Major = major, Minor = minor, Patch = patch, PrereleaseLabel = prereleaseLabel }; + + private static VersionTagFormat Parse(string format) => VersionTagFormat.Parse(format, "TagFormat"); + + // The defaults have to keep producing byte-for-byte what AutoVer produced before the format + // was configurable, otherwise every repo already using it would silently start tagging + // differently on its next release. + [Test] + [Arguments(1, "release_2026-09-02")] + [Arguments(5, "release_2026-09-02_5")] + public async Task DefaultTagFormat_MatchesPreConfigurableBehavior(int iteration, string expected) + { + var format = Parse(UserConfiguration.DefaultTagFormat); + + await Assert.That(format.Render(null, ReleaseDate, iteration)).IsEqualTo(expected); + await Assert.That(format.Family).IsEqualTo(TagFormatFamily.Date); + } + + [Test] + [Arguments(1, "Release 2026-09-02")] + [Arguments(5, "Release 2026-09-02 #5")] + public async Task DefaultReleaseNameFormat_MatchesPreConfigurableBehavior(int iteration, string expected) + { + var format = VersionTagFormat.Parse(UserConfiguration.DefaultReleaseNameFormat, "ReleaseNameFormat"); + + await Assert.That(format.Render(null, ReleaseDate, iteration)).IsEqualTo(expected); + } + + [Test] + public async Task SemverFormat_OmitsOptionalGroupsWithoutValues() + { + var format = Parse("v{major}.{minor}.{patch}[-{prerelease}][-{iteration}]"); + + await Assert.That(format.Family).IsEqualTo(TagFormatFamily.Semver); + await Assert.That(format.Render(Version(1, 4, 0), ReleaseDate, 1)).IsEqualTo("v1.4.0"); + await Assert.That(format.Render(Version(1, 4, 0, "beta.1"), ReleaseDate, 1)).IsEqualTo("v1.4.0-beta.1"); + await Assert.That(format.Render(Version(1, 4, 0), ReleaseDate, 2)).IsEqualTo("v1.4.0-2"); + await Assert.That(format.Render(Version(1, 4, 0, "beta.1"), ReleaseDate, 3)).IsEqualTo("v1.4.0-beta.1-3"); + } + + [Test] + public async Task DateFormat_HonorsNetFormatSpecifiers() + { + await Assert.That(Parse("release_{date:yyyyMMdd}").Render(null, ReleaseDate, 1)).IsEqualTo("release_20260902"); + await Assert.That(Parse("{year}.{month}.{day}").Render(null, ReleaseDate, 1)).IsEqualTo("2026.09.02"); + await Assert.That(Parse("{year:yy}{month:MMM}{day:d}").Render(null, ReleaseDate, 1)).IsEqualTo("26Sep2"); + } + + [Test] + public async Task EscapedStructuralCharacters_RenderLiterally() + { + await Assert.That(Parse("v{{{major}.{minor}.{patch}}}").Render(Version(1, 4, 0), ReleaseDate, 1)) + .IsEqualTo("v{1.4.0}"); + await Assert.That(Parse("[[{major}.{minor}.{patch}]]").Render(Version(1, 4, 0), ReleaseDate, 1)) + .IsEqualTo("[1.4.0]"); + } + + [Test] + [Arguments("v{major}.{minor}.{patch}[-{prerelease}][-{iteration}]", "v1.4.0")] + [Arguments("v{major}.{minor}.{patch}[-{prerelease}][-{iteration}]", "v1.4.0-beta.1")] + [Arguments("v{major}.{minor}.{patch}[-{prerelease}][-{iteration}]", "v1.4.0-beta.1-2")] + [Arguments("v{major}.{minor}.{patch}[-{prerelease}][-{iteration}]", "v1.4.0-3")] + [Arguments("release_{date}[_{iteration}]", "release_2026-09-02")] + [Arguments("release_{date}[_{iteration}]", "release_2026-09-02_7")] + [Arguments("release_{date:yyyyMMdd}", "release_20260902")] + [Arguments("{year}.{month}.{day}", "2026.09.02")] + [Arguments("{year:yy}{month:MMM}{day:dd}", "26Sep02")] + [Arguments("{month:MMMM}-{day}-{year}", "September-02-2026")] + [Arguments("release_{date:yyyy/MM/dd}", "release_2026/09/02")] + public async Task RenderedTag_RoundTripsBackThroughTheSameFormat(string formatString, string tag) + { + var format = Parse(formatString); + + await Assert.That(format.TryParseTag(tag, out var parsed)).IsTrue(); + await Assert.That(parsed!.Raw).IsEqualTo(tag); + await Assert.That(format.Render(parsed.Version, parsed.Date ?? ReleaseDate, parsed.Iteration)).IsEqualTo(tag); + } + + // A format is free to place {prerelease} outside an optional group (a repo that always sets a + // prerelease label, say). Whatever that renders still has to be readable back, otherwise the + // tag AutoVer just wrote could never be found again and every release would look like the first. + [Test] + [Arguments("beta.1", "v1.4.0-beta.1")] + [Arguments(null, "v1.4.0-")] + public async Task NonOptionalPrerelease_RendersAndReadsBackEitherWay(string? prereleaseLabel, string expected) + { + var format = Parse("v{major}.{minor}.{patch}-{prerelease}"); + var rendered = format.Render(Version(1, 4, 0, prereleaseLabel), ReleaseDate, 1); + + await Assert.That(rendered).IsEqualTo(expected); + await Assert.That(format.TryParseTag(rendered, out var parsed)).IsTrue(); + await Assert.That(parsed!.Version!.PrereleaseLabel).IsEqualTo(prereleaseLabel); + } + + // A greedy prerelease group would swallow the iteration suffix and report iteration 1. + [Test] + public async Task TrailingIteration_IsNotAbsorbedByThePrereleaseLabel() + { + var format = Parse("v{major}.{minor}.{patch}[-{prerelease}][-{iteration}]"); + + await Assert.That(format.TryParseTag("v1.4.0-beta.1-2", out var parsed)).IsTrue(); + await Assert.That(parsed!.Version!.PrereleaseLabel).IsEqualTo("beta.1"); + await Assert.That(parsed.Iteration).IsEqualTo(2); + } + + [Test] + [Arguments("release_{date}[_{iteration}]", "v1.4.0")] + [Arguments("release_{date}[_{iteration}]", "release_2026-13-45")] + [Arguments("release_{date}[_{iteration}]", "release_2026-09-02-extra")] + [Arguments("v{major}.{minor}.{patch}", "v1.4")] + [Arguments("v{major}.{minor}.{patch}", "1.4.0")] + public async Task NonMatchingTag_IsRejected(string formatString, string tag) + { + await Assert.That(Parse(formatString).TryParseTag(tag, out _)).IsFalse(); + } + + [Test] + public async Task SupportsIteration_ReflectsWhetherTheFormatCanRepresentARepeatRelease() + { + await Assert.That(Parse("v{major}.{minor}.{patch}").SupportsIteration).IsFalse(); + await Assert.That(Parse("v{major}.{minor}.{patch}[-{iteration}]").SupportsIteration).IsTrue(); + await Assert.That(Parse(UserConfiguration.DefaultTagFormat).SupportsIteration).IsTrue(); + } + + [Test] + // Mixed families - the whole reason ordering would be ambiguous. + [Arguments("v{major}.{minor}.{patch}-{date}")] + [Arguments("{year}.{month}.{day}-{prerelease}")] + // Incomplete ordering keys. + [Arguments("v{major}.{minor}")] + [Arguments("{month}-{day}")] + [Arguments("release_{date:yyyy}")] + [Arguments("release_{date:yyyy-MM}")] + // No ordering key at all. + [Arguments("release")] + [Arguments("release_{iteration}")] + // Structural problems. + [Arguments("v{major}.{minor}.{patch")] + [Arguments("v{major}.{minor}.{patch}}")] + [Arguments("v{major}.{minor}.{patch}[-{prerelease}")] + [Arguments("v{major}.{minor}.{patch}-{prerelease}]")] + [Arguments("v{major}.{minor}.{patch}[[-{prerelease}]")] + [Arguments("v{major}.{minor}.{patch}[-{iteration}[-{prerelease}]]")] + // Placeholders that can't be absent don't belong in an optional group. + [Arguments("v{major}.{minor}[.{patch}]")] + [Arguments("release_[{date}]")] + // An optional group with nothing to key off. + [Arguments("v{major}.{minor}.{patch}[-rc]")] + // Duplicated placeholders can't be read back unambiguously. + [Arguments("v{major}.{minor}.{patch}-{major}")] + // Unknown placeholder, and a format on something that isn't a date. + [Arguments("v{major}.{minor}.{patch}-{revision}")] + [Arguments("v{major:00}.{minor}.{patch}")] + // Date specifiers that render but can't be matched back. + [Arguments("release_{date:yyyy-MM-dd HH:mm}")] + [Arguments("release_{year}-{month}-{day:ddd}")] + // An empty format suffix isn't "no suffix" - .NET reads "" as the general date/time pattern. + [Arguments("release_{date:}")] + [Arguments("release_{year:}-{month}-{day}")] + // {date} already covers the whole date; combining it with components produces a composite + // format with repeated specifiers that can't be parsed back. + [Arguments("release_{date}-{year}")] + [Arguments("release_{date}_{month}{day}")] + // Ambiguous: adjacent variable-width placeholders can't be read back unchanged. + [Arguments("{major}{minor}{patch}")] + [Arguments("v{major}.{minor}.{patch}{iteration}")] + [Arguments("{year}-{month:M}{day:d}")] + [Arguments("v{major}.{minor}.{patch}-{prerelease}{iteration}")] + public async Task InvalidFormat_IsRejectedAtParseTime(string formatString) + { + await Assert.That(() => Parse(formatString)).Throws(); + } + + // The invariant culture reads a two-digit year against a window ending in 2049, which would + // read a 2050 release's "50" back as 1950 - sorting it below every existing release. + [Test] + [Arguments(2026)] + [Arguments(2049)] + [Arguments(2050)] + [Arguments(2099)] + public async Task TwoDigitYear_RoundTripsPastTheInvariantCulturePivot(int year) + { + var format = Parse("{year:yy}.{month}.{day}"); + var date = new DateTime(year, 1, 12); + + var rendered = format.Render(null, date, 1); + + await Assert.That(format.TryParseTag(rendered, out var parsed)).IsTrue(); + await Assert.That(parsed!.Date).IsEqualTo(date); + } + + [Test] + public async Task EmptyFormat_IsRejected() + { + await Assert.That(() => Parse("")).Throws(); + await Assert.That(() => Parse(" ")).Throws(); + } +}