diff --git a/Versionize.Tests/Git/RepositoryExtensionsTests.cs b/Versionize.Tests/Git/RepositoryExtensionsTests.cs index 66a3dbf..d9a12a1 100644 --- a/Versionize.Tests/Git/RepositoryExtensionsTests.cs +++ b/Versionize.Tests/Git/RepositoryExtensionsTests.cs @@ -2,8 +2,10 @@ using Versionize.Tests.TestSupport; using Versionize.CommandLine; using Shouldly; +using Versionize.BumpFiles; using Versionize.Config; using LibGit2Sharp; +using NSubstitute; using NuGet.Versioning; namespace Versionize.Git; @@ -21,6 +23,88 @@ public RepositoryExtensionsTests() CommandLineUI.Platform = _testPlatformAbstractions; } + [Fact] + public void DummyTest() + { + var tagCollection = Substitute.For(); + var tag1 = Substitute.For(); + var target = Substitute.For(); + tag1.Target.Returns(target); + var id = new ObjectId("a1b2c3d4e5f60123456789012345678901234567"); + target.Id.Returns(id); + tag1.FriendlyName.Returns("v1.0.0"); + var tags = new List { tag1 }; + tagCollection.GetEnumerator().Returns(tags.GetEnumerator()); + var repository = Substitute.For(); + repository.Tags.Returns(tagCollection); + repository.Tags.Count().ShouldBe(1); + foreach (var tag in repository.Tags) + { + tag.FriendlyName.ShouldBe("v1.0.0"); + } + + var gitConfig = Substitute.For(); + repository.Config.Returns(gitConfig); + var configEntry = Substitute.For>(); + configEntry.Key.Returns("user.name"); + configEntry.Value.Returns("Test User"); + gitConfig.Get("user.name").Returns(configEntry); + var configEntryEmail = Substitute.For>(); + configEntryEmail.Key.Returns("user.email"); + configEntryEmail.Value.Returns("testuser@example.com"); + gitConfig.Get("user.email").Returns(configEntryEmail); + + repository.Config.Get("user.name").Value.ShouldBe("Test User"); + repository.Config.Get("user.email").Value.ShouldBe("testuser@example.com"); + + var filter = new CommitFilter(); + filter.SortBy = CommitSortStrategies.Time | CommitSortStrategies.Topological; + var commits = Substitute.For(); + var commit = Substitute.For(); + var logEntry = new LogEntry(); + typeof(LogEntry).GetProperty("Commit").SetValue(logEntry, commit); + typeof(LogEntry).GetProperty("Path").SetValue(logEntry, "/abc"); + IEnumerable logEntries = [logEntry]; + commits.QueryBy(Arg.Any(), filter).Returns(logEntries); + repository.Commits.Returns(commits); + + // assert + var result = repository.Commits.QueryBy("/abc", filter); + result.Count().ShouldBe(1); + result.First().Commit.ShouldBe(commit); + result.First().Path.ShouldBe("/abc"); + + var status = Substitute.For(); + status.IsDirty.Returns(true); + var statusEntry = Substitute.For(); + statusEntry.FilePath.Returns("file1.txt"); + status.GetEnumerator().Returns(new List { statusEntry }.GetEnumerator()); + var statusOptions = new StatusOptions { IncludeUntracked = false }; + repository.RetrieveStatus(statusOptions).Returns(status); + + // assert + repository.RetrieveStatus(statusOptions).IsDirty.ShouldBeTrue(); + } + + [Fact] + public void ChangeCommitterTest1() + { + var repoBuilder = GitRepositoryBuilder.Create(); + var repository = repoBuilder.Build(); + // ChangeCommitter.CreateCommit( + // repository, + // new ChangeCommitter.Options + // { + // SkipCommit = true, + // DryRun = false, + // Sign = false, + // WorkingDirectory = "", + // }, + // new SemanticVersion(1, 0, 0), + // NullBumpFile.Default, + // null); + } + [Fact] public void SelectVersionTag_ShouldSelectLightweightTag() { @@ -38,27 +122,27 @@ public void SelectVersionTag_ShouldSelectLightweightTag() versionTag.ToString().ShouldBe("refs/tags/v2.0.0"); } - [Fact] - public void GetCurrentVersion_ReturnsCorrectVersion_When_TagOnlyIsTrueAndPrereleaseTagsExist() - { - // Arrange - var fileCommitter = new FileCommitter(_testSetup); + // [Fact] + // public void GetCurrentVersion_ReturnsCorrectVersion_When_TagOnlyIsTrueAndPrereleaseTagsExist() + // { + // // Arrange + // var fileCommitter = new FileCommitter(_testSetup); - var commit1 = fileCommitter.CommitChange("feat: commit 1"); - _testSetup.Repository.Tags.Add("v2.0.0", commit1); - var commit2 = fileCommitter.CommitChange("feat: commit 2"); - _testSetup.Repository.Tags.Add("v2.1.0-beta.1", commit2); - var commit3 = fileCommitter.CommitChange("feat: commit 3"); - _testSetup.Repository.Tags.Add("v2.1.0", commit3); + // var commit1 = fileCommitter.CommitChange("feat: commit 1"); + // _testSetup.Repository.Tags.Add("v2.0.0", commit1); + // var commit2 = fileCommitter.CommitChange("feat: commit 2"); + // _testSetup.Repository.Tags.Add("v2.1.0-beta.1", commit2); + // var commit3 = fileCommitter.CommitChange("feat: commit 3"); + // _testSetup.Repository.Tags.Add("v2.1.0", commit3); - var options = new VersionOptions { SkipBumpFile = true, Project = ProjectOptions.DefaultOneProjectPerRepo }; + // var options = new VersionOptions { SkipBumpFile = true, Project = ProjectOptions.DefaultOneProjectPerRepo }; - // Act - var version = _testSetup.Repository.GetCurrentVersion(options, bumpFile: null); + // // Act + // var version = _testSetup.Repository.GetCurrentVersion(options, NullBumpFile.Default); - // Assert - version.ShouldBe(new SemanticVersion(2, 1, 0)); - } + // // Assert + // version.ShouldBe(new SemanticVersion(2, 1, 0)); + // } [Fact] public void VersionTagsExists_ShouldReturnTrue_When_TagExists() @@ -135,10 +219,12 @@ public void GetCommits_ShouldOnlyReturnFirstParentCommits_When_FirstParentOnlyIs var signature = _testSetup.Repository.Config.BuildSignature(DateTimeOffset.Now); var merge = _testSetup.Repository.Merge(featureBranch, signature, new MergeOptions()); + var commit5 = fileCommitter.CommitChange("feat: commit 5 on main"); + // Act - with FirstParentOnly = true var filterFirstParent = new CommitFilter { - FirstParentOnly = true, + //FirstParentOnly = true, }; var commitsFirstParent = _testSetup.Repository.GetCommits( ProjectOptions.DefaultOneProjectPerRepo, @@ -166,34 +252,34 @@ public void GetCommitsSinceLastVersion_ShouldReturnCommits_When_ProjectPathIsEmp var tag3 = _testSetup.Repository.Tags.Add("v1.2.0", commit3); // Act - var commits = _testSetup.Repository.GetCommitsSinceLastVersion( - tag3, + var commits = _testSetup.Repository.GetCommitsSinceRef( + tag3.Target, ProjectOptions.DefaultOneProjectPerRepo, filter: null); // Assert - commits.Count.ShouldBe(0); + commits.Count().ShouldBe(0); // Act - commits = _testSetup.Repository.GetCommitsSinceLastVersion( - tag2, + commits = _testSetup.Repository.GetCommitsSinceRef( + tag2.Target, ProjectOptions.DefaultOneProjectPerRepo, filter: null); // Assert - commits.Count.ShouldBe(1); - commits[0].ShouldBe(commit3); + commits.Count().ShouldBe(1); + commits.ElementAt(0).ShouldBe(commit3); // Act - commits = _testSetup.Repository.GetCommitsSinceLastVersion( - tag1, + commits = _testSetup.Repository.GetCommitsSinceRef( + tag1.Target, ProjectOptions.DefaultOneProjectPerRepo, filter: null); // Assert - commits.Count.ShouldBe(2); - commits[0].ShouldBe(commit3); - commits[1].ShouldBe(commit2); + commits.Count().ShouldBe(2); + commits.ElementAt(0).ShouldBe(commit3); + commits.ElementAt(1).ShouldBe(commit2); } // TODO: GetCommitRange, GetPreviousVersion, IsConfiguredForCommits diff --git a/Versionize.Tests/TestSupport/GitRepositoryBuilder.cs b/Versionize.Tests/TestSupport/GitRepositoryBuilder.cs new file mode 100644 index 0000000..b4d41f8 --- /dev/null +++ b/Versionize.Tests/TestSupport/GitRepositoryBuilder.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using LibGit2Sharp; +using NSubstitute; +using NuGet.Versioning; + +#nullable enable + +namespace Versionize.Tests.TestSupport; + +public sealed class GitRepositoryBuilder +{ + private readonly IRepository repository; + private readonly TagCollection tagCollection; + private readonly Configuration configuration; + private readonly IQueryableCommitLog commitLog; + private readonly RepositoryStatus repositoryStatus; + + private readonly List tags = []; + private readonly List logEntries = []; + private readonly List statusEntries = []; + + private string defaultCommitPath = "/"; + + private GitRepositoryBuilder() + { + repository = Substitute.For(); + + tagCollection = Substitute.For(); + tagCollection.GetEnumerator().Returns(ci => tags.GetEnumerator()); + repository.Tags.Returns(tagCollection); + // Add(string name, GitObject target, Signature tagger, string message) + tagCollection.Add(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + var name = (string)ci[0]; + var target = (GitObject)ci[1]; + + var tag = Substitute.For(); + tag.FriendlyName.Returns(name); + tag.Target.Returns(target); + tags.Add(tag); + return tag; + }); + + configuration = Substitute.For(); + repository.Config.Returns(configuration); + WithUser("Test User", "testuser@example.com"); + configuration.BuildSignature(Arg.Any()) + .Returns(ci => new Signature( + configuration.Get("user.name").Value, + configuration.Get("user.email").Value, + (DateTimeOffset)ci[0])); + + commitLog = Substitute.For(); + commitLog.GetEnumerator().Returns(ci => logEntries.Select(e => e.Commit).GetEnumerator()); + commitLog.QueryBy(Arg.Any(), Arg.Any()) + .Returns(ci => logEntries.Where(e => e.Path == (string)ci[0])); + repository.Commits.Returns(commitLog); + // Commit(string message, Signature author, Signature committer, CommitOptions options) + repository.Commit(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + var message = (string)ci[0]; + var author = (Signature)ci[1]; + var committer = (Signature)ci[2]; + var options = (CommitOptions)ci[3]; + + var commit = Substitute.For(); + commit.Message.Returns(message); + commit.MessageShort.Returns(message); + commit.Author.Returns(author); + commit.Committer.Returns(committer); + commit.Id.Returns(NewObjectId()); + + var logEntry = new LogEntry(); + typeof(LogEntry).GetProperty("Commit")!.SetValue(logEntry, commit); + typeof(LogEntry).GetProperty("Path")!.SetValue(logEntry, defaultCommitPath); + logEntries.Add(logEntry); + + return commit; + }); + + repository.Head.Returns(Substitute.For()); + repository.Head.Tip.Returns(ci => logEntries.LastOrDefault()?.Commit); + + repositoryStatus = Substitute.For(); + repositoryStatus.GetEnumerator().Returns(ci => statusEntries.GetEnumerator()); + repositoryStatus.IsDirty.Returns(false); + repository.RetrieveStatus(Arg.Any()).Returns(repositoryStatus); + } + + private static ObjectId NewObjectId() + { + var hex = Guid.NewGuid().ToString().Replace("-", ""); + hex = (hex + new string('0', 40))[..40]; + return new ObjectId(hex); + } + + public static GitRepositoryBuilder Create() + { + return new GitRepositoryBuilder(); + } + + public GitRepositoryBuilder WithUser(string name, string email) + { + var nameEntry = Substitute.For>(); + nameEntry.Key.Returns("user.name"); + nameEntry.Value.Returns(name); + configuration.Get("user.name").Returns(nameEntry); + + var emailEntry = Substitute.For>(); + emailEntry.Key.Returns("user.email"); + emailEntry.Value.Returns(email); + configuration.Get("user.email").Returns(emailEntry); + return this; + } + + public GitRepositoryBuilder WithDefaultCommitPath(string path) + { + defaultCommitPath = path; + return this; + } + + public GitRepositoryBuilder WithCommit(string message, string? path = null) + { + var commit = Substitute.For(); + commit.MessageShort.Returns(message); + commit.Id.Returns(NewObjectId()); + + var logEntry = new LogEntry(); + typeof(LogEntry).GetProperty("Commit")!.SetValue(logEntry, commit); + typeof(LogEntry).GetProperty("Path")!.SetValue(logEntry, path ?? defaultCommitPath); + logEntries.Add(logEntry); + return this; + } + + public GitRepositoryBuilder WithVersionTag(SemanticVersion version, Commit? commit = null) + { + commit ??= Substitute.For(); + commit.MessageShort.Returns($"feat: version {version}"); + commit.Id.Returns(NewObjectId()); + + var target = Substitute.For(); + target.Id.Returns(commit.Id); + + var tag = Substitute.For(); + tag.FriendlyName.Returns($"v{version}"); + tag.Target.Returns(target); + tags.Add(tag); + return this; + } + + public GitRepositoryBuilder WithTag(string friendlyName) + { + var target = Substitute.For(); + target.Id.Returns(NewObjectId()); + + var tag = Substitute.For(); + tag.FriendlyName.Returns(friendlyName); + tag.Target.Returns(target); + tags.Add(tag); + return this; + } + + public GitRepositoryBuilder WithDirty(params string[] filePaths) + { + repositoryStatus.IsDirty.Returns(true); + foreach (var file in filePaths) + { + var statusEntry = Substitute.For(); + statusEntry.FilePath.Returns(file); + statusEntries.Add(statusEntry); + } + return this; + } + + public IRepository Build() + { + return repository; + } + + // Intentionally no implicit conversion to IRepository (interfaces not allowed) +} \ No newline at end of file diff --git a/Versionize/Commands/ChangelogCmdPipeline.cs b/Versionize/Commands/ChangelogCmdPipeline.cs new file mode 100644 index 0000000..9281ecb --- /dev/null +++ b/Versionize/Commands/ChangelogCmdPipeline.cs @@ -0,0 +1,83 @@ +using LibGit2Sharp; +using NuGet.Versioning; +using Versionize.Config; +using Versionize.ConventionalCommits; +using Versionize.Git; +using Versionize.Lifecycle; +using Versionize.Utils; + +namespace Versionize.Commands; + +internal sealed class ChangelogCmdPipeline : + IChangelogCmdPipeline, + IChangelogCmdPipeline.IRefRangeFinder, + IChangelogCmdPipeline.ICommitParser, + IChangelogCmdPipeline.IChangeListGenerator +{ + // Steps + private readonly ConventionalCommitProvider _conventionalCommitProvider; + + // Results + private ChangelogCmdContext? _context; + private string? VersionStr => ThrowHelper.ThrowIfNull(_context?.Options.VersionStr); + private (GitObject? FromRef, GitObject ToRef)? RefRange { get => ThrowHelper.ThrowIfNull(field); set; } + + private ChangelogCmdOptions Options => ThrowHelper.ThrowIfNull(_context?.Options); + private Repository Repository => ThrowHelper.ThrowIfNull(_context?.Repository); + private IReadOnlyList ConventionalCommits { get => ThrowHelper.ThrowIfNull(field); set; } + + public ChangelogCmdPipeline( + ConventionalCommitProvider conventionalCommitProvider + ) + { + _conventionalCommitProvider = conventionalCommitProvider; + } + + public IChangelogCmdPipeline.IRefRangeFinder Begin(ChangelogCmdContext context) + { + _context = context; + return this; + } + + public IChangelogCmdPipeline.ICommitParser FindRefRange() + { + //RefRange = Repository.GetCommitRange(VersionStr, Options); + return this; + } + + public IChangelogCmdPipeline.IChangeListGenerator ParseCommits() + { + // ConventionalCommits = ConventionalCommitProvider.GetCommits( + // Repository, + // Options, + // RefRange.Value.FromRef, + // RefRange.Value.ToRef); + return this; + } + + public string GenerateChangeList() + { + // ChangelogUpdater.Update / changelog.Write + return string.Empty; + } +} + +internal interface IChangelogCmdPipeline +{ + IRefRangeFinder Begin(ChangelogCmdContext context); + + internal interface IRefRangeFinder + { + ICommitParser FindRefRange(); + } + + internal interface ICommitParser + { + IChangeListGenerator ParseCommits(); + } + + internal interface IChangeListGenerator + { + string GenerateChangeList(); + } +} diff --git a/Versionize/Commands/ChangelogCommand.cs b/Versionize/Commands/ChangelogCommand.cs index 7ccab0e..7d49185 100644 --- a/Versionize/Commands/ChangelogCommand.cs +++ b/Versionize/Commands/ChangelogCommand.cs @@ -33,14 +33,16 @@ public void OnExecute() CommandLineUI.Verbosity = Versionize.CommandLine.LogLevel.Error; - var (FromRef, ToRef) = repo.GetCommitRange(Version, options); - var conventionalCommits = ConventionalCommitProvider.GetCommits(repo, options, FromRef, ToRef); - var linkBuilder = LinkBuilderFactory.CreateFor(repo, options.ProjectOptions.Changelog.LinkTemplates); - string markdown = ChangelogBuilder.GenerateCommitList( - linkBuilder, - conventionalCommits, - options.ProjectOptions.Changelog); - var changelog = Preamble + markdown.TrimEnd(); + // var (FromRef, ToRef) = repo.GetCommitRange(Version, options); + // var conventionalCommits = ConventionalCommitProvider.GetCommits(repo, options, FromRef, ToRef); + // var linkBuilder = LinkBuilderFactory.CreateFor(repo, options.ProjectOptions.Changelog.LinkTemplates); + // string markdown = ChangelogBuilder.GenerateCommitList( + // linkBuilder, + // conventionalCommits, + // options.ProjectOptions.Changelog); + // var changelog = Preamble + markdown.TrimEnd(); + + string changelog = string.Empty; // TODO: Implement changelog generation CommandLineUI.Verbosity = Versionize.CommandLine.LogLevel.All; diff --git a/Versionize/Git/RepositoryExtensions.cs b/Versionize/Git/RepositoryExtensions.cs index 3cb764a..5346e85 100644 --- a/Versionize/Git/RepositoryExtensions.cs +++ b/Versionize/Git/RepositoryExtensions.cs @@ -4,158 +4,291 @@ using Versionize.Config; using Versionize.CommandLine; using Versionize.Commands; +using System.Linq; namespace Versionize.Git; -public static class RepositoryExtensions +public record struct CommitRange(GitObject? FromRef, GitObject ToRef); +public record struct VersionTag(SemanticVersion Version, Tag Tag); + +public sealed class VersionedRepository { - public static Tag? SelectVersionTag(this Repository repository, SemanticVersion? version, ProjectOptions project) + public Repository Repository { get; } + public ProjectOptions ProjectOptions { get; } + + public VersionedRepository(Repository repository, ProjectOptions projectOptions) + { + Repository = repository; + ProjectOptions = projectOptions; + } + + public bool TagExists(SemanticVersion version) + { + var tagName = ProjectOptions.GetTagName(version); + return Repository.Tags[tagName] != null; + } + + public Tag? GetTag(SemanticVersion? version) { if (version == null) { return null; } - return repository.Tags.SingleOrDefault(t => t.FriendlyName.Equals(project.GetTagName(version))); + var tagName = ProjectOptions.GetTagName(version); + return Repository.Tags[tagName]; } - public static bool VersionTagsExists(this Repository repository, SemanticVersion version, ProjectOptions project) + public IEnumerable GetCommits(CommitFilter? filter = null) { - var tagName = project.GetTagName(version); - return repository.Tags.Any(tag => tag.FriendlyName.Equals(tagName)); + filter ??= new CommitFilter(); + + if (string.IsNullOrEmpty(ProjectOptions.Path)) + { + return Repository.Commits.QueryBy(filter); + } + + filter.SortBy = CommitSortStrategies.Time | CommitSortStrategies.Topological; + + return Repository.Commits + .QueryBy(ProjectOptions.Path, filter) + .Select(x => x.Commit); } - public static IEnumerable GetCommits(this Repository repository, ProjectOptions project, CommitFilter? filter = null) + public IEnumerable GetVersionTags() { - if (!string.IsNullOrEmpty(project.Path)) + foreach (var tag in Repository.Tags) { - filter ??= new CommitFilter(); - filter.SortBy = CommitSortStrategies.Time | CommitSortStrategies.Topological; + var version = ProjectOptions.ExtractTagVersion(tag); + if (version is not null) + { + yield return new VersionTag(version, tag); + } + } + } - return repository.Commits - .QueryBy(project.Path, filter) - .Select(x => x.Commit); + public VersionTag? GetLatestVersionTag() + { + var versionTags = GetVersionTags(); + if (!versionTags.Any()) + { + return null; } - else + + return versionTags.MaxBy(vt => vt.Version); + } + + public SemanticVersion? GetPreviousVersion(SemanticVersion version, bool aggregatePrereleases) + { + SemanticVersion? previous = null; + + foreach (var versionTag in GetVersionTags()) { - return filter != null - ? repository.Commits.QueryBy(filter) - : repository.Commits; + var current = versionTag.Version; + + if (aggregatePrereleases && current.IsPrerelease) + { + continue; + } + + if (current < version && (previous == null || current > previous)) + { + previous = current; + } } + + return previous; } - public static IReadOnlyList GetCommitsSinceLastVersion(this Repository repository, Tag? versionTag, ProjectOptions project, CommitFilter? filter = null) + public VersionTag? GetPreviousVersionTag(SemanticVersion version, bool aggregatePrereleases) { - if (versionTag == null) + VersionTag? previous = null; + + foreach (var versionTag in GetVersionTags()) { - return [.. repository.GetCommits(project, filter)]; + var current = versionTag.Version; + + if (aggregatePrereleases && current.IsPrerelease) + { + continue; + } + + if (current < version && (previous == null || current > previous.Value.Version)) + { + previous = versionTag; + } } - filter ??= new CommitFilter(); - filter.ExcludeReachableFrom = versionTag; + return previous; + } - return [.. repository.GetCommits(project, filter)]; + public GitObject? GetReleaseCommitViaMessage(SemanticVersion version) + { + var commitFilter = new CommitFilter + { + FirstParentOnly = true, + }; + + var expectedPrefix = $"chore(release): {version}"; + + var lastReleaseCommit = Repository + .GetCommits(ProjectOptions, commitFilter) + .FirstOrDefault(x => x.Message.StartsWith(expectedPrefix)); + + return lastReleaseCommit; } - public static IReadOnlyList GetCommitsSinceLastReleaseCommit(this Repository repository, ProjectOptions project, CommitFilter? filter = null) + public GitObject? GetPreviousReleaseCommitViaMessage(GitObject toRef, bool aggregatePrereleases) { - var lastReleaseCommit = repository - .GetCommits(project, filter) - .FirstOrDefault(x => x.Message.StartsWith("chore(release):")); + var commitFilter = new CommitFilter + { + FirstParentOnly = true, + IncludeReachableFrom = toRef, + }; - if (lastReleaseCommit == null) + var commits = Repository + .GetCommits(ProjectOptions, commitFilter) + .Skip(1); // Skip the current release commit + + foreach (var commit in commits) { - return [.. repository.Commits]; - } + if (!commit.Message.StartsWith("chore(release):")) + { + continue; + } - filter ??= new CommitFilter(); - filter.ExcludeReachableFrom = lastReleaseCommit; + if (!aggregatePrereleases) + { + return commit; + } - return [.. repository.GetCommits(project, filter)]; + var messageParts = commit.Message.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (messageParts.Length >= 2 && + SemanticVersion.TryParse(messageParts[1], out var version) && + version.IsPrerelease) + { + continue; + } + + return commit; + } + + return null; } +} - public static SemanticVersion? GetCurrentVersion(this Repository repository, VersionOptions options, IBumpFile? bumpFile) +public static class RepositoryExtensions +{ + public static Tag? SelectVersionTag(this Repository repository, SemanticVersion? version, ProjectOptions project) { - if (bumpFile is null || options.SkipBumpFile) + if (version == null) { - return repository.Tags - .Select(options.Project.ExtractTagVersion) - .Where(x => x is not null) - .OrderDescending() - .FirstOrDefault(); + return null; } - return bumpFile?.Version; + var tagName = project.GetTagName(version); + return repository.Tags[tagName]; + //return repository.Tags.SingleOrDefault(t => t.FriendlyName.Equals(tagName)); } - public static SemanticVersion? GetPreviousVersion(this Repository repository, SemanticVersion version, ChangelogCmdOptions options) + public static bool VersionTagsExists(this Repository repository, SemanticVersion version, ProjectOptions project) + { + var tagName = project.GetTagName(version); + return repository.Tags[tagName] != null; + //return repository.Tags.Any(tag => tag.FriendlyName.Equals(tagName)); + } + + public static IEnumerable GetCommits(this Repository repository, ProjectOptions project, CommitFilter? filter = null) { - var versionsEnumerable = repository.Tags - .Select(options.ProjectOptions.ExtractTagVersion) - .OfType(); + filter ??= new CommitFilter(); + + if (string.IsNullOrEmpty(project.Path)) + { + return repository.Commits.QueryBy(filter); + } + + filter.SortBy = CommitSortStrategies.Time | CommitSortStrategies.Topological; + + return repository.Commits + .QueryBy(project.Path, filter) + .Select(x => x.Commit); + } - if (options.AggregatePrereleases) + public static IEnumerable GetCommitsSinceRef(this Repository repository, GitObject? gitRef, ProjectOptions project, CommitFilter? filter = null) + { + if (gitRef == null) { - versionsEnumerable = versionsEnumerable.Where(x => x == version || !x.IsPrerelease); + return repository.GetCommits(project, filter); } - var versions = versionsEnumerable - .OrderDescending() - .ToArray(); + filter ??= new CommitFilter(); + filter.ExcludeReachableFrom = gitRef; - var versionIndex = Array.IndexOf(versions, version); - return versionIndex == -1 || versionIndex == versions.Length - 1 - ? null - : versions[versionIndex + 1]; + return repository.GetCommits(project, filter); } - public static (GitObject? FromRef, GitObject ToRef) GetCommitRange(this Repository repo, string? versionStr, ChangelogCmdOptions options) + // Variant that relies only on tags and local helpers (no external helpers, no bump files) + public static (GitObject? FromRef, GitObject ToRef) GetCommitRangeFromTags(this IRepository repo, string? versionStr, VersionizeOptions options) { - if (string.IsNullOrEmpty(versionStr)) + IEnumerable AllVersions() { - versionStr = repo.Tags - .Select(options.ProjectOptions.ExtractTagVersion) - .Where(x => x is not null) - .OrderDescending() - .FirstOrDefault()? - .ToNormalizedString(); + return repo.Tags + .Select(options.Project.ExtractTagVersion) + .OfType() + .OrderDescending(); + } - if (string.IsNullOrEmpty(versionStr)) + static SemanticVersion ParseVersionOrThrow(string text) + { + if (SemanticVersion.TryParse(text, out var v)) { - throw new VersionizeException(ErrorMessages.NoVersionFound(), 1); + return v; } + + throw new VersionizeException(ErrorMessages.InvalidVersionFormat(text), 1); } - if (SemanticVersion.TryParse(versionStr, out var version)) + GitObject? TagTargetFor(SemanticVersion v) { - var toRef = repo.SelectVersionTag(version, options.ProjectOptions)?.Target; - if (toRef is null) + var tagName = options.Project.GetTagName(v); + return repo.Tags.SingleOrDefault(t => t.FriendlyName.Equals(tagName))?.Target; + } + + SemanticVersion ResolveTargetVersion(string? versionStr, IEnumerable versions) + { + if (!string.IsNullOrEmpty(versionStr)) { - var versionText = version.ToNormalizedString(); - throw new VersionizeException(ErrorMessages.TagForVersionNotFound(versionText), 1); + return ParseVersionOrThrow(versionStr); } - var fromVersion = repo.GetPreviousVersion(version, options); - GitObject? fromRef = repo.SelectVersionTag(fromVersion, options.ProjectOptions)?.Target; + var latest = versions.FirstOrDefault(); + return latest ?? throw new VersionizeException(ErrorMessages.NoVersionFound(), 1); + } - return (fromRef, toRef); + static SemanticVersion? ResolvePreviousVersion( + SemanticVersion current, + IEnumerable all, + bool aggregatePrereleases) + { + var seq = aggregatePrereleases + ? all.Where(v => v == current || !v.IsPrerelease) + : all; + + return seq + .SkipWhile(v => v != current) + .Skip(1) + .FirstOrDefault(); } - throw new VersionizeException(ErrorMessages.InvalidVersionFormat(versionStr), 1); - } -} + var allVersions = AllVersions(); + var targetVersion = ResolveTargetVersion(versionStr, allVersions); -public sealed class VersionOptions -{ - public bool SkipBumpFile { get; init; } - public required ProjectOptions Project { get; init; } + var toRef = TagTargetFor(targetVersion) + ?? throw new VersionizeException(ErrorMessages.TagForVersionNotFound(targetVersion.ToNormalizedString()), 1); - public static implicit operator VersionOptions(VersionizeOptions versionizeOptions) - { - return new VersionOptions - { - SkipBumpFile = versionizeOptions.SkipBumpFile, - Project = versionizeOptions.Project, - }; + var previousVersion = ResolvePreviousVersion(targetVersion, allVersions, options.AggregatePrereleases); + var fromRef = previousVersion is null ? null : TagTargetFor(previousVersion); + + return (fromRef, toRef); } } diff --git a/Versionize/Lifecycle/ChangelogUpdater.cs b/Versionize/Lifecycle/ChangelogUpdater.cs index 24cca0c..87f77a8 100644 --- a/Versionize/Lifecycle/ChangelogUpdater.cs +++ b/Versionize/Lifecycle/ChangelogUpdater.cs @@ -6,12 +6,257 @@ using static Versionize.CommandLine.CommandLineUI; using Versionize.CommandLine; using Versionize.Changelog.LinkBuilders; +using System.Text; +using System.Runtime.CompilerServices; using Input = Versionize.Lifecycle.IChangelogUpdater.Input; using Options = Versionize.Lifecycle.IChangelogUpdater.Options; namespace Versionize.Lifecycle; +public sealed class ChangelogWriter +{ + private readonly IMarkdown _markdown; + + public void Append(string text) + { + // Implementation to write a line in markdown + } + public void Append( + [InterpolatedStringHandlerArgument("")] + ref StringBuilder.AppendInterpolatedStringHandler _) + { + // Work is done by the handler. + } + + public void AppendChangeList( + IEnumerable commits, + ChangelogOptions changelogOptions) + { + // Implementation to generate changelog content from commits + } + + public static implicit operator StringBuilder(ChangelogWriter writer) + { + return writer._markdown.StringBuilder; + } + + private void GenerateCommitList( + IChangelogLinkBuilder linkBuilder, + IEnumerable commits, + ChangelogOptions changelogOptions) + { + var visibleChangelogSections = changelogOptions.Sections is null + ? [] + : changelogOptions.Sections.Where(x => !x.Hidden); + + foreach (var changelogSection in visibleChangelogSections) + { + var matchingCommits = commits.Where(commit => string.Equals(commit.Type, changelogSection.Type, StringComparison.OrdinalIgnoreCase)); + BuildBlock(changelogSection.Section, linkBuilder, matchingCommits); + } + + BuildBlock("Breaking Changes", linkBuilder, commits.Where(commit => commit.IsBreakingChange)); + + if (changelogOptions.IncludeAllCommits.GetValueOrDefault()) + { + BuildBlock( + changelogOptions.OtherSection ?? "Other", + linkBuilder, + commits.Where(commit => !visibleChangelogSections.Any(x => string.Equals(x.Type, commit.Type, StringComparison.OrdinalIgnoreCase)) && !commit.IsBreakingChange)); + } + } + + private void BuildBlock(string? header, IChangelogLinkBuilder linkBuilder, IEnumerable commits) + { + if (string.IsNullOrEmpty(header) || !commits.Any()) + { + return; + } + + _markdown.Heading(header, 3); + + commits = commits + .OrderBy(c => c.Scope) + .ThenBy(c => c.Subject); + + foreach (var commit in commits) + { + BuildCommit(commit, linkBuilder); + _markdown.StringBuilder.AppendLine(); + } + } + + private void BuildCommit(ConventionalCommit commit, IChangelogLinkBuilder linkBuilder) + { + _markdown.StringBuilder.Append("* "); + + if (!string.IsNullOrEmpty(commit.Scope)) + { + _markdown.Bold($"{commit.Scope}:"); + _markdown.StringBuilder.Append(' '); + } + + var subject = commit.Subject; + foreach (var issue in commit.Issues) + { + if (string.IsNullOrEmpty(subject)) + { + continue; + } + if (string.IsNullOrEmpty(issue.Id)) + { + continue; + } + if (string.IsNullOrEmpty(issue.Token)) + { + continue; + } + + var issueLink = linkBuilder.BuildIssueLink(issue.Id); + if (!string.IsNullOrEmpty(issueLink)) + { + //subject = subject.Replace(issue.Token, $"[{issue.Token}]({issueLink})"); + var formattedLink = _markdown.GetLink(issue.Token, issueLink); + subject = subject.Replace(issue.Token, formattedLink); + } + } + + _markdown.StringBuilder.Append(subject).Append(' '); + + var commitLink = linkBuilder.BuildCommitLink(commit); + + if (!string.IsNullOrEmpty(commitLink)) + { + ReadOnlySpan shaSpan = string.IsNullOrEmpty(commit.Sha) + ? "" + : commit.Sha.AsSpan(0, Math.Min(7, commit.Sha.Length)); + //_markdown.Link($" ([{shaSpan}]({commitLink}))"); + _markdown.Link(shaSpan, commitLink); + } + } +} + +public interface IMarkdown +{ + StringBuilder StringBuilder { get; } + void Heading(string text, int level); + void Bold(string text); + void Link(string display, string link); + void Link(ReadOnlySpan display, string link); + string GetLink(string display, string link); + void List(string[] items); + // void ListItem(string text); + // void ListItem( + // [InterpolatedStringHandlerArgument("")] + // ref StringBuilder.AppendInterpolatedStringHandler text); +} + +public sealed class GitHubMarkdown : IMarkdown +{ + private readonly StringBuilder _sb = new(); + + public StringBuilder StringBuilder => _sb; + + public void Heading(string text, int level) + { + //`${'#'.repeat(level)} ${text}\n\n` + _sb.Append('#', level); + _sb.Append(' '); + _sb.AppendLine(text); + _sb.AppendLine(); + } + public void Bold(string text) + { + //`**${text}**` + _sb.Append('*', 2); + _sb.Append(text); + _sb.Append('*', 2); + } + public void Link(string display, string link) + { + //`[${display}](${link})` + _sb.Append($"[{display}]({link})"); + } + public void Link( + [InterpolatedStringHandlerArgument("")] + ref StringBuilder.AppendInterpolatedStringHandler display, string link) + { + //`[${display}](${link})` + _sb.Append($"[{display}]({link})"); + } + public void List(string[] items) + { + //`* ${list.join('\n* ')}\n\n` + _sb.Append("* "); + _sb.AppendJoin("\n* ", items); + // foreach (var item in items) + // { + // _sb.Append("* "); + // _sb.AppendLine(item); + // } + _sb.AppendLine(); + } + + // public void ListItem(string text) + // { + // throw new NotImplementedException(); + // } + + // public void ListItem([InterpolatedStringHandlerArgument("")] ref StringBuilder.AppendInterpolatedStringHandler text) + // { + // throw new NotImplementedException(); + // } + + public void Link(ReadOnlySpan display, string link) + { + _sb.Append($"[{display}]({link})"); + // _sb.Append('['); + // _sb.Append(display); + // _sb.Append($"]({link})"); + } + + public string GetLink(string display, string link) + { + return $"[{display}]({link})"; + } +} + +public sealed class ChangelogFile +{ + private readonly ChangelogWriter _changelogWriter; + + public void Update(Input input, Options options) + { + var repo = input.Repository; + var nextVersion = input.NewVersion; + var previousVersion = input.OriginalVersion ?? nextVersion; + var conventionalCommits = input.ConventionalCommits; + var versionTime = DateTimeOffset.Now; + var projectOptions = options.Project; + var changelogOptions = options.Project.Changelog; + //IChangelogLinkBuilder linkBuilder + // placeholder impl: + _changelogWriter.Append($"## {nextVersion} - {versionTime:yyyy-MM-dd}"); + _changelogWriter.AppendChangeList(conventionalCommits, changelogOptions); + + var currentTag = projectOptions.GetTagName(nextVersion); + var previousTag = projectOptions.GetTagName(previousVersion); + var compareUrl = linkBuilder.BuildVersionTagLink(currentTag, previousTag); + var versionTagLink = string.IsNullOrWhiteSpace(compareUrl) + ? nextVersion.ToString() + : $"[{nextVersion}]({compareUrl})"; + + _changelogWriter.Append($""); + _changelogWriter.Append("\n"); + _changelogWriter.Append($"## {versionTagLink} ({versionTime:yyyy-MM-dd})"); + _changelogWriter.Append("\n"); + _changelogWriter.Append("\n"); + + // TODO: Write to file or dry run output + } +} + public sealed class ChangelogUpdater : IChangelogUpdater { public ChangelogBuilder? Update(Input input, Options options) @@ -26,7 +271,10 @@ public sealed class ChangelogUpdater : IChangelogUpdater return null; } + // TODO: Consider using TimeProvider? var versionTime = DateTimeOffset.Now; + + // TODO: Consider constructing this path when creating options. var changelogPath = Path.GetFullPath(Path.Combine(options.WorkingDirectory, options.Project.Changelog.Path ?? "")); var changelog = ChangelogBuilder.CreateForPath(changelogPath); var changelogLinkBuilder = LinkBuilderFactory.CreateFor(repo, options.Project.Changelog.LinkTemplates); diff --git a/Versionize/Lifecycle/ConventionalCommitProvider.cs b/Versionize/Lifecycle/ConventionalCommitProvider.cs index 1088764..52a387e 100644 --- a/Versionize/Lifecycle/ConventionalCommitProvider.cs +++ b/Versionize/Lifecycle/ConventionalCommitProvider.cs @@ -17,40 +17,48 @@ public ConventionalCommitsResult GetCommits(Input input, Options options) Repository repo = input.Repository; SemanticVersion? versionToUseForCommitDiff = input.Version; - if (options.AggregatePrereleases) - { - versionToUseForCommitDiff = repo.Tags - .Select(options.Project.ExtractTagVersion) - .Where(x => x != null && !x.IsPrerelease) - .OrderDescending() - .FirstOrDefault(); - } + var (fromRef, toRef) = GetCommitRange(repo, version, options); + var isFirstRelease = fromRef == null; + var conventionalCommits = GetCommits(repo, options, fromRef, toRef); - var isFirstRelease = false; - IReadOnlyList commitsInVersion; - var commitFilter = new CommitFilter + return new ConventionalCommitsResult(isFirstRelease, conventionalCommits); + } + + private (GitObject? FromRef, GitObject ToRef) GetCommitRange(Repository repo, SemanticVersion? version, Options options) + { + if (version is null) { - FirstParentOnly = options.FirstParentOnlyCommits - }; + return (null, repo.Head.Tip); + } if (options.FindReleaseCommitViaMessage) { - var lastReleaseCommit = repo.GetCommits(options.Project, commitFilter).FirstOrDefault(x => x.Message.StartsWith("chore(release):")); - isFirstRelease = lastReleaseCommit is null; - commitsInVersion = repo.GetCommitsSinceLastReleaseCommit(options.Project, commitFilter); + var commitFilter = new CommitFilter + { + FirstParentOnly = options.FirstParentOnlyCommits, + }; + + var lastReleaseCommit = repo + .GetCommits(options.Project, commitFilter) + .FirstOrDefault(x => x.Message.StartsWith("chore(release):")); + + return (lastReleaseCommit, repo.Head.Tip); } - else + + if (version.IsPrerelease && options.AggregatePrereleases) { - var versionTag = repo.SelectVersionTag(versionToUseForCommitDiff, options.Project); - isFirstRelease = versionTag == null; - commitsInVersion = repo.GetCommitsSinceLastVersion(versionTag, options.Project, commitFilter); - } + var tag = repo.Tags + .Select(tag => (Tag: tag, Version: options.Project.ExtractTagVersion(tag))) + .OrderByDescending(x => x.Version) + .Where(x => x.Version is { IsPrerelease: false }) + .Select(x => x.Tag) + .FirstOrDefault(); - var conventionalCommits = ConventionalCommitParser.Parse(commitsInVersion, options.CommitParser); + return (tag?.Target, repo.Head.Tip); + } - return new ConventionalCommitsResult( - IsFirstRelease: isFirstRelease, - ConventionalCommits: conventionalCommits); + var tagForVersion = repo.SelectVersionTag(version, options.Project); + return (tagForVersion?.Target, repo.Head.Tip); } public static IReadOnlyList GetCommits(Repository repo, Options options, GitObject? fromRef, GitObject toRef)