diff --git a/README.md b/README.md index d0aed72..94f8c7b 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ Each package has its own release history, tracked by tags named `-v F.Value; }\n"); + fixture.Write("src/R/Api.cs", "public static class R { public static int Value => F.Value; }\n"); + fixture.Write("src/A/Api.cs", "public static class A { public static int Value => L.Value + R.Value; }\n"); + fixture.Commit("chore: add package graph", ".gitignore", "global.json", "NuGet.Config", + "Directory.Build.props", "Packages.slnx", "src/F/F.csproj", "src/F/Api.cs", + "src/L/L.csproj", "src/L/Api.cs", "src/R/R.csproj", "src/R/Api.cs", + "src/A/A.csproj", "src/A/Api.cs", "src/U/U.csproj", "src/U/Api.cs"); + fixture.Tag("F", "1.2.3"); + fixture.Tag("L", "2.3.4"); + fixture.Tag("R", "5.6.7"); + fixture.Tag("A", "3.4.5"); + fixture.Tag("U", "4.5.6"); + fixture.Write("src/F/Api.cs", "public static class F { public static int Value => 43; }\n"); + fixture.Commit("feat!: change F API", "src/F/Api.cs"); + fixture.Write("src/L/Api.cs", "public static class L { public static int Value => F.Value + 1; }\n"); + fixture.Commit("feat: extend L API", "src/L/Api.cs"); + + string planPath = Path.Combine(fixture.DirectoryPath, "plan.json"); + (int planExitCode, string planOutput, string planError) = await Run(fixture, cancellationToken, + "plan", fixture.SolutionPath, "--repository", fixture.DirectoryPath, "--outfile", planPath); + using (Assert.Multiple()) + { + await Assert.That(planExitCode).IsEqualTo(0).Because(planError); + await Assert.That(planOutput).IsEmpty(); + } + using (JsonDocument plan = JsonDocument.Parse(await File.ReadAllTextAsync(planPath, cancellationToken))) + { + JsonElement[] packages = plan.RootElement.GetProperty("Packages").EnumerateArray().ToArray(); + string[] packageIds = packages.Select(package => package.GetProperty("PackageId").GetString()!).ToArray(); + using (Assert.Multiple()) + { + await Assert.That(packages.Select(package => ( + Id: package.GetProperty("PackageId").GetString()!, + Version: package.GetProperty("Version").GetString()!))) + .IsEquivalentTo(new (string Id, string Version)[] + { + ("F", "2.0.0"), ("L", "2.4.0"), ("R", "5.6.8"), ("A", "3.4.6"), + }); + await Assert.That(Array.IndexOf(packageIds, "F") < Array.IndexOf(packageIds, "L")).IsTrue(); + await Assert.That(Array.IndexOf(packageIds, "F") < Array.IndexOf(packageIds, "R")).IsTrue(); + await Assert.That(Array.IndexOf(packageIds, "L") < Array.IndexOf(packageIds, "A")).IsTrue(); + await Assert.That(Array.IndexOf(packageIds, "R") < Array.IndexOf(packageIds, "A")).IsTrue(); + await Assert.That(plan.RootElement.GetProperty("ReferencedPackages").GetArrayLength()).IsEqualTo(0); + await AssertDependencies(packages, "F"); + await AssertDependencies(packages, "L", "F"); + await AssertDependencies(packages, "R", "F"); + await AssertDependencies(packages, "A", "L", "R"); + } + } + + (int applyExitCode, string applyOutput, string applyError) = await Run(fixture, cancellationToken, + "apply", "--repository", fixture.DirectoryPath, "--infile", planPath, "--output", fixture.OutputPath); + await Assert.That(applyExitCode).IsEqualTo(0).Because(applyError); + string[] expectedPaths = [ + Path.Combine(fixture.OutputPath, "F.2.0.0.nupkg"), + Path.Combine(fixture.OutputPath, "L.2.4.0.nupkg"), + Path.Combine(fixture.OutputPath, "R.5.6.8.nupkg"), + Path.Combine(fixture.OutputPath, "A.3.4.6.nupkg"), + ]; + string[] expectedTags = ["F-v2.0.0", "L-v2.4.0", "R-v5.6.8", "A-v3.4.6"]; + await AssertApplyResult(fixture, applyOutput, expectedPaths, expectedTags); + using (Assert.Multiple()) + { + await PackageArchiveAssertions.AssertArchive(expectedPaths[0], "F", "2.0.0", cancellationToken); + await PackageArchiveAssertions.AssertArchive(expectedPaths[1], "L", "2.4.0", cancellationToken, ("F", "2.0.0")); + await PackageArchiveAssertions.AssertArchive(expectedPaths[2], "R", "5.6.8", cancellationToken, ("F", "2.0.0")); + await PackageArchiveAssertions.AssertArchive(expectedPaths[3], "A", "3.4.6", cancellationToken, + ("L", "2.4.0"), ("R", "5.6.8")); + await Assert.That(fixture.Git.Tags.Select(tag => tag.FriendlyName)).IsEquivalentTo(new[] + { + "F-v1.2.3", "L-v2.3.4", "R-v5.6.7", "A-v3.4.5", "U-v4.5.6", + "F-v2.0.0", "L-v2.4.0", "R-v5.6.8", "A-v3.4.6", + }); + } + } + + [Test] + public async Task BuiltCliAppliesConsumerOnlyThenEmptyRelease(CancellationToken cancellationToken) + { + using RepositoryFixture fixture = new(); + fixture.AddProject("F"); + fixture.AddProject("L"); + fixture.AddProject("A"); + fixture.AddProject("U"); + fixture.AddReference("L", "F"); + fixture.AddReference("A", "L"); + fixture.Write("src/L/Api.cs", "public static class L { public static int Value => F.Value; }\n"); + fixture.Write("src/A/Api.cs", "public static class A { public static int Value => L.Value; }\n"); + fixture.Commit("chore: add package chain", ".gitignore", "global.json", "NuGet.Config", + "Directory.Build.props", "Packages.slnx", "src/F/F.csproj", "src/F/Api.cs", + "src/L/L.csproj", "src/L/Api.cs", "src/A/A.csproj", "src/A/Api.cs", + "src/U/U.csproj", "src/U/Api.cs"); + fixture.Tag("F", "1.2.3"); + fixture.Tag("L", "2.3.4"); + fixture.Tag("A", "3.4.5"); + fixture.Tag("U", "4.5.6"); + fixture.Write("src/A/Api.cs", "public static class A { public static int Value => L.Value + 1; }\n"); + fixture.Commit("feat: extend A API", "src/A/Api.cs"); + + string planPath = Path.Combine(fixture.DirectoryPath, "plan.json"); + (int planExitCode, string planOutput, string planError) = await Run(fixture, cancellationToken, + "plan", fixture.SolutionPath, "--repository", fixture.DirectoryPath, "--outfile", planPath); + using (Assert.Multiple()) + { + await Assert.That(planExitCode).IsEqualTo(0).Because(planError); + await Assert.That(planOutput).IsEmpty(); + } + using (JsonDocument plan = JsonDocument.Parse(await File.ReadAllTextAsync(planPath, cancellationToken))) + { + JsonElement package = plan.RootElement.GetProperty("Packages").EnumerateArray().Single(); + JsonElement[] references = plan.RootElement.GetProperty("ReferencedPackages").EnumerateArray().ToArray(); + using (Assert.Multiple()) + { + await Assert.That(package.GetProperty("PackageId").GetString()).IsEqualTo("A"); + await Assert.That(package.GetProperty("Version").GetString()).IsEqualTo("3.5.0"); + await Assert.That(package.GetProperty("DependencyIds").EnumerateArray().Select(id => id.GetString()!)) + .IsEquivalentTo(new[] { "L" }); + await Assert.That(references.Select(reference => ( + Id: reference.GetProperty("PackageId").GetString()!, + Version: reference.GetProperty("Version").GetString()!))) + .IsEquivalentTo(new (string Id, string Version)[] { ("F", "1.2.3"), ("L", "2.3.4") }); + } + } + + (int applyExitCode, string applyOutput, string applyError) = await Run(fixture, cancellationToken, + "apply", "--repository", fixture.DirectoryPath, "--infile", planPath, "--output", fixture.OutputPath); + await Assert.That(applyExitCode).IsEqualTo(0).Because(applyError); + string archivePath = Path.Combine(fixture.OutputPath, "A.3.5.0.nupkg"); + await AssertApplyResult(fixture, applyOutput, [archivePath], ["A-v3.5.0"]); + using (Assert.Multiple()) + { + await PackageArchiveAssertions.AssertArchive(archivePath, "A", "3.5.0", cancellationToken, ("L", "2.3.4")); + await Assert.That(fixture.Git.Tags.Select(tag => tag.FriendlyName)) + .IsEquivalentTo(new[] { "F-v1.2.3", "L-v2.3.4", "A-v3.4.5", "U-v4.5.6", "A-v3.5.0" }); + } + byte[] archiveHash = SHA256.HashData(await File.ReadAllBytesAsync(archivePath, cancellationToken)); + string[] tagsBefore = fixture.Git.Tags.Select(tag => tag.FriendlyName).Order(StringComparer.Ordinal).ToArray(); + + (int repeatPlanExitCode, string repeatPlanOutput, string repeatPlanError) = await Run(fixture, cancellationToken, + "plan", fixture.SolutionPath, "--repository", fixture.DirectoryPath, "--outfile", planPath); + using (Assert.Multiple()) + { + await Assert.That(repeatPlanExitCode).IsEqualTo(0).Because(repeatPlanError); + await Assert.That(repeatPlanOutput).IsEmpty(); + } + using (JsonDocument repeatPlan = JsonDocument.Parse(await File.ReadAllTextAsync(planPath, cancellationToken))) + { + using (Assert.Multiple()) + { + await Assert.That(repeatPlan.RootElement.GetProperty("Packages").GetArrayLength()).IsEqualTo(0); + await Assert.That(repeatPlan.RootElement.GetProperty("ReferencedPackages").GetArrayLength()).IsEqualTo(0); + } + } + (int repeatApplyExitCode, string repeatApplyOutput, string repeatApplyError) = await Run(fixture, cancellationToken, + "apply", "--repository", fixture.DirectoryPath, "--infile", planPath, "--output", fixture.OutputPath); + await Assert.That(repeatApplyExitCode).IsEqualTo(0).Because(repeatApplyError); + using JsonDocument repeatResult = JsonDocument.Parse(repeatApplyOutput); + using (Assert.Multiple()) + { + await Assert.That(repeatResult.RootElement.GetProperty("PackagePaths").GetArrayLength()).IsEqualTo(0); + await Assert.That(repeatResult.RootElement.GetProperty("CreatedTags").GetArrayLength()).IsEqualTo(0); + await Assert.That(Directory.GetFiles(fixture.OutputPath)).IsEquivalentTo(new[] { archivePath }); + await Assert.That(SHA256.HashData(await File.ReadAllBytesAsync(archivePath, cancellationToken)) + .SequenceEqual(archiveHash)).IsTrue(); + await Assert.That(fixture.Git.Tags.Select(tag => tag.FriendlyName)).IsEquivalentTo(tagsBefore); + } + } + + private static async Task AssertDependencies(JsonElement[] packages, string packageId, params string[] dependencies) + { + JsonElement package = packages.Single(candidate => candidate.GetProperty("PackageId").GetString() == packageId); + await Assert.That(package.GetProperty("DependencyIds").EnumerateArray().Select(id => id.GetString()!)) + .IsEquivalentTo(dependencies); + } + + private static async Task AssertApplyResult(RepositoryFixture fixture, string output, + string[] expectedPaths, string[] expectedTags) + { + using JsonDocument document = JsonDocument.Parse(output); + string[] paths = document.RootElement.GetProperty("PackagePaths").EnumerateArray() + .Select(path => path.GetString()!).ToArray(); + string[] tags = document.RootElement.GetProperty("CreatedTags").EnumerateArray() + .Select(tag => tag.GetString()!).ToArray(); + using (Assert.Multiple()) + { + await Assert.That(paths).IsEquivalentTo(expectedPaths); + await Assert.That(Directory.GetFiles(fixture.OutputPath)).IsEquivalentTo(expectedPaths); + await Assert.That(tags).IsEquivalentTo(expectedTags); + foreach (string tag in expectedTags) + { + await Assert.That(fixture.Git.Tags[tag]?.Target.Sha).IsEqualTo(fixture.Git.Head.Tip!.Sha); + } + } + } + + private static async Task<(int ExitCode, string Output, string Error)> Run( + RepositoryFixture fixture, CancellationToken cancellationToken, params string[] arguments) + { + ProcessStartInfo startInfo = new("dotnet") + { + WorkingDirectory = fixture.DirectoryPath, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + startInfo.ArgumentList.Add(typeof(Program).Assembly.Location); + foreach (string argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using Process process = Process.Start(startInfo)!; + Task output = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task error = process.StandardError.ReadToEndAsync(cancellationToken); + try + { + await process.WaitForExitAsync(cancellationToken); + return (process.ExitCode, await output, await error); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(CancellationToken.None); + } + } + } +} diff --git a/src/PackageCascade.Tests.Integration/CoreOnly/Executing/GeneratedPlanIntegrationTests.cs b/src/PackageCascade.Tests.Integration/CoreOnly/Executing/GeneratedPlanIntegrationTests.cs new file mode 100644 index 0000000..8a5ae4b --- /dev/null +++ b/src/PackageCascade.Tests.Integration/CoreOnly/Executing/GeneratedPlanIntegrationTests.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.DependencyInjection; +using PackageCascade.Public.Definition; +using PackageCascade.Public.DependencyInjection; +using PackageCascade.Public.Executing; +using PackageCascade.Public.Planning; +using PackageCascade.Tests.Integration.Fixtures; + +namespace PackageCascade.Tests.Integration.CoreOnly.Executing; + +public sealed class GeneratedPlanIntegrationTests +{ + [Test] + public async Task PlannerAndExecutorReleaseInitialDependencyChain(CancellationToken cancellationToken) + { + using RepositoryFixture fixture = new(); + fixture.AddProject("F"); + fixture.AddProject("A"); + fixture.AddReference("A", "F"); + fixture.Write("src/A/Api.cs", "public static class A { public static int Value => F.Value; }\n"); + fixture.Commit("chore: add packages", ".gitignore", "global.json", "NuGet.Config", + "Directory.Build.props", "Packages.slnx", "src/F/F.csproj", "src/F/Api.cs", + "src/A/A.csproj", "src/A/Api.cs"); + + await using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); + IPackageCascadePlanner planner = provider.GetRequiredService(); + IPackageCascadeExecutor executor = provider.GetRequiredService(); + ReleasePlan plan = planner.Generate(fixture.DirectoryPath, fixture.SolutionPath); + using (Assert.Multiple()) + { + await Assert.That(plan.Packages.Select(package => (package.PackageId, package.Version))) + .IsEquivalentTo(new (string PackageId, string Version)[] { ("F", "0.1.0"), ("A", "0.1.0") }); + await Assert.That(plan.Packages[0].PackageId).IsEqualTo("F"); + await Assert.That(plan.Packages[0].DependencyIds).IsEmpty(); + await Assert.That(plan.Packages[1].DependencyIds).IsEquivalentTo(new[] { "F" }); + await Assert.That(plan.ReferencedPackages).IsEmpty(); + } + + ExecutionResult result = await executor.Execute(plan, fixture.DirectoryPath, + new ExecutionOptions(fixture.OutputPath, IncludeSymbols: false), cancellationToken); + string fArchive = Path.Combine(fixture.OutputPath, "F.0.1.0.nupkg"); + string aArchive = Path.Combine(fixture.OutputPath, "A.0.1.0.nupkg"); + using (Assert.Multiple()) + { + await Assert.That(result.PackagePaths).IsEquivalentTo(new[] { fArchive, aArchive }); + await Assert.That(Directory.GetFiles(fixture.OutputPath)).IsEquivalentTo(new[] { fArchive, aArchive }); + await Assert.That(result.CreatedTags).IsEquivalentTo(new[] { "F-v0.1.0", "A-v0.1.0" }); + await Assert.That(fixture.Git.Tags["F-v0.1.0"]?.Target.Sha).IsEqualTo(fixture.Git.Head.Tip!.Sha); + await Assert.That(fixture.Git.Tags["A-v0.1.0"]?.Target.Sha).IsEqualTo(fixture.Git.Head.Tip!.Sha); + } + using (Assert.Multiple()) + { + await PackageArchiveAssertions.AssertArchive(fArchive, "F", "0.1.0", cancellationToken); + await PackageArchiveAssertions.AssertArchive(aArchive, "A", "0.1.0", cancellationToken, ("F", "0.1.0")); + } + } +} diff --git a/src/PackageCascade.Tests.Integration/CoreOnly/Planning/PlannerIntegrationTests.cs b/src/PackageCascade.Tests.Integration/CoreOnly/Planning/PlannerIntegrationTests.cs index 9295772..cfbe6a2 100644 --- a/src/PackageCascade.Tests.Integration/CoreOnly/Planning/PlannerIntegrationTests.cs +++ b/src/PackageCascade.Tests.Integration/CoreOnly/Planning/PlannerIntegrationTests.cs @@ -1,75 +1,700 @@ using LibGit2Sharp; using Microsoft.Extensions.DependencyInjection; +using PackageCascade.Public.Definition; using PackageCascade.Public.DependencyInjection; using PackageCascade.Public.Planning; -using PackageCascade.Public.Definition; +using PackageCascade.Tests.Integration.Fixtures; namespace PackageCascade.Tests.Integration.CoreOnly.Planning; -public sealed class PlannerIntegrationTests : IDisposable +public sealed class PlannerIntegrationTests { - private readonly string _directory = Directory.CreateTempSubdirectory("packagecascade-planner-integration-").FullName; - private readonly Repository _repository; + [Test] + [Arguments("", SemverBump.None, "")] + [Arguments("chore: tidy source", SemverBump.None, "")] + [Arguments("fix: repair F", SemverBump.Patch, "1.2.4")] + [Arguments("feat: extend F", SemverBump.Minor, "1.3.0")] + [Arguments("feat!: replace F API", SemverBump.Major, "2.0.0")] + public async Task CalculatesDirectBumpFromTaggedHistory(string message, SemverBump bump, string expectedVersion) + { + using RepositoryFixture repository = new(); + repository.AddProject("F"); + repository.Commit("chore: add packages", "."); + repository.Tag("F", "1.2.3"); + Commit? change = null; + if (message.Length > 0) + { + repository.Write("src/F/Api.cs", "public static class F { public static int Value => 43; }\n"); + change = repository.Commit(message, "src/F/Api.cs"); + } - public PlannerIntegrationTests() + using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); + IPackageCascadePlanner planner = provider.GetRequiredService(); + ReleasePlan plan = planner.Generate(repository.DirectoryPath, repository.SolutionPath); + using (Assert.Multiple()) + { + await PlanAssertions.HasReferences(plan); + await Assert.That(plan.Configuration).IsEqualTo("Release"); + await Assert.That(plan.WorkingDirectory).IsEqualTo("."); + if (bump == SemverBump.None) + { + await PlanAssertions.HasReleases(plan); + } + else + { + await PlanAssertions.HasReleases(plan, ("F", expectedVersion, [])); + await PlanAssertions.HasReasons(plan, "F", new ConventionalCommitReason(bump, [change!.Sha])); + await Assert.That(plan.Packages.Single().ProjectPath).IsEqualTo("src/F/F.csproj"); + } + } + } + + [Test] + [Arguments("fix: change F", SemverBump.Patch, "1.2.4")] + [Arguments("feat: change F", SemverBump.Minor, "1.3.0")] + [Arguments("feat!: change F", SemverBump.Major, "2.0.0")] + public async Task FoundationChangePatchesAllTransitiveDependants(string message, SemverBump bump, string expectedVersion) { - Repository.Init(_directory); - _repository = new(_directory); + using RepositoryFixture repository = new(); + repository.AddProject("F"); + repository.AddProject("L"); + repository.AddProject("A"); + repository.AddProject("U"); + repository.AddReference("L", "F"); + repository.AddReference("A", "L"); + repository.Commit("chore: add packages", "."); + repository.Tag("F", "1.2.3"); + repository.Tag("L", "2.3.4"); + repository.Tag("A", "3.4.5"); + repository.Tag("U", "4.5.6"); + repository.Write("src/F/Api.cs", "public static class F { public static int Value => 43; }\n"); + Commit change = repository.Commit(message, "src/F/Api.cs"); + + using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); + IPackageCascadePlanner planner = provider.GetRequiredService(); + ReleasePlan plan = planner.Generate(repository.DirectoryPath, repository.SolutionPath); + using (Assert.Multiple()) + { + await PlanAssertions.HasReleases(plan, ("F", expectedVersion, []), ("L", "2.3.5", ["F"]), ("A", "3.4.6", ["L"])); + await PlanAssertions.HasReferences(plan); + await PlanAssertions.HasReasons(plan, "F", new ConventionalCommitReason(bump, [change.Sha])); + await PlanAssertions.HasReasons(plan, "L", new DependencyReleaseReason("F")); + await PlanAssertions.HasReasons(plan, "A", new DependencyReleaseReason("L")); + await PlanAssertions.Precedes(plan, "F", "L"); + await PlanAssertions.Precedes(plan, "L", "A"); + await Assert.That(plan.Configuration).IsEqualTo("Release"); + await Assert.That(plan.WorkingDirectory).IsEqualTo("."); + await Assert.That(plan.Packages.Select(package => (package.PackageId, package.ProjectPath))) + .IsEquivalentTo(plan.Packages.Select(package => (package.PackageId, $"src/{package.PackageId}/{package.PackageId}.csproj"))); + } } [Test] - public async Task PlansReleaseFromLatestTagThroughHeadUsingRegisteredPlanner() + public async Task EarlierRootSdkChangeRetainsMajorBumpAfterNestedGlobalJsonIsAdded() { - string projectDirectory = Directory.CreateDirectory(Path.Combine(_directory, "src", "Fixture")).FullName; - string solutionPath = Path.Combine(_directory, "Packages.slnx"); - await File.WriteAllTextAsync(solutionPath, """ - - - - """); - await File.WriteAllTextAsync(Path.Combine(projectDirectory, "Fixture.csproj"), """ - - - net11.0 - - - """); + using RepositoryFixture repository = new(); + repository.AddProject("F"); + repository.Write("solutions/nested/Packages.slnx", ""); + repository.Write("global.json", """ + {"sdk":{"version":"11.0.100-rc.2.26504.105","allowPrerelease":true}} + """); + repository.Commit("chore: initial project", "."); + repository.Tag("F", "1.2.3"); - Commit baseline = Commit("chore: initial project", "Packages.slnx", "src/Fixture/Fixture.csproj"); - _repository.Tags.Add("Fixture-v1.2.3", baseline); + repository.Write("global.json", """ + {"sdk":{"version":"11.0.100-rc.2.26504.105","allowPrerelease":true,"rollForward":"latestPatch"}} + """); + Commit sdkChange = repository.Commit("build!: change root SDK policy", "global.json"); + repository.Write("solutions/nested/global.json", """ + {"sdk":{"version":"11.0.100-rc.2.26504.105","allowPrerelease":true}} + """); + repository.Commit("chore: add nested SDK policy", "solutions/nested/global.json"); - await File.WriteAllTextAsync(Path.Combine(projectDirectory, "Api.cs"), "public static class Api { }\n"); - Commit("fix: repair API", "src/Fixture/Api.cs"); + using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); + IPackageCascadePlanner planner = provider.GetRequiredService(); + ReleasePlan plan = planner.Generate(repository.DirectoryPath, Path.Combine(repository.DirectoryPath, "solutions/nested/Packages.slnx")); + PlannedRelease release = plan.Packages.Single(); + + using (Assert.Multiple()) + { + await Assert.That(release.Version).IsEqualTo("2.0.0"); + await Assert.That(release.Reasons.Any(reason => reason is ConventionalCommitReason + { Bump: SemverBump.Major } commitReason && commitReason.CommitShas.Contains(sdkChange.Sha))).IsTrue(); + } + } - await using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); + [Test] + [Arguments("fix: change L", SemverBump.Patch, "2.3.5")] + [Arguments("feat: change L", SemverBump.Minor, "2.4.0")] + [Arguments("feat!: change L", SemverBump.Major, "3.0.0")] + public async Task MiddleChangeReleasesOnlyItsDependants(string message, SemverBump bump, string expectedVersion) + { + using RepositoryFixture repository = new(); + repository.AddProject("F"); + repository.AddProject("L"); + repository.AddProject("A"); + repository.AddProject("U"); + repository.AddReference("L", "F"); + repository.AddReference("A", "L"); + repository.Commit("chore: add packages", "."); + repository.Tag("F", "1.2.3"); + repository.Tag("L", "2.3.4"); + repository.Tag("A", "3.4.5"); + repository.Tag("U", "4.5.6"); + repository.Write("src/L/Api.cs", "public static class L { public static int Value => 43; }\n"); + Commit change = repository.Commit(message, "src/L/Api.cs"); + + using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); IPackageCascadePlanner planner = provider.GetRequiredService(); + ReleasePlan plan = planner.Generate(repository.DirectoryPath, repository.SolutionPath); + using (Assert.Multiple()) + { + await PlanAssertions.HasReleases(plan, ("L", expectedVersion, ["F"]), ("A", "3.4.6", ["L"])); + await PlanAssertions.HasReferences(plan, new ReferencedPackage("F", "src/F/F.csproj", "1.2.3")); + await PlanAssertions.HasReasons(plan, "L", new ConventionalCommitReason(bump, [change.Sha])); + await PlanAssertions.HasReasons(plan, "A", new DependencyReleaseReason("L")); + await PlanAssertions.Precedes(plan, "L", "A"); + await Assert.That(plan.Configuration).IsEqualTo("Release"); + await Assert.That(plan.WorkingDirectory).IsEqualTo("."); + await Assert.That(plan.Packages.Select(package => (package.PackageId, package.ProjectPath))) + .IsEquivalentTo(plan.Packages.Select(package => (package.PackageId, $"src/{package.PackageId}/{package.PackageId}.csproj"))); + } + } - ReleasePlan plan = planner.Generate(_directory, solutionPath); - PlannedRelease release = plan.Packages.Single(); + [Test] + [Arguments("fix: change A", SemverBump.Patch, "3.4.6")] + [Arguments("feat: change A", SemverBump.Minor, "3.5.0")] + [Arguments("feat!: change A", SemverBump.Major, "4.0.0")] + public async Task ConsumerChangeKeepsDependenciesAtTheirReleasedVersions(string message, SemverBump bump, string expectedVersion) + { + using RepositoryFixture repository = new(); + repository.AddProject("F"); + repository.AddProject("L"); + repository.AddProject("A"); + repository.AddProject("U"); + repository.AddReference("L", "F"); + repository.AddReference("A", "L"); + repository.Commit("chore: add packages", "."); + repository.Tag("F", "1.2.3"); + repository.Tag("L", "2.3.4"); + repository.Tag("A", "3.4.5"); + repository.Tag("U", "4.5.6"); + repository.Write("src/A/Api.cs", "public static class A { public static int Value => 43; }\n"); + Commit change = repository.Commit(message, "src/A/Api.cs"); + using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); + IPackageCascadePlanner planner = provider.GetRequiredService(); + ReleasePlan plan = planner.Generate(repository.DirectoryPath, repository.SolutionPath); using (Assert.Multiple()) { + await PlanAssertions.HasReleases(plan, ("A", expectedVersion, ["L"])); + await PlanAssertions.HasReferences(plan, new ReferencedPackage("F", "src/F/F.csproj", "1.2.3"), new ReferencedPackage("L", "src/L/L.csproj", "2.3.4")); + await PlanAssertions.HasReasons(plan, "A", new ConventionalCommitReason(bump, [change.Sha])); await Assert.That(plan.Configuration).IsEqualTo("Release"); - await Assert.That(release.PackageId).IsEqualTo("Fixture"); - await Assert.That(release.Version).IsEqualTo("1.2.4"); - await Assert.That(release.Reasons.Any(reason => reason is ConventionalCommitReason { Bump: SemverBump.Patch })).IsTrue(); + await Assert.That(plan.WorkingDirectory).IsEqualTo("."); + await Assert.That(plan.Packages.Select(package => (package.PackageId, package.ProjectPath))) + .IsEquivalentTo(plan.Packages.Select(package => (package.PackageId, $"src/{package.PackageId}/{package.PackageId}.csproj"))); } } - public void Dispose() + [Test] + public async Task DirectMinorWinsOverPropagatedPatchAndRetainsBothReasons() { - _repository.Dispose(); - Directory.Delete(_directory, recursive: true); + using RepositoryFixture repository = new(); + repository.AddProject("F"); + repository.AddProject("L"); + repository.AddProject("A"); + repository.AddProject("U"); + repository.AddReference("L", "F"); + repository.AddReference("A", "L"); + repository.Commit("chore: add packages", "."); + repository.Tag("F", "1.2.3"); + repository.Tag("L", "2.3.4"); + repository.Tag("A", "3.4.5"); + repository.Tag("U", "4.5.6"); + repository.Write("src/F/Api.cs", "public static class F { public static int Value => 43; }\n"); + Commit foundation = repository.Commit("feat!: replace F API", "src/F/Api.cs"); + repository.Write("src/L/Api.cs", "public static class L { public static int Value => 43; }\n"); + Commit library = repository.Commit("feat: extend L", "src/L/Api.cs"); + + using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); + IPackageCascadePlanner planner = provider.GetRequiredService(); + ReleasePlan plan = planner.Generate(repository.DirectoryPath, repository.SolutionPath); + using (Assert.Multiple()) + { + await PlanAssertions.HasReleases(plan, ("F", "2.0.0", []), ("L", "2.4.0", ["F"]), ("A", "3.4.6", ["L"])); + await PlanAssertions.HasReferences(plan); + await PlanAssertions.HasReasons(plan, "F", new ConventionalCommitReason(SemverBump.Major, [foundation.Sha])); + await PlanAssertions.HasReasons(plan, "L", new ConventionalCommitReason(SemverBump.Minor, [library.Sha]), new DependencyReleaseReason("F")); + await PlanAssertions.HasReasons(plan, "A", new DependencyReleaseReason("L")); + await PlanAssertions.Precedes(plan, "F", "L"); + await PlanAssertions.Precedes(plan, "L", "A"); + await Assert.That(plan.Configuration).IsEqualTo("Release"); + await Assert.That(plan.WorkingDirectory).IsEqualTo("."); + await Assert.That(plan.Packages.Select(package => (package.PackageId, package.ProjectPath))) + .IsEquivalentTo(plan.Packages.Select(package => (package.PackageId, $"src/{package.PackageId}/{package.PackageId}.csproj"))); + } + } + + [Test] + public async Task DirectMajorWinsOverTransitivePatchAndRetainsBothReasons() + { + using RepositoryFixture repository = new(); + repository.AddProject("F"); + repository.AddProject("L"); + repository.AddProject("A"); + repository.AddProject("U"); + repository.AddReference("L", "F"); + repository.AddReference("A", "L"); + repository.Commit("chore: add packages", "."); + repository.Tag("F", "1.2.3"); + repository.Tag("L", "2.3.4"); + repository.Tag("A", "3.4.5"); + repository.Tag("U", "4.5.6"); + repository.Write("src/F/Api.cs", "public static class F { public static int Value => 43; }\n"); + Commit foundation = repository.Commit("feat: extend F", "src/F/Api.cs"); + repository.Write("src/A/Api.cs", "public static class A { public static int Value => 43; }\n"); + Commit application = repository.Commit("feat!: replace A API", "src/A/Api.cs"); + + using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); + IPackageCascadePlanner planner = provider.GetRequiredService(); + ReleasePlan plan = planner.Generate(repository.DirectoryPath, repository.SolutionPath); + using (Assert.Multiple()) + { + await PlanAssertions.HasReleases(plan, ("F", "1.3.0", []), ("L", "2.3.5", ["F"]), ("A", "4.0.0", ["L"])); + await PlanAssertions.HasReferences(plan); + await PlanAssertions.HasReasons(plan, "F", new ConventionalCommitReason(SemverBump.Minor, [foundation.Sha])); + await PlanAssertions.HasReasons(plan, "L", new DependencyReleaseReason("F")); + await PlanAssertions.HasReasons(plan, "A", new ConventionalCommitReason(SemverBump.Major, [application.Sha]), new DependencyReleaseReason("L")); + await PlanAssertions.Precedes(plan, "F", "L"); + await PlanAssertions.Precedes(plan, "L", "A"); + await Assert.That(plan.Configuration).IsEqualTo("Release"); + await Assert.That(plan.WorkingDirectory).IsEqualTo("."); + await Assert.That(plan.Packages.Select(package => (package.PackageId, package.ProjectPath))) + .IsEquivalentTo(plan.Packages.Select(package => (package.PackageId, $"src/{package.PackageId}/{package.PackageId}.csproj"))); + } + } + + [Test] + public async Task DiamondFoundationChangeReleasesConvergingConsumerOnce() + { + using RepositoryFixture repository = new(); + repository.AddProject("F"); + repository.AddProject("L"); + repository.AddProject("R"); + repository.AddProject("A"); + repository.AddProject("U"); + repository.AddReference("L", "F"); + repository.AddReference("R", "F"); + repository.AddReference("A", "L"); + repository.AddReference("A", "R"); + repository.Commit("chore: add packages", "."); + repository.Tag("F", "1.2.3"); + repository.Tag("L", "2.3.4"); + repository.Tag("R", "5.6.7"); + repository.Tag("A", "3.4.5"); + repository.Tag("U", "4.5.6"); + repository.Write("src/F/Api.cs", "public static class F { public static int Value => 43; }\n"); + Commit change = repository.Commit("feat!: replace F API", "src/F/Api.cs"); + + using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); + IPackageCascadePlanner planner = provider.GetRequiredService(); + ReleasePlan plan = planner.Generate(repository.DirectoryPath, repository.SolutionPath); + using (Assert.Multiple()) + { + await PlanAssertions.HasReleases(plan, ("F", "2.0.0", []), ("L", "2.3.5", ["F"]), ("R", "5.6.8", ["F"]), ("A", "3.4.6", ["L", "R"])); + await PlanAssertions.HasReferences(plan); + await PlanAssertions.HasReasons(plan, "F", new ConventionalCommitReason(SemverBump.Major, [change.Sha])); + await PlanAssertions.HasReasons(plan, "L", new DependencyReleaseReason("F")); + await PlanAssertions.HasReasons(plan, "R", new DependencyReleaseReason("F")); + await PlanAssertions.HasReasons(plan, "A", new DependencyReleaseReason("L"), new DependencyReleaseReason("R")); + await PlanAssertions.Precedes(plan, "F", "L"); + await PlanAssertions.Precedes(plan, "F", "R"); + await PlanAssertions.Precedes(plan, "L", "A"); + await PlanAssertions.Precedes(plan, "R", "A"); + await Assert.That(plan.Configuration).IsEqualTo("Release"); + await Assert.That(plan.WorkingDirectory).IsEqualTo("."); + await Assert.That(plan.Packages.Select(package => (package.PackageId, package.ProjectPath))) + .IsEquivalentTo(plan.Packages.Select(package => (package.PackageId, $"src/{package.PackageId}/{package.PackageId}.csproj"))); + } } - private Commit Commit(string message, params string[] paths) + [Test] + public async Task DiamondBranchChangeLeavesSiblingUnreleased() { - foreach (string path in paths) + using RepositoryFixture repository = new(); + repository.AddProject("F"); + repository.AddProject("L"); + repository.AddProject("R"); + repository.AddProject("A"); + repository.AddProject("U"); + repository.AddReference("L", "F"); + repository.AddReference("R", "F"); + repository.AddReference("A", "L"); + repository.AddReference("A", "R"); + repository.Commit("chore: add packages", "."); + repository.Tag("F", "1.2.3"); + repository.Tag("L", "2.3.4"); + repository.Tag("R", "5.6.7"); + repository.Tag("A", "3.4.5"); + repository.Tag("U", "4.5.6"); + repository.Write("src/L/Api.cs", "public static class L { public static int Value => 43; }\n"); + Commit change = repository.Commit("feat: extend L", "src/L/Api.cs"); + + using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); + IPackageCascadePlanner planner = provider.GetRequiredService(); + ReleasePlan plan = planner.Generate(repository.DirectoryPath, repository.SolutionPath); + using (Assert.Multiple()) { - Commands.Stage(_repository, path); + await PlanAssertions.HasReleases(plan, ("L", "2.4.0", ["F"]), ("A", "3.4.6", ["L", "R"])); + await PlanAssertions.HasReferences(plan, new ReferencedPackage("F", "src/F/F.csproj", "1.2.3"), new ReferencedPackage("R", "src/R/R.csproj", "5.6.7")); + await PlanAssertions.HasReasons(plan, "L", new ConventionalCommitReason(SemverBump.Minor, [change.Sha])); + await PlanAssertions.HasReasons(plan, "A", new DependencyReleaseReason("L")); + await PlanAssertions.Precedes(plan, "L", "A"); + await Assert.That(plan.Configuration).IsEqualTo("Release"); + await Assert.That(plan.WorkingDirectory).IsEqualTo("."); + await Assert.That(plan.Packages.Select(package => (package.PackageId, package.ProjectPath))) + .IsEquivalentTo(plan.Packages.Select(package => (package.PackageId, $"src/{package.PackageId}/{package.PackageId}.csproj"))); } + } - Signature author = new("PackageCascade Tests", "tests@example.invalid", DateTimeOffset.UtcNow); - return _repository.Commit(message, author, author); + [Test] + public async Task BothDiamondBranchesPatchConsumerOnlyOnce() + { + using RepositoryFixture repository = new(); + repository.AddProject("F"); + repository.AddProject("L"); + repository.AddProject("R"); + repository.AddProject("A"); + repository.AddProject("U"); + repository.AddReference("L", "F"); + repository.AddReference("R", "F"); + repository.AddReference("A", "L"); + repository.AddReference("A", "R"); + repository.Commit("chore: add packages", "."); + repository.Tag("F", "1.2.3"); + repository.Tag("L", "2.3.4"); + repository.Tag("R", "5.6.7"); + repository.Tag("A", "3.4.5"); + repository.Tag("U", "4.5.6"); + repository.Write("src/L/Api.cs", "public static class L { public static int Value => 43; }\n"); + Commit left = repository.Commit("feat: extend L", "src/L/Api.cs"); + repository.Write("src/R/Api.cs", "public static class R { public static int Value => 43; }\n"); + Commit right = repository.Commit("feat!: replace R API", "src/R/Api.cs"); + + using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); + IPackageCascadePlanner planner = provider.GetRequiredService(); + ReleasePlan plan = planner.Generate(repository.DirectoryPath, repository.SolutionPath); + using (Assert.Multiple()) + { + await PlanAssertions.HasReleases(plan, ("L", "2.4.0", ["F"]), ("R", "6.0.0", ["F"]), ("A", "3.4.6", ["L", "R"])); + await PlanAssertions.HasReferences(plan, new ReferencedPackage("F", "src/F/F.csproj", "1.2.3")); + await PlanAssertions.HasReasons(plan, "L", new ConventionalCommitReason(SemverBump.Minor, [left.Sha])); + await PlanAssertions.HasReasons(plan, "R", new ConventionalCommitReason(SemverBump.Major, [right.Sha])); + await PlanAssertions.HasReasons(plan, "A", new DependencyReleaseReason("L"), new DependencyReleaseReason("R")); + await PlanAssertions.Precedes(plan, "L", "A"); + await PlanAssertions.Precedes(plan, "R", "A"); + await Assert.That(plan.Configuration).IsEqualTo("Release"); + await Assert.That(plan.WorkingDirectory).IsEqualTo("."); + await Assert.That(plan.Packages.Select(package => (package.PackageId, package.ProjectPath))) + .IsEquivalentTo(plan.Packages.Select(package => (package.PackageId, $"src/{package.PackageId}/{package.PackageId}.csproj"))); + } } + + [Test] + public async Task DisconnectedComponentDoesNotEnterReleaseOrReferenceSet() + { + using RepositoryFixture repository = new(); + repository.AddProject("F"); + repository.AddProject("L"); + repository.AddProject("A"); + repository.AddProject("U"); + repository.AddReference("L", "F"); + repository.AddReference("A", "U"); + repository.Commit("chore: add packages", "."); + repository.Tag("F", "1.2.3"); + repository.Tag("L", "2.3.4"); + repository.Tag("A", "3.4.5"); + repository.Tag("U", "4.5.6"); + repository.Write("src/F/Api.cs", "public static class F { public static int Value => 43; }\n"); + Commit change = repository.Commit("fix: repair F", "src/F/Api.cs"); + + using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); + IPackageCascadePlanner planner = provider.GetRequiredService(); + ReleasePlan plan = planner.Generate(repository.DirectoryPath, repository.SolutionPath); + using (Assert.Multiple()) + { + await PlanAssertions.HasReleases(plan, ("F", "1.2.4", []), ("L", "2.3.5", ["F"])); + await PlanAssertions.HasReferences(plan); + await PlanAssertions.HasReasons(plan, "F", new ConventionalCommitReason(SemverBump.Patch, [change.Sha])); + await PlanAssertions.HasReasons(plan, "L", new DependencyReleaseReason("F")); + await PlanAssertions.Precedes(plan, "F", "L"); + await Assert.That(plan.Configuration).IsEqualTo("Release"); + await Assert.That(plan.WorkingDirectory).IsEqualTo("."); + await Assert.That(plan.Packages.Select(package => (package.PackageId, package.ProjectPath))) + .IsEquivalentTo(plan.Packages.Select(package => (package.PackageId, $"src/{package.PackageId}/{package.PackageId}.csproj"))); + } + } + + [Test] + public async Task DisconnectedComponentsRetainIndependentBumpsAndReasons() + { + using RepositoryFixture repository = new(); + repository.AddProject("F"); + repository.AddProject("L"); + repository.AddProject("A"); + repository.AddProject("U"); + repository.AddReference("L", "F"); + repository.AddReference("A", "U"); + repository.Commit("chore: add packages", "."); + repository.Tag("F", "1.2.3"); + repository.Tag("L", "2.3.4"); + repository.Tag("A", "3.4.5"); + repository.Tag("U", "4.5.6"); + repository.Write("src/F/Api.cs", "public static class F { public static int Value => 43; }\n"); + Commit foundation = repository.Commit("fix: repair F", "src/F/Api.cs"); + repository.Write("src/U/Api.cs", "public static class U { public static int Value => 43; }\n"); + Commit unrelated = repository.Commit("feat: extend U", "src/U/Api.cs"); + + using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); + IPackageCascadePlanner planner = provider.GetRequiredService(); + ReleasePlan plan = planner.Generate(repository.DirectoryPath, repository.SolutionPath); + using (Assert.Multiple()) + { + await PlanAssertions.HasReleases(plan, ("F", "1.2.4", []), ("L", "2.3.5", ["F"]), ("U", "4.6.0", []), ("A", "3.4.6", ["U"])); + await PlanAssertions.HasReferences(plan); + await PlanAssertions.HasReasons(plan, "F", new ConventionalCommitReason(SemverBump.Patch, [foundation.Sha])); + await PlanAssertions.HasReasons(plan, "L", new DependencyReleaseReason("F")); + await PlanAssertions.HasReasons(plan, "U", new ConventionalCommitReason(SemverBump.Minor, [unrelated.Sha])); + await PlanAssertions.HasReasons(plan, "A", new DependencyReleaseReason("U")); + await PlanAssertions.Precedes(plan, "F", "L"); + await PlanAssertions.Precedes(plan, "U", "A"); + await Assert.That(plan.Configuration).IsEqualTo("Release"); + await Assert.That(plan.WorkingDirectory).IsEqualTo("."); + await Assert.That(plan.Packages.Select(package => (package.PackageId, package.ProjectPath))) + .IsEquivalentTo(plan.Packages.Select(package => (package.PackageId, $"src/{package.PackageId}/{package.PackageId}.csproj"))); + } + } + + [Test] + public async Task UntaggedChainStartsAtInitialVersionDespiteBreakingHistory() + { + using RepositoryFixture repository = new(); + repository.AddProject("F"); + repository.AddProject("L"); + repository.AddProject("A"); + repository.AddReference("L", "F"); + repository.AddReference("A", "L"); + repository.Commit("chore: add packages", "."); + repository.Write("src/F/Api.cs", "public static class F { public static int Value => 43; }\n"); + Commit change = repository.Commit("feat!: introduce F API", "src/F/Api.cs"); + + using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); + IPackageCascadePlanner planner = provider.GetRequiredService(); + ReleasePlan plan = planner.Generate(repository.DirectoryPath, repository.SolutionPath); + using (Assert.Multiple()) + { + await PlanAssertions.HasReleases(plan, ("F", "0.1.0", []), ("L", "0.1.0", ["F"]), ("A", "0.1.0", ["L"])); + await PlanAssertions.HasReferences(plan); + await PlanAssertions.HasReasons(plan, "F", new InitialReleaseReason()); + await PlanAssertions.HasReasons(plan, "L", new InitialReleaseReason(), new DependencyReleaseReason("F")); + await PlanAssertions.HasReasons(plan, "A", new InitialReleaseReason(), new DependencyReleaseReason("L")); + await PlanAssertions.Precedes(plan, "F", "L"); + await PlanAssertions.Precedes(plan, "L", "A"); + await Assert.That(plan.Configuration).IsEqualTo("Release"); + await Assert.That(plan.WorkingDirectory).IsEqualTo("."); + await Assert.That(plan.Packages.Select(package => (package.PackageId, package.ProjectPath))) + .IsEquivalentTo(plan.Packages.Select(package => (package.PackageId, $"src/{package.PackageId}/{package.PackageId}.csproj"))); + } + } + + [Test] + public async Task UntaggedFoundationPatchesTaggedDependants() + { + using RepositoryFixture repository = new(); + repository.AddProject("F"); + repository.AddProject("L"); + repository.AddProject("A"); + repository.AddProject("U"); + repository.AddReference("L", "F"); + repository.AddReference("A", "L"); + repository.Commit("chore: add packages", "."); + repository.Tag("L", "2.3.4"); + repository.Tag("A", "3.4.5"); + repository.Tag("U", "4.5.6"); + + using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); + IPackageCascadePlanner planner = provider.GetRequiredService(); + ReleasePlan plan = planner.Generate(repository.DirectoryPath, repository.SolutionPath); + using (Assert.Multiple()) + { + await PlanAssertions.HasReleases(plan, ("F", "0.1.0", []), ("L", "2.3.5", ["F"]), ("A", "3.4.6", ["L"])); + await PlanAssertions.HasReferences(plan); + await PlanAssertions.HasReasons(plan, "F", new InitialReleaseReason()); + await PlanAssertions.HasReasons(plan, "L", new DependencyReleaseReason("F")); + await PlanAssertions.HasReasons(plan, "A", new DependencyReleaseReason("L")); + await PlanAssertions.Precedes(plan, "F", "L"); + await PlanAssertions.Precedes(plan, "L", "A"); + await Assert.That(plan.Configuration).IsEqualTo("Release"); + await Assert.That(plan.WorkingDirectory).IsEqualTo("."); + await Assert.That(plan.Packages.Select(package => (package.PackageId, package.ProjectPath))) + .IsEquivalentTo(plan.Packages.Select(package => (package.PackageId, $"src/{package.PackageId}/{package.PackageId}.csproj"))); + } + } + + [Test] + public async Task UntaggedConsumerReferencesTaggedDependenciesWithoutReleasingThem() + { + using RepositoryFixture repository = new(); + repository.AddProject("F"); + repository.AddProject("L"); + repository.AddProject("A"); + repository.AddProject("U"); + repository.AddReference("L", "F"); + repository.AddReference("A", "L"); + repository.Commit("chore: add packages", "."); + repository.Tag("F", "1.2.3"); + repository.Tag("L", "2.3.4"); + repository.Tag("U", "4.5.6"); + + using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); + IPackageCascadePlanner planner = provider.GetRequiredService(); + ReleasePlan plan = planner.Generate(repository.DirectoryPath, repository.SolutionPath); + using (Assert.Multiple()) + { + await PlanAssertions.HasReleases(plan, ("A", "0.1.0", ["L"])); + await PlanAssertions.HasReferences(plan, new ReferencedPackage("F", "src/F/F.csproj", "1.2.3"), new ReferencedPackage("L", "src/L/L.csproj", "2.3.4")); + await PlanAssertions.HasReasons(plan, "A", new InitialReleaseReason()); + await Assert.That(plan.Configuration).IsEqualTo("Release"); + await Assert.That(plan.WorkingDirectory).IsEqualTo("."); + await Assert.That(plan.Packages.Select(package => (package.PackageId, package.ProjectPath))) + .IsEquivalentTo(plan.Packages.Select(package => (package.PackageId, $"src/{package.PackageId}/{package.PackageId}.csproj"))); + } + } + + [Test] + public async Task SeveralDirectCommitsUseStrongestBumpWithoutAccumulatingVersions() + { + using RepositoryFixture repository = new(); + repository.AddProject("F"); + repository.AddProject("L"); + repository.AddProject("A"); + repository.AddProject("U"); + repository.AddReference("L", "F"); + repository.AddReference("A", "L"); + repository.Commit("chore: add packages", "."); + repository.Tag("F", "1.2.3"); + repository.Tag("L", "2.3.4"); + repository.Tag("A", "3.4.5"); + repository.Tag("U", "4.5.6"); + repository.Write("src/F/Api.cs", "public static class F { public static int Value => 43; }\n"); + Commit patch = repository.Commit("fix: repair F", "src/F/Api.cs"); + repository.Write("src/F/Api.cs", "public static class F { public static int Value => 44; }\n"); + Commit minor = repository.Commit("feat: extend F", "src/F/Api.cs"); + repository.Write("src/F/Api.cs", "public static class F { public static int Value => 45; }\n"); + Commit major = repository.Commit("feat!: replace F API", "src/F/Api.cs"); + + using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); + IPackageCascadePlanner planner = provider.GetRequiredService(); + ReleasePlan plan = planner.Generate(repository.DirectoryPath, repository.SolutionPath); + using (Assert.Multiple()) + { + await PlanAssertions.HasReleases(plan, ("F", "2.0.0", []), ("L", "2.3.5", ["F"]), ("A", "3.4.6", ["L"])); + await PlanAssertions.HasReferences(plan); + await PlanAssertions.HasReasons(plan, "F", new ConventionalCommitReason(SemverBump.Major, [patch.Sha, minor.Sha, major.Sha])); + await PlanAssertions.HasReasons(plan, "L", new DependencyReleaseReason("F")); + await PlanAssertions.HasReasons(plan, "A", new DependencyReleaseReason("L")); + await PlanAssertions.Precedes(plan, "F", "L"); + await PlanAssertions.Precedes(plan, "L", "A"); + await Assert.That(plan.Configuration).IsEqualTo("Release"); + await Assert.That(plan.WorkingDirectory).IsEqualTo("."); + await Assert.That(plan.Packages.Select(package => (package.PackageId, package.ProjectPath))) + .IsEquivalentTo(plan.Packages.Select(package => (package.PackageId, $"src/{package.PackageId}/{package.PackageId}.csproj"))); + } + } + + [Test] + public async Task EachPackageUsesItsOwnLatestTagBaseline() + { + using RepositoryFixture repository = new(); + repository.AddProject("F"); + repository.AddProject("L"); + repository.AddReference("L", "F"); + repository.Commit("chore: add packages", "."); + repository.Tag("F", "1.2.3"); + repository.Tag("L", "2.3.4"); + repository.Write("src/F/Api.cs", "public static class F { public static int Value => 43; }\n"); + repository.Write("src/L/Api.cs", "public static class L { public static int Value => 43; }\n"); + Commit feature = repository.Commit("feat: extend both APIs", "src/F/Api.cs", "src/L/Api.cs"); + repository.Tag("L", "2.4.0"); + + using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); + IPackageCascadePlanner planner = provider.GetRequiredService(); + ReleasePlan plan = planner.Generate(repository.DirectoryPath, repository.SolutionPath); + using (Assert.Multiple()) + { + await PlanAssertions.HasReleases(plan, ("F", "1.3.0", []), ("L", "2.4.1", ["F"])); + await PlanAssertions.HasReferences(plan); + await PlanAssertions.HasReasons(plan, "F", new ConventionalCommitReason(SemverBump.Minor, [feature.Sha])); + await PlanAssertions.HasReasons(plan, "L", new DependencyReleaseReason("F")); + await PlanAssertions.Precedes(plan, "F", "L"); + await Assert.That(plan.Configuration).IsEqualTo("Release"); + await Assert.That(plan.WorkingDirectory).IsEqualTo("."); + await Assert.That(plan.Packages.Select(package => (package.PackageId, package.ProjectPath))) + .IsEquivalentTo(plan.Packages.Select(package => (package.PackageId, $"src/{package.PackageId}/{package.PackageId}.csproj"))); + } + } + + [Test] + public async Task FoundationPatchTraversesSixPackagesInDependencyOrder() + { + using RepositoryFixture repository = new(); + repository.AddProject("F"); + repository.AddProject("E"); + repository.AddProject("D"); + repository.AddProject("C"); + repository.AddProject("B"); + repository.AddProject("A"); + repository.AddProject("U"); + repository.AddReference("E", "F"); + repository.AddReference("D", "E"); + repository.AddReference("C", "D"); + repository.AddReference("B", "C"); + repository.AddReference("A", "B"); + repository.Commit("chore: add packages", "."); + repository.Tag("F", "1.2.3"); + repository.Tag("E", "2.3.4"); + repository.Tag("D", "3.4.5"); + repository.Tag("C", "4.5.6"); + repository.Tag("B", "5.6.7"); + repository.Tag("A", "6.7.8"); + repository.Tag("U", "7.8.9"); + repository.Write("src/F/Api.cs", "public static class F { public static int Value => 43; }\n"); + Commit change = repository.Commit("fix: repair F", "src/F/Api.cs"); + + using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); + IPackageCascadePlanner planner = provider.GetRequiredService(); + ReleasePlan plan = planner.Generate(repository.DirectoryPath, repository.SolutionPath); + using (Assert.Multiple()) + { + await PlanAssertions.HasReleases(plan, ("F", "1.2.4", []), ("E", "2.3.5", ["F"]), ("D", "3.4.6", ["E"]), ("C", "4.5.7", ["D"]), ("B", "5.6.8", ["C"]), ("A", "6.7.9", ["B"])); + await PlanAssertions.HasReferences(plan); + await PlanAssertions.HasReasons(plan, "F", new ConventionalCommitReason(SemverBump.Patch, [change.Sha])); + await PlanAssertions.HasReasons(plan, "E", new DependencyReleaseReason("F")); + await PlanAssertions.HasReasons(plan, "D", new DependencyReleaseReason("E")); + await PlanAssertions.HasReasons(plan, "C", new DependencyReleaseReason("D")); + await PlanAssertions.HasReasons(plan, "B", new DependencyReleaseReason("C")); + await PlanAssertions.HasReasons(plan, "A", new DependencyReleaseReason("B")); + await PlanAssertions.Precedes(plan, "F", "E"); + await PlanAssertions.Precedes(plan, "E", "D"); + await PlanAssertions.Precedes(plan, "D", "C"); + await PlanAssertions.Precedes(plan, "C", "B"); + await PlanAssertions.Precedes(plan, "B", "A"); + await Assert.That(plan.Configuration).IsEqualTo("Release"); + await Assert.That(plan.WorkingDirectory).IsEqualTo("."); + await Assert.That(plan.Packages.Select(package => (package.PackageId, package.ProjectPath))) + .IsEquivalentTo(plan.Packages.Select(package => (package.PackageId, $"src/{package.PackageId}/{package.PackageId}.csproj"))); + } + } + } diff --git a/src/PackageCascade.Tests.Integration/CoreOnly/Planning/RepositoryPlanningIntegrationTests.cs b/src/PackageCascade.Tests.Integration/CoreOnly/Planning/RepositoryPlanningIntegrationTests.cs new file mode 100644 index 0000000..1537f9b --- /dev/null +++ b/src/PackageCascade.Tests.Integration/CoreOnly/Planning/RepositoryPlanningIntegrationTests.cs @@ -0,0 +1,280 @@ +using System.Xml.Linq; +using LibGit2Sharp; +using Microsoft.Extensions.DependencyInjection; +using PackageCascade.Public.Definition; +using PackageCascade.Public.DependencyInjection; +using PackageCascade.Public.Planning; +using PackageCascade.Tests.Integration.Fixtures; + +namespace PackageCascade.Tests.Integration.CoreOnly.Planning; + +public sealed class RepositoryPlanningIntegrationTests +{ + [Arguments("chore: update shared build properties", SemverBump.Patch, "1.2.4", "2.3.5", "3.4.6", "4.5.7")] + [Arguments("feat!: change shared build contract", SemverBump.Major, "2.0.0", "3.0.0", "4.0.0", "5.0.0")] + [Test] + public async Task SharedRootChangeReleasesEveryPackageWithItsOwnBaseline( + string message, SemverBump bump, string foundationVersion, string libraryVersion, + string appVersion, string unrelatedVersion) + { + using RepositoryFixture fixture = new(); + fixture.AddProject("Foundation"); + fixture.AddProject("Library"); + fixture.AddProject("App"); + fixture.AddProject("Unrelated"); + fixture.AddReference("Library", "Foundation"); + fixture.AddReference("App", "Library"); + fixture.Commit("chore: add package graph", "."); + fixture.Tag("Foundation", "1.2.3"); + fixture.Tag("Library", "2.3.4"); + fixture.Tag("App", "3.4.5"); + fixture.Tag("Unrelated", "4.5.6"); + + fixture.Write("Directory.Build.props", "false1.0.0"); + Commit changed = fixture.Commit(message, "Directory.Build.props"); + ReleasePlan plan = Generate(fixture); + + using (Assert.Multiple()) + { + await AssertPlan(plan, "Release", ("Foundation", "Foundation"), ("Library", "Library"), + ("App", "App"), ("Unrelated", "Unrelated")); + await PlanAssertions.HasReleases(plan, + ("Foundation", foundationVersion, []), ("Library", libraryVersion, ["Foundation"]), + ("App", appVersion, ["Library"]), ("Unrelated", unrelatedVersion, [])); + await PlanAssertions.HasReasons(plan, "Foundation", new ConventionalCommitReason(bump, [changed.Sha])); + await PlanAssertions.HasReasons(plan, "Library", new ConventionalCommitReason(bump, [changed.Sha]), + new DependencyReleaseReason("Foundation")); + await PlanAssertions.HasReasons(plan, "App", new ConventionalCommitReason(bump, [changed.Sha]), + new DependencyReleaseReason("Library")); + await PlanAssertions.HasReasons(plan, "Unrelated", new ConventionalCommitReason(bump, [changed.Sha])); + await PlanAssertions.Precedes(plan, "Foundation", "Library"); + await PlanAssertions.Precedes(plan, "Library", "App"); + } + } + + [Test] + public async Task SharedPatchAndLocalFeatureKeepTheirDistinctDirectReasons() + { + using RepositoryFixture fixture = new(); + fixture.AddProject("Foundation"); + fixture.AddProject("Library"); + fixture.AddProject("App"); + fixture.AddProject("Unrelated"); + fixture.AddReference("Library", "Foundation"); + fixture.AddReference("App", "Library"); + fixture.Commit("chore: add package graph", "."); + fixture.Tag("Foundation", "1.2.3"); + fixture.Tag("Library", "2.3.4"); + fixture.Tag("App", "3.4.5"); + fixture.Tag("Unrelated", "4.5.6"); + + fixture.Write("Directory.Build.props", "false1.0.0"); + Commit shared = fixture.Commit("chore: update shared build properties", "Directory.Build.props"); + fixture.Write("src/Library/Api.cs", "public static class Library { public static int Value => 43; }\n"); + Commit feature = fixture.Commit("feat: extend library API", "src/Library/Api.cs"); + ReleasePlan plan = Generate(fixture); + + using (Assert.Multiple()) + { + await AssertPlan(plan, "Release", ("Foundation", "Foundation"), ("Library", "Library"), + ("App", "App"), ("Unrelated", "Unrelated")); + await PlanAssertions.HasReleases(plan, + ("Foundation", "1.2.4", []), ("Library", "2.4.0", ["Foundation"]), + ("App", "3.4.6", ["Library"]), ("Unrelated", "4.5.7", [])); + await PlanAssertions.HasReasons(plan, "Foundation", new ConventionalCommitReason(SemverBump.Patch, [shared.Sha])); + await PlanAssertions.HasReasons(plan, "Library", new ConventionalCommitReason(SemverBump.Minor, [shared.Sha, feature.Sha]), + new DependencyReleaseReason("Foundation")); + await PlanAssertions.HasReasons(plan, "App", new ConventionalCommitReason(SemverBump.Patch, [shared.Sha]), + new DependencyReleaseReason("Library")); + await PlanAssertions.HasReasons(plan, "Unrelated", new ConventionalCommitReason(SemverBump.Patch, [shared.Sha])); + await PlanAssertions.Precedes(plan, "Foundation", "Library"); + await PlanAssertions.Precedes(plan, "Library", "App"); + } + } + + [Test] + public async Task PrivateReferencePropagatesReleaseWithoutPackageDependency() + { + using RepositoryFixture fixture = new(); + fixture.AddProject("Foundation"); + fixture.AddProject("Library"); + fixture.AddReference("Library", "Foundation", privateAssets: true); + fixture.Commit("chore: add package graph", "."); + fixture.Tag("Foundation", "1.2.3"); + fixture.Tag("Library", "2.3.4"); + + fixture.Write("src/Foundation/Api.cs", "public static class Foundation { public static int Value => 43; }\n"); + Commit feature = fixture.Commit("feat: extend foundation API", "src/Foundation/Api.cs"); + ReleasePlan plan = Generate(fixture); + + using (Assert.Multiple()) + { + await AssertPlan(plan, "Release", ("Foundation", "Foundation"), ("Library", "Library")); + await PlanAssertions.HasReleases(plan, ("Foundation", "1.3.0", []), ("Library", "2.3.5", [])); + await PlanAssertions.HasReasons(plan, "Foundation", new ConventionalCommitReason(SemverBump.Minor, [feature.Sha])); + await PlanAssertions.HasReasons(plan, "Library", new DependencyReleaseReason("Foundation")); + await PlanAssertions.Precedes(plan, "Foundation", "Library"); + } + } + + [Test] + public async Task ConditionalReferenceChangesOnlyTheSelectedConfiguration() + { + using RepositoryFixture fixture = new(); + fixture.AddProject("Foundation"); + fixture.AddProject("Library"); + fixture.AddReference("Library", "Foundation", configuration: "Staging"); + XDocument solution = XDocument.Load(fixture.SolutionPath); + solution.Root!.AddFirst(new XElement("Configurations", + new XElement("BuildType", new XAttribute("Name", "Release")), + new XElement("BuildType", new XAttribute("Name", "Staging")))); + solution.Save(fixture.SolutionPath); + fixture.Commit("chore: add package graph", "."); + fixture.Tag("Foundation", "1.2.3"); + fixture.Tag("Library", "2.3.4"); + + fixture.Write("src/Foundation/Api.cs", "public static class Foundation { public static int Value => 43; }\n"); + Commit fix = fixture.Commit("fix: repair foundation API", "src/Foundation/Api.cs"); + ReleasePlan release = Generate(fixture); + ReleasePlan staging = Generate(fixture, "Staging"); + + using (Assert.Multiple()) + { + await AssertPlan(release, "Release", ("Foundation", "Foundation")); + await PlanAssertions.HasReleases(release, ("Foundation", "1.2.4", [])); + await PlanAssertions.HasReasons(release, "Foundation", new ConventionalCommitReason(SemverBump.Patch, [fix.Sha])); + await AssertPlan(staging, "Staging", ("Foundation", "Foundation"), ("Library", "Library")); + await PlanAssertions.HasReleases(staging, ("Foundation", "1.2.4", []), ("Library", "2.3.5", ["Foundation"])); + await PlanAssertions.HasReasons(staging, "Foundation", new ConventionalCommitReason(SemverBump.Patch, [fix.Sha])); + await PlanAssertions.HasReasons(staging, "Library", new DependencyReleaseReason("Foundation")); + await PlanAssertions.Precedes(staging, "Foundation", "Library"); + } + } + + [Test] + public async Task EvaluatedPackageIdControlsTagLookupAndDependencyIdentity() + { + using RepositoryFixture fixture = new(); + fixture.AddProject("FoundationProject", "Foundation.Package"); + fixture.AddProject("LibraryProject", "Library.Package"); + fixture.AddReference("LibraryProject", "FoundationProject"); + fixture.Commit("chore: add package graph", "."); + fixture.Tag("Foundation.Package", "1.2.3"); + fixture.Tag("Library.Package", "2.3.4"); + + fixture.Write("src/FoundationProject/Api.cs", "public static class FoundationProject { public static int Value => 43; }\n"); + Commit feature = fixture.Commit("feat: extend foundation API", "src/FoundationProject/Api.cs"); + ReleasePlan plan = Generate(fixture); + + using (Assert.Multiple()) + { + await AssertPlan(plan, "Release", ("Foundation.Package", "FoundationProject"), + ("Library.Package", "LibraryProject")); + await PlanAssertions.HasReleases(plan, ("Foundation.Package", "1.3.0", []), + ("Library.Package", "2.3.5", ["Foundation.Package"])); + await PlanAssertions.HasReasons(plan, "Foundation.Package", new ConventionalCommitReason(SemverBump.Minor, [feature.Sha])); + await PlanAssertions.HasReasons(plan, "Library.Package", new DependencyReleaseReason("Foundation.Package")); + await PlanAssertions.Precedes(plan, "Foundation.Package", "Library.Package"); + } + } + + [Test] + public async Task MultilineBreakingFooterCausesMajorReleaseAndDependantPatch() + { + using RepositoryFixture fixture = new(); + fixture.AddProject("Foundation"); + fixture.AddProject("Library"); + fixture.AddReference("Library", "Foundation"); + fixture.Commit("chore: add package graph", "."); + fixture.Tag("Foundation", "1.2.3"); + fixture.Tag("Library", "2.3.4"); + + fixture.Write("src/Foundation/Api.cs", "public static class Foundation { public static int Value => 43; }\n"); + Commit breaking = fixture.Commit("feat: change foundation API\n\nBREAKING CHANGE: callers must use the new API", "src/Foundation/Api.cs"); + ReleasePlan plan = Generate(fixture); + + using (Assert.Multiple()) + { + await AssertPlan(plan, "Release", ("Foundation", "Foundation"), ("Library", "Library")); + await PlanAssertions.HasReleases(plan, ("Foundation", "2.0.0", []), ("Library", "2.3.5", ["Foundation"])); + await PlanAssertions.HasReasons(plan, "Foundation", new ConventionalCommitReason(SemverBump.Major, [breaking.Sha])); + await PlanAssertions.HasReasons(plan, "Library", new DependencyReleaseReason("Foundation")); + await PlanAssertions.Precedes(plan, "Foundation", "Library"); + } + } + + [Test] + public async Task SiblingTestsAndWorkflowChangesDoNotReleasePackage() + { + using RepositoryFixture fixture = new(); + fixture.AddProject("Foundation"); + fixture.Commit("chore: add foundation", "."); + fixture.Tag("Foundation", "1.2.3"); + + fixture.Write("src/Foundation.Tests/ApiTests.cs", "public static class ApiTests { }\n"); + fixture.Write(".github/workflows/ci.yml", "name: CI\n"); + fixture.Commit("feat!: change test and CI contract", "src/Foundation.Tests/ApiTests.cs", ".github/workflows/ci.yml"); + ReleasePlan plan = Generate(fixture); + + using (Assert.Multiple()) + { + await AssertPlan(plan, "Release"); + await PlanAssertions.HasReleases(plan); + } + } + + [Test] + public async Task UnreachableHigherVersionTagDoesNotReplaceReachableBaseline() + { + using RepositoryFixture fixture = new(); + fixture.AddProject("Foundation"); + fixture.AddProject("Library"); + fixture.AddReference("Library", "Foundation"); + fixture.Commit("chore: add package graph", "."); + fixture.Tag("Foundation", "1.2.3"); + fixture.Tag("Library", "2.3.4"); + Branch original = fixture.Git.Head; + Branch alternate = fixture.Git.CreateBranch("alternate-release"); + Commands.Checkout(fixture.Git, alternate); + fixture.Write("other-branch.txt", "unreachable release\n"); + fixture.Commit("chore: alternate release", "other-branch.txt"); + fixture.Tag("Foundation", "9.0.0"); + Commands.Checkout(fixture.Git, original); + + fixture.Write("src/Foundation/Api.cs", "public static class Foundation { public static int Value => 43; }\n"); + Commit feature = fixture.Commit("feat: extend foundation API", "src/Foundation/Api.cs"); + ReleasePlan plan = Generate(fixture); + + using (Assert.Multiple()) + { + await AssertPlan(plan, "Release", ("Foundation", "Foundation"), ("Library", "Library")); + await PlanAssertions.HasReleases(plan, ("Foundation", "1.3.0", []), ("Library", "2.3.5", ["Foundation"])); + await PlanAssertions.HasReasons(plan, "Foundation", new ConventionalCommitReason(SemverBump.Minor, [feature.Sha])); + await PlanAssertions.HasReasons(plan, "Library", new DependencyReleaseReason("Foundation")); + await PlanAssertions.Precedes(plan, "Foundation", "Library"); + } + } + + private static ReleasePlan Generate(RepositoryFixture fixture, string configuration = "Release") + { + using ServiceProvider provider = new ServiceCollection().AddPackageCascade().BuildServiceProvider(); + IPackageCascadePlanner planner = provider.GetRequiredService(); + return planner.Generate(fixture.DirectoryPath, fixture.SolutionPath, configuration); + } + + private static async Task AssertPlan(ReleasePlan plan, string configuration, + params (string PackageId, string ProjectName)[] projects) + { + using (Assert.Multiple()) + { + await Assert.That(plan.Configuration).IsEqualTo(configuration); + await Assert.That(plan.WorkingDirectory).IsEqualTo("."); + await PlanAssertions.HasReferences(plan); + foreach ((string packageId, string projectName) in projects) + { + PlannedRelease release = plan.Packages.Single(package => package.PackageId == packageId); + await Assert.That(release.ProjectPath).IsEqualTo($"src/{projectName}/{projectName}.csproj"); + } + } + } +} diff --git a/src/PackageCascade.Tests.Integration/Fixtures/PackageArchiveAssertions.cs b/src/PackageCascade.Tests.Integration/Fixtures/PackageArchiveAssertions.cs new file mode 100644 index 0000000..3f3f111 --- /dev/null +++ b/src/PackageCascade.Tests.Integration/Fixtures/PackageArchiveAssertions.cs @@ -0,0 +1,39 @@ +using System.IO.Compression; +using System.Xml; +using System.Xml.Linq; +using NuGet.Versioning; + +namespace PackageCascade.Tests.Integration.Fixtures; + +internal static class PackageArchiveAssertions +{ + public static async Task AssertArchive(string path, string packageId, string version, + CancellationToken cancellationToken, params (string Id, string Version)[] expectedDependencies) + { + await using FileStream file = new(path, FileMode.Open, FileAccess.Read, FileShare.Read, + bufferSize: 4096, options: FileOptions.Asynchronous); + await using ZipArchive archive = await ZipArchive.CreateAsync(file, ZipArchiveMode.Read, + leaveOpen: true, entryNameEncoding: null, cancellationToken: cancellationToken); + ZipArchiveEntry manifest = archive.Entries.Single(entry => entry.FullName.EndsWith(".nuspec", StringComparison.Ordinal)); + await using Stream stream = await manifest.OpenAsync(cancellationToken); + using XmlReader reader = XmlReader.Create(stream, new XmlReaderSettings { Async = true }); + XDocument document = await XDocument.LoadAsync(reader, LoadOptions.None, cancellationToken); + XElement metadata = document.Root!.Elements().Single(element => element.Name.LocalName == "metadata"); + XElement[] dependencies = metadata.Descendants().Where(element => element.Name.LocalName == "dependency").ToArray(); + + using (Assert.Multiple()) + { + await Assert.That(metadata.Elements().Single(element => element.Name.LocalName == "id").Value).IsEqualTo(packageId); + await Assert.That(metadata.Elements().Single(element => element.Name.LocalName == "version").Value).IsEqualTo(version); + await Assert.That(dependencies.Select(element => element.Attribute("id")!.Value)) + .IsEquivalentTo(expectedDependencies.Select(dependency => dependency.Id)); + foreach ((string id, string dependencyVersion) in expectedDependencies) + { + XElement dependency = dependencies.Single(element => (string?)element.Attribute("id") == id); + VersionRange range = VersionRange.Parse(dependency.Attribute("version")!.Value); + await Assert.That(range.MinVersion).IsEqualTo(NuGetVersion.Parse(dependencyVersion)); + await Assert.That(range.IsMinInclusive).IsTrue(); + } + } + } +} diff --git a/src/PackageCascade.Tests.Integration/Fixtures/PlanAssertions.cs b/src/PackageCascade.Tests.Integration/Fixtures/PlanAssertions.cs new file mode 100644 index 0000000..2c951fe --- /dev/null +++ b/src/PackageCascade.Tests.Integration/Fixtures/PlanAssertions.cs @@ -0,0 +1,61 @@ +using PackageCascade.Public.Definition; + +namespace PackageCascade.Tests.Integration.Fixtures; + +internal static class PlanAssertions +{ + public static async Task HasReleases(ReleasePlan plan, + params (string Id, string Version, string[] Dependencies)[] expected) + { + using (Assert.Multiple()) + { + await Assert.That(plan.Packages.Select(package => (package.PackageId, package.Version))) + .IsEquivalentTo(expected.Select(package => (package.Id, package.Version))); + foreach ((string id, string _, string[] dependencies) in expected) + { + PlannedRelease release = plan.Packages.Single(package => package.PackageId == id); + await Assert.That(release.DependencyIds).IsEquivalentTo(dependencies); + } + } + } + + public static async Task HasReferences(ReleasePlan plan, params ReferencedPackage[] expected) + => await Assert.That(plan.ReferencedPackages).IsEquivalentTo(expected); + + public static async Task HasReasons(ReleasePlan plan, string packageId, params ReleaseReason[] expected) + { + IReadOnlyList actual = plan.Packages.Single(package => package.PackageId == packageId).Reasons; + using (Assert.Multiple()) + { + await Assert.That(actual.Count).IsEqualTo(expected.Length); + foreach (ReleaseReason reason in expected) + { + switch (reason) + { + case InitialReleaseReason: + await Assert.That(actual.Count(item => item is InitialReleaseReason)).IsEqualTo(1); + break; + case DependencyReleaseReason dependency: + await Assert.That(actual.Count(item => item is DependencyReleaseReason value && value.PackageId == dependency.PackageId)).IsEqualTo(1); + break; + case ConventionalCommitReason commit: + ConventionalCommitReason direct = actual.Select(item => item is ConventionalCommitReason value ? value : null) + .OfType().Single(); + await Assert.That(direct.Bump).IsEqualTo(commit.Bump); + await Assert.That(direct.CommitShas).IsEquivalentTo(commit.CommitShas); + break; + } + } + } + } + + public static async Task Precedes(ReleasePlan plan, string dependency, string consumer) + { + string[] ids = plan.Packages.Select(package => package.PackageId).ToArray(); + using (Assert.Multiple()) + { + await Assert.That(Array.IndexOf(ids, dependency)).IsGreaterThanOrEqualTo(0); + await Assert.That(Array.IndexOf(ids, consumer)).IsGreaterThan(Array.IndexOf(ids, dependency)); + } + } +} diff --git a/src/PackageCascade.Tests.Integration/Fixtures/RepositoryFixture.cs b/src/PackageCascade.Tests.Integration/Fixtures/RepositoryFixture.cs new file mode 100644 index 0000000..3585773 --- /dev/null +++ b/src/PackageCascade.Tests.Integration/Fixtures/RepositoryFixture.cs @@ -0,0 +1,88 @@ +using System.Xml.Linq; +using LibGit2Sharp; + +namespace PackageCascade.Tests.Integration.Fixtures; + +internal sealed class RepositoryFixture : IDisposable +{ + public string DirectoryPath { get; } = Directory.CreateTempSubdirectory("packagecascade-scenario-").FullName; + public string SolutionPath => Path.Combine(DirectoryPath, "Packages.slnx"); + public string OutputPath => Path.Combine(DirectoryPath, "packages"); + public Repository Git { get; } + + public RepositoryFixture() + { + Repository.Init(DirectoryPath); + Git = new(DirectoryPath); + Write(".gitignore", "**/bin/\n**/obj/\npackages/\nplan.json\n"); + Write("global.json", """ + {"sdk":{"version":"11.0.100-rc.2.26504.105","allowPrerelease":true,"rollForward":"latestPatch"}} + """); + Write("NuGet.Config", ""); + Write("Directory.Build.props", "false"); + new XDocument(new XElement("Solution")).Save(SolutionPath); + } + + public void AddProject(string name, string? packageId = null) + { + XElement properties = new("PropertyGroup", + new XElement("TargetFramework", "net11.0"), + new XElement("DebugType", "portable")); + if (packageId is not null) + { + properties.Add(new XElement("PackageId", packageId)); + } + + Write($"src/{name}/{name}.csproj", + new XElement("Project", new XAttribute("Sdk", "Microsoft.NET.Sdk"), properties).ToString()); + Write($"src/{name}/Api.cs", $"public static class {name} {{ public static int Value => 42; }}\n"); + XDocument solution = XDocument.Load(SolutionPath); + solution.Root!.Add(new XElement("Project", new XAttribute("Path", $"src/{name}/{name}.csproj"))); + solution.Save(SolutionPath); + } + + public void AddReference(string consumer, string dependency, bool privateAssets = false, string? configuration = null) + { + string projectPath = Path.Combine(DirectoryPath, "src", consumer, consumer + ".csproj"); + XDocument project = XDocument.Load(projectPath); + XElement reference = new("ProjectReference", new XAttribute("Include", $"../{dependency}/{dependency}.csproj")); + if (privateAssets) + { + reference.Add(new XAttribute("PrivateAssets", "all")); + } + + if (configuration is not null) + { + reference.Add(new XAttribute("Condition", $"'$(Configuration)' == '{configuration}'")); + } + + project.Root!.Add(new XElement("ItemGroup", reference)); + project.Save(projectPath); + } + + public void Write(string relativePath, string contents) + { + string path = Path.Combine(DirectoryPath, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, contents); + } + + public Commit Commit(string message, params string[] paths) + { + foreach (string path in paths) + { + Commands.Stage(Git, path); + } + + Signature author = new("PackageCascade Tests", "tests@example.invalid", DateTimeOffset.UtcNow); + return Git.Commit(message, author, author); + } + + public void Tag(string packageId, string version) => Git.Tags.Add($"{packageId}-v{version}", Git.Head.Tip); + + public void Dispose() + { + Git.Dispose(); + Directory.Delete(DirectoryPath, recursive: true); + } +} diff --git a/src/PackageCascade.Tests.Unit/Internal/Planning/Git/GitCommitReaderTests.cs b/src/PackageCascade.Tests.Unit/Internal/Planning/Git/GitCommitReaderTests.cs index 87bd5ab..0db9c0c 100644 --- a/src/PackageCascade.Tests.Unit/Internal/Planning/Git/GitCommitReaderTests.cs +++ b/src/PackageCascade.Tests.Unit/Internal/Planning/Git/GitCommitReaderTests.cs @@ -22,7 +22,7 @@ public async Task ReadsCommitsAfterBaselineThroughHead() Commit first = Commit("fix: change A", "src/A/file.txt", "second"); Commit second = Commit("feat: add B", "src/B/file.txt", "third"); - IReadOnlyList commits = new GitCommitReader().ReadSince(_directory, baseline.Sha); + IReadOnlyList commits = new GitCommitReader().ReadSince(_directory, baseline.Sha, "src"); await Assert.That(commits.Select(commit => commit.Sha).SequenceEqual([first.Sha, second.Sha])).IsTrue(); } @@ -33,7 +33,7 @@ public async Task IncludesChangedPathsAndFullMessages() Commit baseline = Commit("chore: baseline", "src/A/file.txt", "first"); Commit("feat: change A\n\nBREAKING CHANGE: API changed", "src/A/file.txt", "second"); - GitCommitDetails commit = new GitCommitReader().ReadSince(_directory, baseline.Sha).Single(); + GitCommitDetails commit = new GitCommitReader().ReadSince(_directory, baseline.Sha, "src").Single(); using (Assert.Multiple()) { @@ -47,11 +47,44 @@ public async Task ReturnsEmptyListWhenBaselineIsHead() { Commit baseline = Commit("chore: baseline", "src/A/file.txt", "first"); - IReadOnlyList commits = new GitCommitReader().ReadSince(_directory, baseline.Sha); + IReadOnlyList commits = new GitCommitReader().ReadSince(_directory, baseline.Sha, "src"); await Assert.That(commits).IsEmpty(); } + [Test] + public async Task TracksEffectiveGlobalJsonBeforeAndAfterEachCommit() + { + Commit baseline = Commit("chore: baseline", "global.json", "root"); + Commit("build!: change root SDK", "global.json", "updated root"); + Commit("chore: add nested SDK", "solutions/nested/global.json", "nested"); + Commit("build: change shadowed root SDK", "global.json", "shadowed root"); + File.Delete(Path.Combine(_directory, "solutions", "nested", "global.json")); + Commands.Stage(_repository, "solutions/nested/global.json"); + Signature author = new("PackageCascade Tests", "tests@example.invalid", DateTimeOffset.UtcNow); + _repository.Commit("chore: remove nested SDK", author, author); + + IReadOnlyList commits = new GitCommitReader().ReadSince(_directory, baseline.Sha, "solutions/nested"); + + await Assert.That(commits.Select(commit => commit.ChangesEffectiveGlobalJson) + .SequenceEqual([true, true, false, true])).IsTrue(); + } + + [Test] + public async Task MatchesSolutionDirectoryCasingAccordingToPlatform() + { + Commit("chore: add root SDK", "global.json", "root"); + Commit baseline = Commit("chore: add nested SDK", "solutions/nested/global.json", "nested"); + Commit("build: change nested SDK", "solutions/nested/global.json", "updated nested"); + Commit("build: change root SDK", "global.json", "updated root"); + + IReadOnlyList commits = new GitCommitReader() + .ReadSince(_directory, baseline.Sha, "Solutions/Nested"); + + bool[] expected = OperatingSystem.IsWindows() ? [true, false] : [false, true]; + await Assert.That(commits.Select(commit => commit.ChangesEffectiveGlobalJson).SequenceEqual(expected)).IsTrue(); + } + public void Dispose() { _repository.Dispose(); diff --git a/src/PackageCascade.Tests.Unit/Internal/Planning/PlanGeneratorTests.cs b/src/PackageCascade.Tests.Unit/Internal/Planning/PlanGeneratorTests.cs index 88285c6..7db69e0 100644 --- a/src/PackageCascade.Tests.Unit/Internal/Planning/PlanGeneratorTests.cs +++ b/src/PackageCascade.Tests.Unit/Internal/Planning/PlanGeneratorTests.cs @@ -318,6 +318,27 @@ public async Task RecordsSolutionDirectoryRelativeToRepository() await Assert.That(plan.WorkingDirectory).IsEqualTo("solutions/nested"); } + [Test] + public async Task EffectiveGlobalJsonChangeBumpsEveryPackage() + { + GitCommitDetails change = Commit("sdk-change", "build: change SDK", "global.json") with + { + ChangesEffectiveGlobalJson = true, + }; + PlanGenerator generator = new(new StubDiscoverer([Project("A"), Project("B")]), + new StubCommitReader([change]), new StubTagReader(Tags()), new StubGraphBuilder(), + new DirectoryBumpCalculator(), new StubPackageBumpCalculator()); + + ReleasePlan plan = generator.Generate(_repositoryPath, _solutionPath); + + using (Assert.Multiple()) + { + await AssertVersions(plan, ("A", "1.2.4"), ("B", "2.3.5")); + await AssertCommitReason(plan, "A", SemverBump.Patch, "sdk-change"); + await AssertCommitReason(plan, "B", SemverBump.Patch, "sdk-change"); + } + } + [Test] public async Task PassesSolutionDirectoryToBumpCalculator() { @@ -421,7 +442,7 @@ private sealed class StubCommitReader : IGitCommitReader public StubCommitReader(IReadOnlyList commits) => _commits = commits; public StubCommitReader(IReadOnlyDictionary> commitsByBaseline) => _commitsByBaseline = commitsByBaseline; - public IReadOnlyList ReadSince(string repositoryPath, string baselineCommitHash) + public IReadOnlyList ReadSince(string repositoryPath, string baselineCommitHash, string solutionDirectory) => _commitsByBaseline is null ? _commits : _commitsByBaseline[baselineCommitHash]; } diff --git a/src/PackageCascade.Tests.Unit/Internal/Planning/SemVer/DirectoryBumpCalculatorTests.cs b/src/PackageCascade.Tests.Unit/Internal/Planning/SemVer/DirectoryBumpCalculatorTests.cs index 1913d22..7468a95 100644 --- a/src/PackageCascade.Tests.Unit/Internal/Planning/SemVer/DirectoryBumpCalculatorTests.cs +++ b/src/PackageCascade.Tests.Unit/Internal/Planning/SemVer/DirectoryBumpCalculatorTests.cs @@ -15,7 +15,10 @@ public class DirectoryBumpCalculatorTests [Arguments("NuGet.Config")] public async Task RootSharedFileChangeBumpsEveryDirectory(string path) { - GitCommitDetails commit = new("commit", "build: update shared settings", [path]); + GitCommitDetails commit = new("commit", "build: update shared settings", [path]) + { + ChangesEffectiveGlobalJson = path == "global.json", + }; Dictionary bumps = new DirectoryBumpCalculator().Calculate([commit], ["src/A", "tools/B"], "."); @@ -34,7 +37,10 @@ public async Task RootSharedFileChangeBumpsEveryDirectory(string path) [Arguments("NuGet.Config")] public async Task SolutionSharedFileChangeBumpsEveryDirectory(string filename) { - GitCommitDetails commit = new("commit", "build: update shared settings", ["src/" + filename]); + GitCommitDetails commit = new("commit", "build: update shared settings", ["src/" + filename]) + { + ChangesEffectiveGlobalJson = filename == "global.json", + }; Dictionary bumps = new DirectoryBumpCalculator().Calculate( [commit], ["src/A", "src/B"], "src"); @@ -119,7 +125,7 @@ public async Task CombinesSharedAndPackageChangesUsingStrongestBump(bool reverse { GitCommitDetails[] commits = [ - new("shared", "feat: enable shared feature", ["global.json"]), + new("shared", "feat: enable shared feature", ["global.json"]) { ChangesEffectiveGlobalJson = true }, new("package", "feat!: change A API", ["src/A/Api.cs"]), ]; diff --git a/src/PackageCascade/Internal/Planning/Git/GitCommitDetails.cs b/src/PackageCascade/Internal/Planning/Git/GitCommitDetails.cs index fef53a3..2f8734b 100644 --- a/src/PackageCascade/Internal/Planning/Git/GitCommitDetails.cs +++ b/src/PackageCascade/Internal/Planning/Git/GitCommitDetails.cs @@ -1,3 +1,6 @@ namespace PackageCascade.Internal.Planning.Git; -internal sealed record GitCommitDetails(string Sha, string Message, IReadOnlyList ChangedPaths); +internal sealed record GitCommitDetails(string Sha, string Message, IReadOnlyList ChangedPaths) +{ + public bool ChangesEffectiveGlobalJson { get; init; } +} diff --git a/src/PackageCascade/Internal/Planning/Git/GitCommitReader.cs b/src/PackageCascade/Internal/Planning/Git/GitCommitReader.cs index 9e28115..343ef68 100644 --- a/src/PackageCascade/Internal/Planning/Git/GitCommitReader.cs +++ b/src/PackageCascade/Internal/Planning/Git/GitCommitReader.cs @@ -4,8 +4,9 @@ namespace PackageCascade.Internal.Planning.Git; internal sealed class GitCommitReader : IGitCommitReader { - public IReadOnlyList ReadSince(string repositoryPath, string baselineCommitHash) + public IReadOnlyList ReadSince(string repositoryPath, string baselineCommitHash, string solutionDirectory) { + IReadOnlyList globalJsonPaths = GetGlobalJsonPaths(solutionDirectory); using Repository repository = new(repositoryPath); Commit head = repository.Head.Tip ?? throw new InvalidOperationException("The repository has no HEAD commit."); Commit baseline = repository.Lookup(baselineCommitHash) @@ -27,15 +28,76 @@ public IReadOnlyList ReadSince(string repositoryPath, string b foreach (Commit commit in repository.Commits.QueryBy(filter)) { - commits.Add(new(commit.Sha, commit.Message.Trim(), GetChangedPaths(repository, commit))); + Commit parent = commit.Parents.First(); + IReadOnlyList changedPaths = GetChangedPaths(repository, parent, commit); + string? previousGlobalJson = FindGlobalJson(parent.Tree, globalJsonPaths); + string? currentGlobalJson = FindGlobalJson(commit.Tree, globalJsonPaths); + commits.Add(new(commit.Sha, commit.Message.Trim(), changedPaths) + { + ChangesEffectiveGlobalJson = (previousGlobalJson is not null && changedPaths.Contains(previousGlobalJson)) + || (currentGlobalJson is not null && changedPaths.Contains(currentGlobalJson)), + }); } return commits; } - private static IReadOnlyList GetChangedPaths(Repository repository, Commit commit) + private static IReadOnlyList GetGlobalJsonPaths(string solutionDirectory) + { + if (solutionDirectory == ".." || solutionDirectory.StartsWith("../", StringComparison.Ordinal)) + { + return []; + } + + List paths = []; + string directory = solutionDirectory is "." or "" ? "" : solutionDirectory.TrimEnd('/'); + while (true) + { + paths.Add(directory.Length == 0 ? "global.json" : directory + "/global.json"); + if (directory.Length == 0) + { + return paths; + } + + int separator = directory.LastIndexOf('/'); + directory = separator < 0 ? "" : directory[..separator]; + } + } + + private static string? FindGlobalJson(Tree tree, IEnumerable paths) + { + StringComparer pathComparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + foreach (string path in paths) + { + Tree? directory = tree; + List trackedSegments = []; + string[] segments = path.Split('/'); + foreach (string segment in segments[..^1]) + { + TreeEntry? entry = directory?.FirstOrDefault(candidate => pathComparer.Equals(candidate.Name, segment)); + if (entry?.Target is not Tree child) + { + directory = null; + break; + } + + trackedSegments.Add(entry.Name); + directory = child; + } + + TreeEntry? globalJson = directory?.FirstOrDefault(entry => pathComparer.Equals(entry.Name, segments[^1])); + if (globalJson?.Target is Blob) + { + trackedSegments.Add(globalJson.Name); + return string.Join('/', trackedSegments); + } + } + + return null; + } + + private static IReadOnlyList GetChangedPaths(Repository repository, Commit parent, Commit commit) { - Commit parent = commit.Parents.First(); TreeChanges changes = repository.Diff.Compare(parent.Tree, commit.Tree); HashSet changedPaths = new(StringComparer.Ordinal); diff --git a/src/PackageCascade/Internal/Planning/Git/IGitCommitReader.cs b/src/PackageCascade/Internal/Planning/Git/IGitCommitReader.cs index 23a2e98..9e6b9a7 100644 --- a/src/PackageCascade/Internal/Planning/Git/IGitCommitReader.cs +++ b/src/PackageCascade/Internal/Planning/Git/IGitCommitReader.cs @@ -2,5 +2,5 @@ namespace PackageCascade.Internal.Planning.Git; internal interface IGitCommitReader { - public IReadOnlyList ReadSince(string repositoryPath, string baselineCommitHash); + public IReadOnlyList ReadSince(string repositoryPath, string baselineCommitHash, string solutionDirectory); } diff --git a/src/PackageCascade/Internal/Planning/PlanGenerator.cs b/src/PackageCascade/Internal/Planning/PlanGenerator.cs index 8c5b8a5..162e5dc 100644 --- a/src/PackageCascade/Internal/Planning/PlanGenerator.cs +++ b/src/PackageCascade/Internal/Planning/PlanGenerator.cs @@ -106,7 +106,7 @@ private Dictionary GetDirectChanges( if (!commitsByBaseline.TryGetValue(tag.CommitSha, out IReadOnlyList? commits)) { - commits = _commitReader.ReadSince(repositoryPath, tag.CommitSha); + commits = _commitReader.ReadSince(repositoryPath, tag.CommitSha, solutionDirectory); commitsByBaseline.Add(tag.CommitSha, commits); } diff --git a/src/PackageCascade/Internal/Planning/SemVer/DirectoryBumpCalculator.cs b/src/PackageCascade/Internal/Planning/SemVer/DirectoryBumpCalculator.cs index 5dd7815..401cec2 100644 --- a/src/PackageCascade/Internal/Planning/SemVer/DirectoryBumpCalculator.cs +++ b/src/PackageCascade/Internal/Planning/SemVer/DirectoryBumpCalculator.cs @@ -22,8 +22,9 @@ public Dictionary Calculate(IEnumerable co foreach (GitCommitDetails commit in commits) { SemverBump bump = GetBump(commit.Message); - bool changesSolutionSharedFile = commit.ChangedPaths.Any(path => IsSolutionSharedFile(path, solutionPrefix)); - if (changesSolutionSharedFile && bump < SemverBump.Patch) + bool changesSharedFile = commit.ChangesEffectiveGlobalJson + || commit.ChangedPaths.Any(path => IsSolutionSharedFile(path, solutionPrefix)); + if (changesSharedFile && bump < SemverBump.Patch) { bump = SemverBump.Patch; } @@ -33,18 +34,18 @@ public Dictionary Calculate(IEnumerable co continue; } - ApplyBumpToAffectedDirectories(commit, bump, changesSolutionSharedFile, directoryPrefixes, bumps); + ApplyBumpToAffectedDirectories(commit, bump, changesSharedFile, directoryPrefixes, bumps); } return bumps; } - private static void ApplyBumpToAffectedDirectories(GitCommitDetails commit, SemverBump bump, bool changesSolutionSharedFile, + private static void ApplyBumpToAffectedDirectories(GitCommitDetails commit, SemverBump bump, bool changesSharedFile, IReadOnlyDictionary directoryPrefixes, Dictionary bumps) { foreach ((string directory, string prefix) in directoryPrefixes) { - if ((changesSolutionSharedFile || commit.ChangedPaths.Any(path => path.StartsWith(prefix, StringComparison.Ordinal))) + if ((changesSharedFile || commit.ChangedPaths.Any(path => path.StartsWith(prefix, StringComparison.Ordinal))) && bump > bumps[directory]) { bumps[directory] = bump; @@ -57,7 +58,6 @@ private static bool IsSolutionSharedFile(string path, string solutionPrefix) => "Directory.Build.props" or "Directory.Build.targets" or "Directory.Packages.props" or - "global.json" or "NuGet.Config"; private static SemverBump GetBump(string message)