diff --git a/src/PackageCascade.Tests.Integration/CliEndToEnd/PrivateReferenceApplyProcessTests.cs b/src/PackageCascade.Tests.Integration/CliEndToEnd/PrivateReferenceApplyProcessTests.cs new file mode 100644 index 0000000..e07d846 --- /dev/null +++ b/src/PackageCascade.Tests.Integration/CliEndToEnd/PrivateReferenceApplyProcessTests.cs @@ -0,0 +1,228 @@ +using System.Diagnostics; +using System.IO.Compression; +using System.Text.Json; +using System.Xml; +using System.Xml.Linq; +using LibGit2Sharp; +using PackageCascade.Cli; + +namespace PackageCascade.Tests.Integration.CliEndToEnd; + +public sealed class PrivateReferenceApplyProcessTests : IDisposable +{ + private readonly string _directory = Directory.CreateTempSubdirectory("packagecascade-private-reference-").FullName; + private readonly Repository _repository; + private readonly string _solutionPath; + private readonly string _outputPath; + + public PrivateReferenceApplyProcessTests() + { + Repository.Init(_directory); + _repository = new(_directory); + _solutionPath = Path.Combine(_directory, "Packages.slnx"); + _outputPath = Path.Combine(_directory, "packages"); + + File.WriteAllText(Path.Combine(_directory, ".gitignore"), "**/bin/\n**/obj/\n"); + File.WriteAllText(Path.Combine(_directory, "global.json"), """ + {"sdk":{"version":"11.0.100-rc.2.26504.105","allowPrerelease":true,"rollForward":"latestPatch"}} + """); + File.WriteAllText(Path.Combine(_directory, "NuGet.Config"), + ""); + File.WriteAllText(Path.Combine(_directory, "Directory.Build.props"), + "false"); + File.WriteAllText(_solutionPath, """ + + + + + """); + + string aDirectory = Directory.CreateDirectory(Path.Combine(_directory, "A")).FullName; + string bDirectory = Directory.CreateDirectory(Path.Combine(_directory, "B")).FullName; + File.WriteAllText(Path.Combine(aDirectory, "A.csproj"), """ + + net11.0 + + + """); + File.WriteAllText(Path.Combine(aDirectory, "Api.cs"), + "public static class A { public static int Value => B.Value; }\n"); + File.WriteAllText(Path.Combine(bDirectory, "B.csproj"), """ + + net11.0 + + """); + File.WriteAllText(Path.Combine(bDirectory, "Api.cs"), + "public static class B { public static int Value => 42; }\n"); + + Commit baseline = Commit("chore: add packages", ".gitignore", "global.json", "NuGet.Config", + "Directory.Build.props", "Packages.slnx", "A/A.csproj", "A/Api.cs", "B/B.csproj", "B/Api.cs"); + _repository.Tags.Add("A-v2.0.0", baseline); + _repository.Tags.Add("B-v1.2.3", baseline); + + File.WriteAllText(Path.Combine(bDirectory, "Api.cs"), + "public static class B { public static int Value => 43; }\n"); + Commit("fix: repair B value", "B/Api.cs"); + } + + [Test] + public async Task BuiltCliPipesPrivateReferencePlanToApply(CancellationToken cancellationToken) + { + using Process apply = StartProcess(redirectInput: true, + "apply", "--repository", _directory, "--output", _outputPath); + using Process plan = StartProcess(redirectInput: false, + "plan", _solutionPath, "--repository", _directory); + Task planError = plan.StandardError.ReadToEndAsync(cancellationToken); + Task applyOutput = apply.StandardOutput.ReadToEndAsync(cancellationToken); + Task applyError = apply.StandardError.ReadToEndAsync(cancellationToken); + + try + { + await plan.StandardOutput.BaseStream.CopyToAsync(apply.StandardInput.BaseStream, cancellationToken); + apply.StandardInput.Close(); + await Task.WhenAll(plan.WaitForExitAsync(cancellationToken), apply.WaitForExitAsync(cancellationToken)); + using (Assert.Multiple()) + { + await Assert.That(plan.ExitCode).IsEqualTo(0).Because(await planError); + await Assert.That(apply.ExitCode).IsEqualTo(0).Because(await applyError); + } + + await AssertApplied(await applyOutput, cancellationToken); + } + finally + { + await StopIfRunning(plan); + await StopIfRunning(apply); + } + } + + [Test] + public async Task BuiltCliAppliesPrivateReferencePlanFromFile(CancellationToken cancellationToken) + { + string planPath = Path.Combine(_directory, "plan.json"); + (int planExitCode, string planOutput, string planError) = await Run(cancellationToken, + "plan", _solutionPath, "--repository", _directory, "--outfile", planPath); + using (Assert.Multiple()) + { + await Assert.That(planExitCode).IsEqualTo(0).Because(planError); + await Assert.That(planOutput).IsEmpty(); + await Assert.That(File.Exists(planPath)).IsTrue(); + } + + (int applyExitCode, string applyOutput, string applyError) = await Run(cancellationToken, + "apply", "--repository", _directory, "--infile", planPath, "--output", _outputPath); + await Assert.That(applyExitCode).IsEqualTo(0).Because(applyError); + await AssertApplied(applyOutput, cancellationToken); + } + + private async Task AssertApplied(string output, CancellationToken cancellationToken) + { + using JsonDocument document = JsonDocument.Parse(output); + JsonElement result = document.RootElement; + string[] packagePaths = result.GetProperty("PackagePaths").EnumerateArray() + .Select(path => path.GetString()!).ToArray(); + string[] createdTags = result.GetProperty("CreatedTags").EnumerateArray() + .Select(tag => tag.GetString()!).ToArray(); + string[] expectedPaths = [ + Path.Combine(_outputPath, "B.1.2.4.nupkg"), + Path.Combine(_outputPath, "A.2.0.1.nupkg"), + ]; + XElement bMetadata = await ReadMetadata(expectedPaths[0], cancellationToken); + XElement aMetadata = await ReadMetadata(expectedPaths[1], cancellationToken); + + using (Assert.Multiple()) + { + await Assert.That(packagePaths).IsEquivalentTo(expectedPaths); + await Assert.That(Directory.GetFiles(_outputPath, "*.nupkg")).IsEquivalentTo(expectedPaths); + await Assert.That(createdTags).IsEquivalentTo(new[] { "B-v1.2.4", "A-v2.0.1" }); + await Assert.That(_repository.Tags["B-v1.2.4"]?.Target.Sha).IsEqualTo(_repository.Head.Tip!.Sha); + await Assert.That(_repository.Tags["A-v2.0.1"]?.Target.Sha).IsEqualTo(_repository.Head.Tip!.Sha); + await Assert.That(_repository.Tags["B-v1.2.3"]).IsNotNull(); + await Assert.That(_repository.Tags["A-v2.0.0"]).IsNotNull(); + await AssertIdentity(bMetadata, "B", "1.2.4"); + await AssertIdentity(aMetadata, "A", "2.0.1"); + await Assert.That(aMetadata.Descendants().Any(element => element.Name.LocalName == "dependency" && + (string?)element.Attribute("id") == "B")).IsFalse(); + } + } + + private static async Task AssertIdentity(XElement metadata, string id, string version) + { + await Assert.That(metadata.Elements().Single(element => element.Name.LocalName == "id").Value).IsEqualTo(id); + await Assert.That(metadata.Elements().Single(element => element.Name.LocalName == "version").Value).IsEqualTo(version); + } + + private static async Task ReadMetadata(string path, CancellationToken cancellationToken) + { + 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); + return document.Root!.Elements().Single(element => element.Name.LocalName == "metadata"); + } + + private Process StartProcess(bool redirectInput, params string[] arguments) + { + ProcessStartInfo startInfo = new("dotnet") + { + WorkingDirectory = _directory, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = redirectInput, + }; + startInfo.ArgumentList.Add(typeof(Program).Assembly.Location); + foreach (string argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + return Process.Start(startInfo)!; + } + + private async Task<(int ExitCode, string Output, string Error)> Run( + CancellationToken cancellationToken, params string[] arguments) + { + using Process process = StartProcess(redirectInput: false, arguments); + 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 + { + await StopIfRunning(process); + } + } + + private static async Task StopIfRunning(Process process) + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(CancellationToken.None); + } + } + + private Commit Commit(string message, params string[] paths) + { + foreach (string path in paths) + { + Commands.Stage(_repository, path); + } + + Signature author = new("PackageCascade Tests", "tests@example.invalid", DateTimeOffset.UtcNow); + return _repository.Commit(message, author, author); + } + + public void Dispose() + { + _repository.Dispose(); + Directory.Delete(_directory, recursive: true); + } +} diff --git a/src/PackageCascade.Tests.Unit/Internal/Planning/PlanGeneratorTests.cs b/src/PackageCascade.Tests.Unit/Internal/Planning/PlanGeneratorTests.cs index 6c0df0e..5925e39 100644 --- a/src/PackageCascade.Tests.Unit/Internal/Planning/PlanGeneratorTests.cs +++ b/src/PackageCascade.Tests.Unit/Internal/Planning/PlanGeneratorTests.cs @@ -95,6 +95,24 @@ public async Task LibraryFeatureReleasesOnlyLibraryAndItsDependants() } } + [Test] + public async Task PrivateReferenceStillCascadesAndOrdersReleasesWithoutPackageDependency() + { + PackableProject application = Project("A", "B") with { PackageDependencyProjectPaths = [] }; + ReleasePlan plan = Generate([application, Project("B", "C"), Project("C")], Tags(), + [Commit("fix-b", "fix: repair B", "src/B/Api.cs")]); + + using (Assert.Multiple()) + { + await AssertVersions(plan, ("A", "1.2.4"), ("B", "2.3.5")); + await AssertDependencyReason(plan, "A", "B"); + await Assert.That(string.Join(",", plan.Packages.Select(package => package.PackageId))).IsEqualTo("B,A"); + await Assert.That(Release(plan, "A").DependencyIds).IsEmpty(); + await Assert.That(Release(plan, "B").DependencyIds).IsEquivalentTo(["C"]); + await Assert.That(plan.ReferencedPackages.Single()).IsEqualTo(new ReferencedPackage("C", "src/C/C.csproj", "3.4.5")); + } + } + [Test] public async Task ApplicationFixDoesNotReleaseItsDependenciesOrUnrelatedPackages() { diff --git a/src/PackageCascade.Tests.Unit/Internal/Planning/Projects/SolutionPackageDiscovererTests.cs b/src/PackageCascade.Tests.Unit/Internal/Planning/Projects/SolutionPackageDiscovererTests.cs index a26c824..f0ff83f 100644 --- a/src/PackageCascade.Tests.Unit/Internal/Planning/Projects/SolutionPackageDiscovererTests.cs +++ b/src/PackageCascade.Tests.Unit/Internal/Planning/Projects/SolutionPackageDiscovererTests.cs @@ -1,5 +1,6 @@ using PackageCascade.Internal.Planning.Projects; using PackageCascade.Internal.Planning; +using System.Xml.Linq; namespace PackageCascade.Tests.Unit.Internal.Planning.Projects; @@ -51,9 +52,43 @@ public async Task UsesExplicitPackageId() public async Task CapturesDirectProjectReferences() { IReadOnlyList packages = new SolutionPackageDiscoverer().Discover(_solutionPath); + PackableProject package = packages.Single(package => package.ProjectPath == _explicitProject); - await Assert.That(packages.Single(package => package.ProjectPath == _explicitProject).ReferencedProjectPaths) - .IsEquivalentTo(new[] { _defaultProject }); + using (Assert.Multiple()) + { + await Assert.That(package.ReferencedProjectPaths).IsEquivalentTo([_defaultProject]); + await Assert.That(package.PackageDependencyProjectPaths).IsEquivalentTo([_defaultProject]); + } + } + + [Test] + public async Task PrivateReferenceRemainsInProjectGraphButNotPackageDependencies() + { + SetDefaultReferencePrivateAssets("build ; ALL"); + + PackableProject package = new SolutionPackageDiscoverer().Discover(_solutionPath) + .Single(project => project.ProjectPath == _explicitProject); + + using (Assert.Multiple()) + { + await Assert.That(package.ReferencedProjectPaths).IsEquivalentTo([_defaultProject]); + await Assert.That(package.PackageDependencyProjectPaths).IsEmpty(); + } + } + + [Test] + public async Task PrivateAssetsElementAlsoExcludesPackageDependency() + { + SetDefaultReferencePrivateAssets("ALL", asElement: true); + + PackableProject package = new SolutionPackageDiscoverer().Discover(_solutionPath) + .Single(project => project.ProjectPath == _explicitProject); + + using (Assert.Multiple()) + { + await Assert.That(package.ReferencedProjectPaths).IsEquivalentTo([_defaultProject]); + await Assert.That(package.PackageDependencyProjectPaths).IsEmpty(); + } } [Test] @@ -72,9 +107,13 @@ public async Task CapturesReferencesConditionedOnTargetFramework() """); IReadOnlyList packages = new SolutionPackageDiscoverer().Discover(_solutionPath); + PackableProject package = packages.Single(package => package.ProjectPath == _explicitProject); - await Assert.That(packages.Single(package => package.ProjectPath == _explicitProject).ReferencedProjectPaths) - .IsEquivalentTo(new[] { _defaultProject }); + using (Assert.Multiple()) + { + await Assert.That(package.ReferencedProjectPaths).IsEquivalentTo([_defaultProject]); + await Assert.That(package.PackageDependencyProjectPaths).IsEquivalentTo([_defaultProject]); + } } [Test] @@ -133,4 +172,21 @@ private string WriteProject(string name, string properties, params string[] refe return projectPath; } + + private void SetDefaultReferencePrivateAssets(string value, bool asElement = false) + { + XDocument document = XDocument.Load(_explicitProject); + XElement reference = document.Descendants("ProjectReference") + .Single(item => (string?)item.Attribute("Include") == "../Default/Default.csproj"); + if (asElement) + { + reference.Add(new XElement("PrivateAssets", value)); + } + else + { + reference.SetAttributeValue("PrivateAssets", value); + } + + document.Save(_explicitProject); + } } diff --git a/src/PackageCascade/Internal/Planning/PlanGenerator.cs b/src/PackageCascade/Internal/Planning/PlanGenerator.cs index 3fe92f9..887c598 100644 --- a/src/PackageCascade/Internal/Planning/PlanGenerator.cs +++ b/src/PackageCascade/Internal/Planning/PlanGenerator.cs @@ -201,7 +201,9 @@ private static IReadOnlyList CreatePackages( Path.GetRelativePath(repositoryPath, project.ProjectPath).Replace('\\', '/'), version.ToNormalizedString(), GetReasons(project, previousTag, directChanges[project], selected, graph), - graph[project].Select(dependency => dependency.PackageId) + graph[project].Where(dependency => project.PackageDependencyProjectPaths.Contains(dependency.ProjectPath, + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal)) + .Select(dependency => dependency.PackageId) .Order(StringComparer.Ordinal).ToArray())); } diff --git a/src/PackageCascade/Internal/Planning/Projects/PackableProject.cs b/src/PackageCascade/Internal/Planning/Projects/PackableProject.cs index b2399dc..f1703dd 100644 --- a/src/PackageCascade/Internal/Planning/Projects/PackableProject.cs +++ b/src/PackageCascade/Internal/Planning/Projects/PackableProject.cs @@ -1,3 +1,6 @@ namespace PackageCascade.Internal.Planning.Projects; -internal sealed record PackableProject(string ProjectPath, string PackageId, IReadOnlyList ReferencedProjectPaths); +internal sealed record PackableProject(string ProjectPath, string PackageId, IReadOnlyList ReferencedProjectPaths) +{ + public IReadOnlyList PackageDependencyProjectPaths { get; init; } = ReferencedProjectPaths; +} diff --git a/src/PackageCascade/Internal/Planning/Projects/SolutionPackageDiscoverer.cs b/src/PackageCascade/Internal/Planning/Projects/SolutionPackageDiscoverer.cs index 26701fa..30afbae 100644 --- a/src/PackageCascade/Internal/Planning/Projects/SolutionPackageDiscoverer.cs +++ b/src/PackageCascade/Internal/Planning/Projects/SolutionPackageDiscoverer.cs @@ -48,6 +48,7 @@ private static PackableProject CreatePackage( { string packageId = nodes[0].ProjectInstance.GetPropertyValue("PackageId"); HashSet referencedPaths = new(pathComparer); + HashSet packageDependencyPaths = new(pathComparer); foreach (ProjectGraphNode node in nodes) { @@ -62,16 +63,41 @@ private static PackableProject CreatePackage( throw new InvalidOperationException($"Project '{projectPath}' has different PackageIds across evaluations."); } - foreach (ProjectItemInstance reference in node.ProjectInstance.GetItems("ProjectReference")) + CollectReferences(node, projects, referencedPaths, packageDependencyPaths); + } + + return new(projectPath, packageId, referencedPaths.OrderBy(path => path, StringComparer.Ordinal).ToImmutableArray()) + { + PackageDependencyProjectPaths = packageDependencyPaths.OrderBy(path => path, StringComparer.Ordinal).ToImmutableArray(), + }; + } + + private static void CollectReferences( + ProjectGraphNode node, + IReadOnlyDictionary projects, + HashSet referencedPaths, + HashSet packageDependencyPaths) + { + foreach (ProjectItemInstance reference in node.ProjectInstance.GetItems("ProjectReference")) + { + string referencedPath = reference.GetMetadataValue("FullPath"); + if (!projects.ContainsKey(referencedPath)) + { + continue; + } + + referencedPaths.Add(referencedPath); + if (!IsPrivateReference(reference)) { - string referencedPath = reference.GetMetadataValue("FullPath"); - if (projects.ContainsKey(referencedPath)) - { - referencedPaths.Add(referencedPath); - } + packageDependencyPaths.Add(referencedPath); } } + } - return new(projectPath, packageId, referencedPaths.OrderBy(path => path, StringComparer.Ordinal).ToImmutableArray()); + private static bool IsPrivateReference(ProjectItemInstance reference) + { + return reference.GetMetadataValue("PrivateAssets") + .Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Contains("all", StringComparer.OrdinalIgnoreCase); } } diff --git a/src/PackageCascade/Public/Definition/PlannedRelease.cs b/src/PackageCascade/Public/Definition/PlannedRelease.cs index 2cca4ec..879f22e 100644 --- a/src/PackageCascade/Public/Definition/PlannedRelease.cs +++ b/src/PackageCascade/Public/Definition/PlannedRelease.cs @@ -5,7 +5,7 @@ namespace PackageCascade.Public.Definition; /// The project file path; relative paths are resolved from the repository root. /// The proposed package version as a normalised string. /// The commit, dependency, or initial-release reasons for this release. -/// The package IDs of directly referenced packable projects, including unchanged packages. +/// The package IDs expected as package dependencies in the manifest, including unchanged packages. public sealed record PlannedRelease( string PackageId, string ProjectPath,