diff --git a/CHANGELOG.md b/CHANGELOG.md
index 81c787a..09b182c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.11.0] - 2026-08-27
+
+### Added
+
+- Added shell auto-completion support through the `completion` command.
+
+### Fixed
+
+- **`prepare` no longer describes one version twice.** A version whose packages had shipped in part was read as one that had never shipped at all, so `prepare` staged it a second time.
+- **Notes written after a `prepare` join the entry they belong to.** Preparing a version the changelog already has an entry for now adds the unreleased notes to that entry rather than giving the version a second heading.
+
## [0.10.0] - 2026-08-23
### Added
@@ -209,6 +220,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
Initial release.
+[0.11.0]: https://github.com/ritten-org/Ritten/compare/v0.10.0...v0.11.0
[0.10.0]: https://github.com/ritten-org/Ritten/compare/v0.9.0...v0.10.0
[0.9.0]: https://github.com/ritten-org/Ritten/compare/v0.8.0...v0.9.0
[0.8.0]: https://github.com/ritten-org/Ritten/compare/v0.7.0...v0.8.0
diff --git a/Directory.Build.props b/Directory.Build.props
index 6540020..b50cdca 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -12,7 +12,7 @@
Copyright © 2026 Tom Wolfe
- 0.10.0
+ 0.11.0
$(Version.Split('-')[0])
$(Version.Split('-')[0])
icon.png
diff --git a/Directory.Packages.props b/Directory.Packages.props
index ddc28b9..96e5f84 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -22,8 +22,9 @@
-
+
+
-
+
\ No newline at end of file
diff --git a/src/Ritten/Changelogs/ChangelogEntry.cs b/src/Ritten/Changelogs/ChangelogEntry.cs
index 48437d8..55d1fcc 100644
--- a/src/Ritten/Changelogs/ChangelogEntry.cs
+++ b/src/Ritten/Changelogs/ChangelogEntry.cs
@@ -58,6 +58,32 @@ public record ChangelogEntry
///
public IReadOnlyCollection Security { get; init; } = [];
+ ///
+ /// Takes on another entry's notes, keeping this entry's own first.
+ ///
+ /// The entry whose notes are being taken on.
+ public ChangelogEntry Merge(ChangelogEntry other)
+ {
+ var merged = this with
+ {
+ Preamble = Join(Preamble, other.Preamble),
+ Added = [.. Added, .. other.Added],
+ Changed = [.. Changed, .. other.Changed],
+ Deprecated = [.. Deprecated, .. other.Deprecated],
+ Removed = [.. Removed, .. other.Removed],
+ Fixed = [.. Fixed, .. other.Fixed],
+ Security = [.. Security, .. other.Security]
+ };
+
+ // Where the sections account for both bodies in full, dropping the bodies lets the entry
+ // render as one set of sections rather than two — the same fixes under a single "Fixed".
+ // Where they don't, the body is the only view that holds a heading the format doesn't
+ // define, so the bodies are joined verbatim and an untidy repeat is the lesser loss.
+ return IsStructured && other.IsStructured
+ ? merged with { Body = "" }
+ : merged with { Body = Join(Body, other.Body) };
+ }
+
///
/// What these notes do to what already shipped.
///
@@ -81,4 +107,25 @@ public record ChangelogEntry
&& Security.Count == 0
&& string.IsNullOrWhiteSpace(Preamble)
&& string.IsNullOrWhiteSpace(Body);
+
+ ///
+ /// Whether the sections account for the whole body, so nothing is lost by rebuilding it from them.
+ ///
+ ///
+ /// Compared line by line rather than as text, because the two differences a rebuild does make —
+ /// the order the author wrote their sections in, and the blank lines between them — are
+ /// formatting the format itself prescribes. A heading the structured view can't hold, and the
+ /// notes under it, are lines that go missing, which is the loss this is looking for.
+ ///
+ private bool IsStructured => Lines(ChangelogRenderer.RenderEntry(this with { Body = "" })).SetEquals(Lines(Body));
+
+ private static HashSet Lines(string text) =>
+ [.. text.Split('\n').Select(line => line.Trim()).Where(line => line.Length > 0)];
+
+ private static string Join(string first, string second) => (first.Trim('\n'), second.Trim('\n')) switch
+ {
+ ("", var only) => only,
+ (var only, "") => only,
+ var (a, b) => $"{a}\n\n{b}"
+ };
}
diff --git a/src/Ritten/Changelogs/Steps/DecideVersion.cs b/src/Ritten/Changelogs/Steps/DecideVersion.cs
index 6af0bf7..5a8dc09 100644
--- a/src/Ritten/Changelogs/Steps/DecideVersion.cs
+++ b/src/Ritten/Changelogs/Steps/DecideVersion.cs
@@ -37,7 +37,7 @@ public async Task> Run(Project project, Changelog ch
// An unpublished version is already the next one: the project was bumped and never shipped,
// so preparing again would skip a version nobody released.
- if (!release.Published)
+ if (!release.AnyPublished)
{
log.Detail($"Preparing {project.Version}, which the project already declares and hasn't published.");
return new PreparedRelease(project.Version, false, "already declared, not yet published");
diff --git a/src/Ritten/Changelogs/Steps/PrepareChangelog.cs b/src/Ritten/Changelogs/Steps/PrepareChangelog.cs
index 96fb025..772e7af 100644
--- a/src/Ritten/Changelogs/Steps/PrepareChangelog.cs
+++ b/src/Ritten/Changelogs/Steps/PrepareChangelog.cs
@@ -36,6 +36,7 @@ TimeProvider time
/// A token to monitor for cancellation requests.
public async Task Run(Changelog changelog, Project project, PreparedRelease release, CancellationToken ct = default)
{
+ var hasChangelog = changelog.Entry(release.Version) is not null;
var rolled = WithRelease(changelog, release, out var entry);
var linked = WithLinks(rolled, project);
@@ -54,6 +55,7 @@ public async Task Run(Changelog changelog, Project project, Prepared
log.Detail(entry switch
{
null => $"Updated the version links in {options.Value.File}.",
+ _ when hasChangelog => $"Added the unreleased notes to the entry {release.Version} already had in {options.Value.File}.",
_ => $"Rolled the unreleased notes into {release.Version} in {options.Value.File}."
});
@@ -61,8 +63,9 @@ public async Task Run(Changelog changelog, Project project, Prepared
}
///
- /// Dates the unreleased entry and gives it its version, leaving every other entry alone.
- /// The body renders verbatim, so nobody's prose is reformatted on the way through.
+ /// Dates the unreleased entry and gives it its version — joining the entry that version already
+ /// has, when it has one — and leaves every other entry alone. The body renders verbatim, so
+ /// nobody's prose is reformatted on the way through.
///
private Changelog WithRelease(Changelog changelog, PreparedRelease release, out ChangelogEntry? rolled)
{
@@ -80,13 +83,20 @@ private Changelog WithRelease(Changelog changelog, PreparedRelease release, out
return changelog;
}
- rolled = unreleased with
+ var today = DateOnly.FromDateTime(time.GetUtcNow().UtcDateTime);
+ var entries = changelog.Entries.ToList();
+
+ // The version may already have an entry: prepared once before and not yet shipped.
+ // Its notes join the ones already under that heading.
+ if (changelog.Entry(release.Version) is { } existing)
{
- Version = release.Version,
- Date = DateOnly.FromDateTime(time.GetUtcNow().UtcDateTime)
- };
+ rolled = existing.Merge(unreleased) with { Date = today };
+ entries[entries.IndexOf(existing)] = rolled;
+ entries.Remove(unreleased);
+ return changelog with { Entries = entries };
+ }
- var entries = changelog.Entries.ToList();
+ rolled = unreleased with { Version = release.Version, Date = today };
entries[entries.IndexOf(unreleased)] = rolled;
return changelog with { Entries = entries };
}
diff --git a/src/Ritten/Program.cs b/src/Ritten/Program.cs
index 2706f7b..9c67aa1 100644
--- a/src/Ritten/Program.cs
+++ b/src/Ritten/Program.cs
@@ -6,6 +6,8 @@
using Ritten.Workflows.DotNet;
using Ritten.Workflows.DotNetPackage;
using Ritten.Workflows.DotNetTool;
+using Wolfe.CommandLine;
+using Wolfe.CommandLine.Completions;
var builder = WorkflowApplication.CreateBuilder();
@@ -23,7 +25,9 @@
return ExitCode.ConfigurationError;
}
-var root = new RootCommand("The Ritten build workflow.");
+var root = new RootCommand("The Ritten build workflow.")
+ .AddCompletions("ritten");
await root.InstallRitten(built.Value);
+await CompletionAutoInstall.Run("ritten", args);
return await root.Parse(args).InvokeAsync();
diff --git a/src/Ritten/Releases/ReleaseState.cs b/src/Ritten/Releases/ReleaseState.cs
index ac7c295..d206985 100644
--- a/src/Ritten/Releases/ReleaseState.cs
+++ b/src/Ritten/Releases/ReleaseState.cs
@@ -16,6 +16,11 @@ public sealed record ReleaseState(bool Published, bool LatestInLine, NuGetVersio
///
public bool OnLatestLine => LatestVersionInLine == LatestVersion;
+ ///
+ /// Whether any one package in the build has this version on the feed.
+ ///
+ public bool AnyPublished => Published || Packages.Any(p => p.Published);
+
///
/// Where each shipped package stands individually.
///
diff --git a/src/Ritten/Ritten.csproj b/src/Ritten/Ritten.csproj
index 22be2e6..41da659 100644
--- a/src/Ritten/Ritten.csproj
+++ b/src/Ritten/Ritten.csproj
@@ -37,6 +37,7 @@
+
diff --git a/tests/Ritten.Tests/Changelogs/ChangelogEntryTests.cs b/tests/Ritten.Tests/Changelogs/ChangelogEntryTests.cs
index 8573d2c..3df9771 100644
--- a/tests/Ritten.Tests/Changelogs/ChangelogEntryTests.cs
+++ b/tests/Ritten.Tests/Changelogs/ChangelogEntryTests.cs
@@ -44,4 +44,44 @@ public void EmptyNotesReleaseNothing()
{
new ChangelogEntry().ReleaseKind.ShouldBe(ReleaseKind.None);
}
+
+ [Fact]
+ public void MergingGathersTheNotesUnderOneSetOfSections()
+ {
+ var existing = ChangelogParser.ParseEntry("### Added\n\n- A shipped thing.\n\n### Fixed\n\n- An old fix.");
+ var later = ChangelogParser.ParseEntry("### Fixed\n\n- A later fix.\n\n### Added\n\n- A later thing.");
+
+ var merged = existing.Merge(later);
+
+ merged.Added.ShouldBe(["A shipped thing.", "A later thing."]);
+ merged.Fixed.ShouldBe(["An old fix.", "A later fix."]);
+
+ // The sections held every line of both bodies, so the entry renders as one set of them —
+ // the order the author wrote their own sections in is not a reason to keep two.
+ ChangelogRenderer.RenderEntry(merged).ShouldBe("### Added\n\n- A shipped thing.\n- A later thing.\n\n### Fixed\n\n- An old fix.\n- A later fix.");
+ }
+
+ [Fact]
+ public void MergingKeepsBothBodiesWhenTheSectionsCannotHoldThem()
+ {
+ // "Notes" is not one of the six, so it lives on the body alone: rebuilding from the
+ // sections would drop the heading and everything under it.
+ var existing = ChangelogParser.ParseEntry("### Notes\n\n- Something the format has no section for.");
+ var later = ChangelogParser.ParseEntry("### Fixed\n\n- A later fix.");
+
+ var rendered = ChangelogRenderer.RenderEntry(existing.Merge(later));
+
+ rendered.ShouldContain("### Notes");
+ rendered.ShouldContain("- Something the format has no section for.");
+ rendered.ShouldContain("- A later fix.");
+ }
+
+ [Fact]
+ public void MergingIntoAnEntryWithNothingInItKeepsTheNotesArriving()
+ {
+ var merged = new ChangelogEntry().Merge(ChangelogParser.ParseEntry("### Fixed\n\n- A later fix."));
+
+ merged.Fixed.ShouldBe(["A later fix."]);
+ ChangelogRenderer.RenderEntry(merged).ShouldBe("### Fixed\n\n- A later fix.");
+ }
}
diff --git a/tests/Ritten.Tests/Changelogs/DecideVersionTests.cs b/tests/Ritten.Tests/Changelogs/DecideVersionTests.cs
index 3f64cbb..303bcf5 100644
--- a/tests/Ritten.Tests/Changelogs/DecideVersionTests.cs
+++ b/tests/Ritten.Tests/Changelogs/DecideVersionTests.cs
@@ -39,6 +39,17 @@ public async Task KeepsAVersionThatIsDeclaredButNotPublished()
await _prompt.DidNotReceive().Confirm(Arg.Any(), Arg.Any());
}
+ [Fact]
+ public async Task MovesPastAVersionThatShippedInPart()
+ {
+ // One package of 1.2.0 reached the feed and another didn't: the version is out in the
+ // world, so what's left of it is deploy's to finish and prepare must move past it.
+ var result = await Step().Run(Project("1.2.0"), Changelog(new ChangelogEntry { Fixed = ["A thing."] }), PartlyPublished(), TestContext.Current.CancellationToken);
+
+ result.Value.ShouldNotBeNull().Version.ShouldBe(NuGetVersion.Parse("1.2.1"));
+ result.Value.Bumped.ShouldBeTrue();
+ }
+
[Fact]
public async Task DerivesFromTheUnreleasedNotesAndConfirms()
{
@@ -87,7 +98,16 @@ private static Changelog Changelog(ChangelogEntry? unreleased = null) =>
new() { Entries = unreleased is null ? [] : [unreleased] };
private static ReleaseState Published() =>
- new(Published: true, LatestInLine: true, NuGetVersion.Parse("1.2.0"), NuGetVersion.Parse("1.2.0"));
+ new(Published: true, LatestInLine: true, NuGetVersion.Parse("1.2.0"), NuGetVersion.Parse("1.2.0"))
+ {
+ Packages = [new PackagePublication("My.Package", true)]
+ };
+
+ private static ReleaseState PartlyPublished() =>
+ new(Published: false, LatestInLine: true, NuGetVersion.Parse("1.2.0"), NuGetVersion.Parse("1.2.0"))
+ {
+ Packages = [new PackagePublication("My.Package", true), new PackagePublication("My.Package.Core", false)]
+ };
private static ReleaseState Unpublished() =>
new(Published: false, LatestInLine: true, NuGetVersion.Parse("1.2.0"), NuGetVersion.Parse("1.2.0"));
diff --git a/tests/Ritten.Tests/Changelogs/PrepareChangelogTests.cs b/tests/Ritten.Tests/Changelogs/PrepareChangelogTests.cs
index 123961d..349e1ca 100644
--- a/tests/Ritten.Tests/Changelogs/PrepareChangelogTests.cs
+++ b/tests/Ritten.Tests/Changelogs/PrepareChangelogTests.cs
@@ -1,4 +1,5 @@
using System.Text;
+using System.Text.RegularExpressions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
@@ -101,6 +102,24 @@ public async Task WritesNothingWhenTheChangelogAlreadySaysIt()
file.DidNotReceive().OpenWrite();
}
+ [Fact]
+ public async Task MergesIntoTheEntryTheVersionAlreadyHas()
+ {
+ // The version was prepared before and never shipped, so notes written since belong in the
+ // entry it already has: a second heading for the same version describes it twice.
+ SetChangelog(Existing);
+
+ var result = await Step().Run(Changelog(Existing), Project(), Prepared("1.2.0", bumped: false), TestContext.Current.CancellationToken);
+
+ result.IsFailure.ShouldBeFalse();
+ var written = Written();
+ Regex.Matches(written, "^## \\[1\\.2\\.0\\]", RegexOptions.Multiline).Count.ShouldBe(1);
+ written.ShouldContain("## [1.2.0] - 2026-08-21");
+ written.ShouldContain("- **A new thing.** It does something.");
+ written.ShouldContain("- **An old thing.** It was broken.");
+ written.ShouldNotContain("## [Unreleased]");
+ }
+
[Fact]
public async Task LeavesTheEntriesAloneWhenThereIsNothingUnreleased()
{