Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ Each package has its own release history, tracked by tags named `<PackageId>-v<v
- `fix:` and `perf:` changes require a Patch bump; `feat:` requires Minor. A conventional commit with `!` or a `BREAKING CHANGE:` footer requires Major.
- Changes are attributed by project directory. When several commits affect a package, the strongest bump wins.
- If B changes and A depends on B, A gets at least a Patch bump, propagating through its dependants. A's own Minor or Major change takes precedence.
- A change to root-level `Directory.Build.props`, `Directory.Build.targets`, `Directory.Packages.props`, `global.json`, or `NuGet.Config` affects every package, with Patch as the minimum even for `build:` or `chore:` commits. Nested files retain ordinary directory-based handling.
- A change to `Directory.Build.props`, `Directory.Build.targets`, `Directory.Packages.props`, `global.json`, or `NuGet.Config` beside the solution file affects every package, with Patch as the minimum even for `build:` or `chore:` commits. Files with those names elsewhere retain ordinary directory-based handling.

For example, a feature in B can release B from `1.4.0` to `1.5.0` and its unchanged dependant A from `3.2.0` to `3.2.1`. A's package then refers to B at `1.5.0`, rather than inheriting A's version.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,19 @@ public async Task RecordsSolutionDirectoryRelativeToRepository()
await Assert.That(plan.WorkingDirectory).IsEqualTo("solutions/nested");
}

[Test]
public async Task PassesSolutionDirectoryToBumpCalculator()
{
StubDirectoryBumpCalculator calculator = new();
PlanGenerator generator = CreateGenerator(new StubDiscoverer(Graph()),
new StubCommitReader([Commit("change", "fix: update settings", "solutions/Directory.Build.props")]),
Tags(), calculator);

generator.Generate(_repositoryPath, Path.Combine(_repositoryPath, "solutions", "Packages.slnx"));

await Assert.That(calculator.SolutionDirectory).IsEqualTo("solutions");
}

private static ReleasePlan Generate(IReadOnlyList<PackableProject> projects,
IReadOnlyDictionary<string, PackageReleaseTag> tags, IReadOnlyList<GitCommitDetails>? commits = null)
{
Expand All @@ -331,9 +344,9 @@ private static PlanGenerator CreateGenerator(IReadOnlyList<PackableProject> proj
=> CreateGenerator(new StubDiscoverer(projects), commits, tags);

private static PlanGenerator CreateGenerator(StubDiscoverer discoverer, IGitCommitReader commits,
IReadOnlyDictionary<string, PackageReleaseTag> tags)
IReadOnlyDictionary<string, PackageReleaseTag> tags, IDirectoryBumpCalculator? directoryCalculator = null)
=> new(discoverer, commits, new StubTagReader(tags), new StubGraphBuilder(),
new StubDirectoryBumpCalculator(), new StubPackageBumpCalculator());
directoryCalculator ?? new StubDirectoryBumpCalculator(), new StubPackageBumpCalculator());

private static async Task AssertVersions(ReleasePlan plan, params (string PackageId, string Version)[] expected)
{
Expand Down Expand Up @@ -425,8 +438,12 @@ public IReadOnlyDictionary<PackableProject, IReadOnlyList<PackableProject>> Buil

private sealed class StubDirectoryBumpCalculator : IDirectoryBumpCalculator
{
public Dictionary<string, SemverBump> Calculate(IEnumerable<GitCommitDetails> commits, IEnumerable<string> directories)
public string? SolutionDirectory { get; private set; }

public Dictionary<string, SemverBump> Calculate(IEnumerable<GitCommitDetails> commits, IEnumerable<string> directories,
string solutionDirectory)
{
SolutionDirectory = solutionDirectory;
Dictionary<string, SemverBump> bumps = directories.ToDictionary(directory => directory,
_ => SemverBump.None, StringComparer.Ordinal);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public async Task RootSharedFileChangeBumpsEveryDirectory(string path)
{
GitCommitDetails commit = new("commit", "build: update shared settings", [path]);

Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate([commit], ["src/A", "tools/B"]);
Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate([commit], ["src/A", "tools/B"], ".");

using (Assert.Multiple())
{
Expand All @@ -26,6 +26,41 @@ public async Task RootSharedFileChangeBumpsEveryDirectory(string path)
}
}

[Test]
[Arguments("Directory.Build.props")]
[Arguments("Directory.Build.targets")]
[Arguments("Directory.Packages.props")]
[Arguments("global.json")]
[Arguments("NuGet.Config")]
public async Task SolutionSharedFileChangeBumpsEveryDirectory(string filename)
{
GitCommitDetails commit = new("commit", "build: update shared settings", ["src/" + filename]);

Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate(
[commit], ["src/A", "src/B"], "src");

using (Assert.Multiple())
{
await Assert.That(bumps["src/A"]).IsEqualTo(SemverBump.Patch);
await Assert.That(bumps["src/B"]).IsEqualTo(SemverBump.Patch);
}
}

[Test]
public async Task RepositoryRootSharedFileDoesNotBumpProjectsForNestedSolution()
{
GitCommitDetails commit = new("commit", "build: update shared settings", ["Directory.Build.props"]);

Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate(
[commit], ["src/A", "src/B"], "src");

using (Assert.Multiple())
{
await Assert.That(bumps["src/A"]).IsEqualTo(SemverBump.None);
await Assert.That(bumps["src/B"]).IsEqualTo(SemverBump.None);
}
}

[Test]
[Arguments("Update shared settings", "Patch")]
[Arguments("fix: correct shared settings", "Patch")]
Expand All @@ -35,7 +70,7 @@ public async Task RootSharedFileUsesCommitBumpWithPatchMinimum(string message, s
{
GitCommitDetails commit = new("commit", message, ["Directory.Build.props"]);

Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate([commit], ["src/A", "src/B"]);
Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate([commit], ["src/A", "src/B"], ".");

using (Assert.Multiple())
{
Expand All @@ -54,7 +89,7 @@ public async Task NestedSharedFilenameOnlyAffectsItsOwnDirectory(string path)
{
GitCommitDetails commit = new("commit", "fix: correct settings", [path]);

Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate([commit], ["src/A", "src/B"]);
Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate([commit], ["src/A", "src/B"], ".");

using (Assert.Multiple())
{
Expand All @@ -72,7 +107,7 @@ public async Task OtherRootFilesDoNotBumpEveryDirectory(string path)
{
GitCommitDetails commit = new("commit", "fix: update repository file", [path]);

Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate([commit], ["src/A"]);
Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate([commit], ["src/A"], ".");

await Assert.That(bumps["src/A"]).IsEqualTo(SemverBump.None);
}
Expand All @@ -89,7 +124,7 @@ public async Task CombinesSharedAndPackageChangesUsingStrongestBump(bool reverse
];

Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate(
reverseOrder ? commits.Reverse() : commits, ["src/A", "src/B"]);
reverseOrder ? commits.Reverse() : commits, ["src/A", "src/B"], ".");

using (Assert.Multiple())
{
Expand All @@ -109,7 +144,7 @@ public async Task MapsConventionalCommitToBump(string message, string expected)
{
GitCommitDetails commit = new("commit", message, ["src/A/file.txt"]);

Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate([commit], ["src/A"]);
Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate([commit], ["src/A"], ".");

await Assert.That(bumps["src/A"].ToString()).IsEqualTo(expected);
}
Expand All @@ -123,7 +158,7 @@ public async Task UsesStrongestRelevantBump()
new("second", "fix: repair", ["src/A/fix.cs"]),
];

Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate(commits, ["src/A"]);
Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate(commits, ["src/A"], ".");

await Assert.That(bumps["src/A"]).IsEqualTo(SemverBump.Minor);
}
Expand All @@ -133,7 +168,7 @@ public async Task DoesNotMatchSiblingDirectory()
{
GitCommitDetails commit = new("commit", "feat: add feature", ["src/AB/file.cs"]);

Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate([commit], ["src/A"]);
Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate([commit], ["src/A"], ".");

await Assert.That(bumps["src/A"]).IsEqualTo(SemverBump.None);
}
Expand All @@ -143,7 +178,7 @@ public async Task MatchesProjectAtRepositoryRoot()
{
GitCommitDetails commit = new("commit", "fix: repair", ["Api.cs"]);

Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate([commit], ["."]);
Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate([commit], ["."], ".");

await Assert.That(bumps["."]).IsEqualTo(SemverBump.Patch);
}
Expand All @@ -153,7 +188,7 @@ public async Task AcceptsPlatformDirectorySeparators()
{
GitCommitDetails commit = new("commit", "fix: repair", ["src/A/file.cs"]);

Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate([commit], [@"src\A"]);
Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate([commit], [@"src\A"], ".");

await Assert.That(bumps[@"src\A"]).IsEqualTo(SemverBump.Patch);
}
Expand All @@ -167,7 +202,7 @@ public async Task ReturnsBumpForEachSuppliedDirectory()
new("second", "fix: repair B", ["src/B/file.cs"]),
];

Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate(commits, ["src/A", "src/B", "src/C"]);
Dictionary<string, SemverBump> bumps = new DirectoryBumpCalculator().Calculate(commits, ["src/A", "src/B", "src/C"], ".");

using (Assert.Multiple())
{
Expand Down
15 changes: 8 additions & 7 deletions src/PackageCascade/Internal/Planning/PlanGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,13 @@ public ReleasePlan Generate(
string solutionPath,
string configuration = "Release")
{
string solutionDirectory = Path.GetRelativePath(repositoryPath, Path.GetDirectoryName(Path.GetFullPath(solutionPath))!)
.Replace('\\', '/');
IReadOnlyList<PackableProject> projects = _packageDiscoverer.Discover(solutionPath, configuration);
IReadOnlyDictionary<PackableProject, IReadOnlyList<PackableProject>> graph = _graphBuilder.Build(projects);
IReadOnlyDictionary<string, PackageReleaseTag> tags = _tagReader.ReadLatest(repositoryPath, projects.Select(project => project.PackageId));

Dictionary<PackableProject, DirectChange> directChanges = GetDirectChanges(repositoryPath, projects, tags);
Dictionary<PackableProject, DirectChange> directChanges = GetDirectChanges(repositoryPath, solutionDirectory, projects, tags);
Dictionary<string, SemverBump> directoryBumps = directChanges.ToDictionary(entry => GetDirectory(repositoryPath, entry.Key), entry => entry.Value.Bump, StringComparer.Ordinal);
IReadOnlyDictionary<PackableProject, SemverBump> bumps = _bumpCalculator.Calculate(repositoryPath, graph, directoryBumps);

Expand All @@ -53,9 +55,7 @@ public ReleasePlan Generate(
repositoryPath, layers, selected, graph, tags, directChanges, versions);

IReadOnlyList<ReferencedPackage> referencedPackages = GetReferencedPackages(repositoryPath, selected, graph, tags);
string workingDirectory = Path.GetRelativePath(repositoryPath, Path.GetDirectoryName(Path.GetFullPath(solutionPath))!)
.Replace('\\', '/');
return new(packages, configuration, referencedPackages, workingDirectory);
return new(packages, configuration, referencedPackages, solutionDirectory);
}

private static IReadOnlyList<ReferencedPackage> GetReferencedPackages(
Expand Down Expand Up @@ -89,6 +89,7 @@ private static IReadOnlyList<ReferencedPackage> GetReferencedPackages(

private Dictionary<PackableProject, DirectChange> GetDirectChanges(
string repositoryPath,
string solutionDirectory,
IEnumerable<PackableProject> projects,
IReadOnlyDictionary<string, PackageReleaseTag> tags)
{
Expand All @@ -109,20 +110,20 @@ private Dictionary<PackableProject, DirectChange> GetDirectChanges(
commitsByBaseline.Add(tag.CommitSha, commits);
}

changes.Add(project, GetDirectChange(commits, GetDirectory(repositoryPath, project)));
changes.Add(project, GetDirectChange(commits, GetDirectory(repositoryPath, project), solutionDirectory));
}

return changes;
}

private DirectChange GetDirectChange(IEnumerable<GitCommitDetails> commits, string directory)
private DirectChange GetDirectChange(IEnumerable<GitCommitDetails> commits, string directory, string solutionDirectory)
{
SemverBump strongestBump = SemverBump.None;
List<string> commitShas = [];

foreach (GitCommitDetails commit in commits)
{
SemverBump bump = _directoryCalculator.Calculate([commit], [directory])[directory];
SemverBump bump = _directoryCalculator.Calculate([commit], [directory], solutionDirectory)[directory];
if (bump == SemverBump.None)
{
continue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ namespace PackageCascade.Internal.Planning.SemVer;

internal sealed partial class DirectoryBumpCalculator : IDirectoryBumpCalculator
{
public Dictionary<string, SemverBump> Calculate(IEnumerable<GitCommitDetails> commits, IEnumerable<string> directories)
public Dictionary<string, SemverBump> Calculate(IEnumerable<GitCommitDetails> commits, IEnumerable<string> directories,
string solutionDirectory)
{
Dictionary<string, SemverBump> bumps = new(StringComparer.Ordinal);
Dictionary<string, string> directoryPrefixes = new(StringComparer.Ordinal);
string solutionPrefix = solutionDirectory is "." or "" ? "" : solutionDirectory.TrimEnd('/', '\\').Replace('\\', '/') + "/";

foreach (string directory in directories)
{
Expand All @@ -20,8 +22,8 @@ public Dictionary<string, SemverBump> Calculate(IEnumerable<GitCommitDetails> co
foreach (GitCommitDetails commit in commits)
{
SemverBump bump = GetBump(commit.Message);
bool changesRootSharedFile = commit.ChangedPaths.Any(IsRootSharedFile);
if (changesRootSharedFile && bump < SemverBump.Patch)
bool changesSolutionSharedFile = commit.ChangedPaths.Any(path => IsSolutionSharedFile(path, solutionPrefix));
if (changesSolutionSharedFile && bump < SemverBump.Patch)
{
bump = SemverBump.Patch;
}
Expand All @@ -31,26 +33,27 @@ public Dictionary<string, SemverBump> Calculate(IEnumerable<GitCommitDetails> co
continue;
}

ApplyBumpToAffectedDirectories(commit, bump, changesRootSharedFile, directoryPrefixes, bumps);
ApplyBumpToAffectedDirectories(commit, bump, changesSolutionSharedFile, directoryPrefixes, bumps);
}

return bumps;
}

private static void ApplyBumpToAffectedDirectories(GitCommitDetails commit, SemverBump bump, bool changesRootSharedFile,
private static void ApplyBumpToAffectedDirectories(GitCommitDetails commit, SemverBump bump, bool changesSolutionSharedFile,
IReadOnlyDictionary<string, string> directoryPrefixes, Dictionary<string, SemverBump> bumps)
{
foreach ((string directory, string prefix) in directoryPrefixes)
{
if ((changesRootSharedFile || commit.ChangedPaths.Any(path => path.StartsWith(prefix, StringComparison.Ordinal)))
if ((changesSolutionSharedFile || commit.ChangedPaths.Any(path => path.StartsWith(prefix, StringComparison.Ordinal)))
&& bump > bumps[directory])
{
bumps[directory] = bump;
}
}
}

private static bool IsRootSharedFile(string path) => path is
private static bool IsSolutionSharedFile(string path, string solutionPrefix) =>
path.StartsWith(solutionPrefix, StringComparison.Ordinal) && path[solutionPrefix.Length..] is
"Directory.Build.props" or
"Directory.Build.targets" or
"Directory.Packages.props" or
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ namespace PackageCascade.Internal.Planning.SemVer;

internal interface IDirectoryBumpCalculator
{
public Dictionary<string, SemverBump> Calculate(IEnumerable<GitCommitDetails> commits, IEnumerable<string> directories);
public Dictionary<string, SemverBump> Calculate(IEnumerable<GitCommitDetails> commits, IEnumerable<string> directories,
string solutionDirectory);
}
Loading