From 84c86fb232a313fae815b551c7ce90973e67ae08 Mon Sep 17 00:00:00 2001 From: Leonardo Chaia Date: Thu, 15 Jun 2023 04:31:08 -0700 Subject: [PATCH] feat: exclude projects (#82) Adds feature to exclude projects to CLI using `--exclude`. Co-authored-by: xIceFox --- README.md | 33 +++++ .../AffectedSummary.cs | 8 ++ src/DotnetAffected.Core/AffectedOptions.cs | 10 +- .../Processor/AffectedProcessorBase.cs | 61 ++++++++- .../Commands/AffectedGlobalOptions.cs | 7 + .../Commands/AffectedRootCommand.cs | 3 +- .../Commands/Binding/AffectedOptionsBinder.cs | 3 +- src/dotnet-affected/Views/AffectedInfoView.cs | 1 + .../Views/WithChangesAndAffectedView.cs | 13 +- .../ProjectExclusionTests.cs | 127 ++++++++++++++++++ .../AffectedCommandTests.cs | 24 ++++ 11 files changed, 275 insertions(+), 15 deletions(-) create mode 100644 test/DotnetAffected.Core.Tests/ProjectExclusionTests.cs diff --git a/README.md b/README.md index 7d3f933..fd7a408 100755 --- a/README.md +++ b/README.md @@ -271,6 +271,39 @@ WRITE: /home/lchaia/dev/dotnet-affected/affected.proj WRITE: /home/lchaia/dev/dotnet-affected/affected.json ``` +## Excluding Projects + +Projects can be excluded by using the `--exclude` (shorthand `-e`) argument. It expects a dotnet Regular Expression +that will be matched against each Project's Full Path. + +In the below example, `dotnet-affected.Tests` is excluded due to the regular expression provided. +```shell +$ dotnet affected --dry-run --verbose -e .Tests. +1 files have changed referenced by 1 projects +0 NuGet Packages have changed +1 projects are affected by these changes +1 projects were excluded +Changed Projects +Name Path +dotnet-affected /home/lchaia/dev/dotnet-affected/src/dotnet-affected/dotnet-affected.csproj + +Affected Projects +Name Path +dotnet-affected.Benchmarks /home/lchaia/dev/dotnet-affected/benchmarks/dotnet-affected.Benchmarks/dotnet-affected.Benchmarks.csproj + +Excluded Projects +Name Path +dotnet-affected.Tests /home/lchaia/dev/dotnet-affected/test/dotnet-affected.Tests/dotnet-affected.Tests.csproj +DRY-RUN: WRITE /home/lchaia/dev/dotnet-affected/affected.proj +DRY-RUN: CONTENTS: + + + + + + +``` + ## Continuous Integration For usage in CI, it's recommended to use the `--from` and `--to` options with the environment variables provided by your diff --git a/src/DotnetAffected.Abstractions/AffectedSummary.cs b/src/DotnetAffected.Abstractions/AffectedSummary.cs index 8d08384..1527066 100644 --- a/src/DotnetAffected.Abstractions/AffectedSummary.cs +++ b/src/DotnetAffected.Abstractions/AffectedSummary.cs @@ -13,16 +13,19 @@ public class AffectedSummary /// /// /// + /// /// public AffectedSummary( string[] filesThatChanged, ProjectGraphNode[] projectsWithChangedFiles, ProjectGraphNode[] affectedProjects, + ProjectGraphNode[] excludedProjects, PackageChange[] changedPackages) { FilesThatChanged = filesThatChanged; ProjectsWithChangedFiles = projectsWithChangedFiles; AffectedProjects = affectedProjects; + ExcludedProjects = excludedProjects; ChangedPackages = changedPackages; } @@ -41,6 +44,11 @@ public AffectedSummary( /// public ProjectGraphNode[] AffectedProjects { get; } + /// + /// Gets a list of projects that had changes or were affected but were excluded from discovery. + /// + public ProjectGraphNode[] ExcludedProjects { get; } + /// /// Gets the list of packages that changed. /// diff --git a/src/DotnetAffected.Core/AffectedOptions.cs b/src/DotnetAffected.Core/AffectedOptions.cs index 9d96544..6bd5eb0 100644 --- a/src/DotnetAffected.Core/AffectedOptions.cs +++ b/src/DotnetAffected.Core/AffectedOptions.cs @@ -16,16 +16,19 @@ public class AffectedOptions : IDiscoveryOptions /// /// /// + /// public AffectedOptions( string? repositoryPath = null, string? solutionPath = null, string? fromRef = null, - string? toRef = null) + string? toRef = null, + string? exclusionRegex = null) { RepositoryPath = DetermineRepositoryPath(repositoryPath, solutionPath); SolutionPath = solutionPath; FromRef = fromRef ?? string.Empty; ToRef = toRef ?? string.Empty; + ExclusionRegex = exclusionRegex; } /// @@ -48,6 +51,11 @@ public AffectedOptions( /// public string ToRef { get; } + /// + /// Gets the regular expression to use for excluding projects. + /// + public string? ExclusionRegex { get; } + private static string DetermineRepositoryPath(string? repositoryPath, string? solutionPath) { // the argument takes precedence. diff --git a/src/DotnetAffected.Core/Processor/AffectedProcessorBase.cs b/src/DotnetAffected.Core/Processor/AffectedProcessorBase.cs index 5d31fc0..ab74240 100644 --- a/src/DotnetAffected.Core/Processor/AffectedProcessorBase.cs +++ b/src/DotnetAffected.Core/Processor/AffectedProcessorBase.cs @@ -2,6 +2,7 @@ using Microsoft.Build.Graph; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; namespace DotnetAffected.Core.Processor { @@ -18,27 +19,41 @@ internal abstract class AffectedProcessorBase public AffectedSummary Process(AffectedProcessorContext context) { // Get files that changed according to changes provider. - context.ChangedFiles = DiscoverChangedFiles(context).ToArray(); + context.ChangedFiles = DiscoverChangedFiles(context) + .ToArray(); // Map the files that changed to their corresponding project/s. - context.ChangedProjects = DiscoverProjectsForFiles(context).ToArray(); + var excludedProjects = new List(); + context.ChangedProjects = ApplyExclusionPattern( + DiscoverProjectsForFiles(context), + context.Options, + excludedProjects); // Get packages that have changed, either from central package management or from the project file context.ChangedPackages = DiscoverPackageChanges(context); // Determine which projects are affected by the projects and packages that have changed. - context.AffectedProjects = DiscoverAffectedProjects(context); + context.AffectedProjects = ApplyExclusionPattern( + DiscoverAffectedProjects(context), + context.Options, + excludedProjects); // Output a summary of the operation. - return new AffectedSummary(context.ChangedFiles, context.ChangedProjects, context.AffectedProjects, context.ChangedPackages); + return new AffectedSummary( + context.ChangedFiles, + context.ChangedProjects, + context.AffectedProjects, + excludedProjects.Distinct() + .ToArray(), + context.ChangedPackages); } - + /// /// Discover which files have changes /// /// /// - protected virtual IEnumerable DiscoverChangedFiles(AffectedProcessorContext context) + protected virtual IEnumerable DiscoverChangedFiles(AffectedProcessorContext context) => context.ChangesProvider.GetChangedFiles(context.RepositoryPath, context.FromRef, context.ToRef); /// @@ -49,11 +64,43 @@ protected virtual IEnumerable DiscoverChangedFiles(AffectedProcessorCont protected virtual IEnumerable DiscoverProjectsForFiles(AffectedProcessorContext context) { // We init now because we want the graph to initialize late (lazy) - var provider = context.ChangedProjectsProvider ?? new PredictionChangedProjectsProvider(context.Graph, context.Options); + var provider = context.ChangedProjectsProvider ?? + new PredictionChangedProjectsProvider(context.Graph, context.Options); // Match which files belong to which of our known projects return provider.GetReferencingProjects(context.ChangedFiles); } + /// + /// Applies the to exclude + /// projects that matches the regular expression. + /// + /// List of projects that changed. + /// Affected options. + /// Collection of excluded projects + /// Project lis excluding the ones that matches the exclusion regex. + protected virtual ProjectGraphNode[] ApplyExclusionPattern( + IEnumerable inputProjects, + AffectedOptions options, + ICollection excludedProjects) + { + var pattern = options.ExclusionRegex; + + if (string.IsNullOrEmpty(pattern)) + return inputProjects.ToArray(); + + var changedProjects = new List(); + var regex = new Regex(pattern); + foreach (var project in inputProjects) + { + if (regex.IsMatch(project.GetFullPath())) + excludedProjects.Add(project); + else + changedProjects.Add(project); + } + + return changedProjects.ToArray(); + } + /// /// Discover which packages have changed.
///
diff --git a/src/dotnet-affected/Commands/AffectedGlobalOptions.cs b/src/dotnet-affected/Commands/AffectedGlobalOptions.cs index dc59195..2d95001 100755 --- a/src/dotnet-affected/Commands/AffectedGlobalOptions.cs +++ b/src/dotnet-affected/Commands/AffectedGlobalOptions.cs @@ -45,6 +45,13 @@ internal static class AffectedGlobalOptions description: "A branch or commit to compare against --to."); public static readonly ToOption ToOption = new(FromOption); + + public static readonly Option ExclusionRegexOption = new( + new[] + { + "--exclude", "-e" + }, + description: "A dotnet Regular Expression used to exclude discovered and affected projects."); } internal sealed class ToOption : Option diff --git a/src/dotnet-affected/Commands/AffectedRootCommand.cs b/src/dotnet-affected/Commands/AffectedRootCommand.cs index 56dce47..871f3cc 100755 --- a/src/dotnet-affected/Commands/AffectedRootCommand.cs +++ b/src/dotnet-affected/Commands/AffectedRootCommand.cs @@ -26,6 +26,7 @@ public AffectedRootCommand() this.AddGlobalOption(AffectedGlobalOptions.AssumeChangesOption); this.AddGlobalOption(AffectedGlobalOptions.FromOption); this.AddGlobalOption(AffectedGlobalOptions.ToOption); + this.AddGlobalOption(AffectedGlobalOptions.ExclusionRegexOption); this.AddOption(FormatOption); this.AddOption(DryRunOption); @@ -74,7 +75,7 @@ public FormatOption() }) { this.Description = "Space-seperated output file formats. Possible values: ."; - + this.SetDefaultValue(new[] { "traversal" diff --git a/src/dotnet-affected/Commands/Binding/AffectedOptionsBinder.cs b/src/dotnet-affected/Commands/Binding/AffectedOptionsBinder.cs index 7264f15..ea3a493 100644 --- a/src/dotnet-affected/Commands/Binding/AffectedOptionsBinder.cs +++ b/src/dotnet-affected/Commands/Binding/AffectedOptionsBinder.cs @@ -15,7 +15,8 @@ protected override AffectedOptions GetBoundValue(BindingContext bindingContext) parseResult.GetValueForOption(AffectedGlobalOptions.RepositoryPathOptions), parseResult.GetValueForOption(AffectedGlobalOptions.SolutionPathOption), parseResult.GetValueForOption(AffectedGlobalOptions.FromOption), - parseResult.GetValueForOption(AffectedGlobalOptions.ToOption) + parseResult.GetValueForOption(AffectedGlobalOptions.ToOption), + parseResult.GetValueForOption(AffectedGlobalOptions.ExclusionRegexOption) ); } } diff --git a/src/dotnet-affected/Views/AffectedInfoView.cs b/src/dotnet-affected/Views/AffectedInfoView.cs index 139e90d..954dc7d 100644 --- a/src/dotnet-affected/Views/AffectedInfoView.cs +++ b/src/dotnet-affected/Views/AffectedInfoView.cs @@ -12,6 +12,7 @@ public AffectedInfoView(AffectedSummary summary) $"referenced by {summary.ProjectsWithChangedFiles.Count()} projects")); Add(new ContentView($"{summary.ChangedPackages.Count()} NuGet Packages have changed")); Add(new ContentView($"{summary.AffectedProjects.Count()} projects are affected by these changes")); + Add(new ContentView($"{summary.ExcludedProjects.Count()} projects were excluded")); Add(new WithChangesAndAffectedView(summary)); } diff --git a/src/dotnet-affected/Views/WithChangesAndAffectedView.cs b/src/dotnet-affected/Views/WithChangesAndAffectedView.cs index 2049d11..75e1600 100644 --- a/src/dotnet-affected/Views/WithChangesAndAffectedView.cs +++ b/src/dotnet-affected/Views/WithChangesAndAffectedView.cs @@ -29,13 +29,16 @@ public WithChangesAndAffectedView(AffectedSummary summary) Add(new ContentView("\nAffected Projects")); - if (!summary.AffectedProjects.Any()) - { + if (summary.AffectedProjects.Any()) + Add(new ProjectInfoTable(summary.AffectedProjects)); + else Add(new ContentView("No projects where affected by any of the changed projects.")); - return; - } - Add(new ProjectInfoTable(summary.AffectedProjects)); + if (summary.ExcludedProjects.Any()) + { + Add(new ContentView("\nExcluded Projects")); + Add(new ProjectInfoTable(summary.ExcludedProjects)); + } } } } diff --git a/test/DotnetAffected.Core.Tests/ProjectExclusionTests.cs b/test/DotnetAffected.Core.Tests/ProjectExclusionTests.cs new file mode 100644 index 0000000..116f936 --- /dev/null +++ b/test/DotnetAffected.Core.Tests/ProjectExclusionTests.cs @@ -0,0 +1,127 @@ +using DotnetAffected.Testing.Utils; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace DotnetAffected.Core.Tests +{ + /// + /// Tests that ensures that the exclusion regex pattern + /// is applied for excluding projects. + /// + public class ProjectExclusionTests + : BaseDotnetAffectedTest + { + protected override AffectedOptions Options => + new AffectedOptions(this.Repository.Path, exclusionRegex: ".Inventory."); + + [Fact] + public async Task When_exclusion_regex_is_present_matching_projects_should_be_excluded() + { + // Create a project + var projectName = "InventoryManagement"; + var msBuildProject = this.Repository.CreateCsProject(projectName); + + // Create another project that depends on the first one + var dependantProjectName = "InventoryManagement.Tests"; + var dependantMsBuildProject = this.Repository.CreateCsProject( + dependantProjectName, + p => p.AddProjectDependency(msBuildProject.FullPath)); + + // Commit so there are no changes + this.Repository.StageAndCommit(); + + // Create changes in the first project + var targetFilePath = Path.Combine(projectName, "file.cs"); + await this.Repository.CreateTextFileAsync(targetFilePath, "// Initial content"); + + Assert.Empty(AffectedSummary.ProjectsWithChangedFiles); + Assert.Empty(AffectedSummary.AffectedProjects); + + Assert.Single(AffectedSummary.ExcludedProjects); + } + + [Fact] + public async Task When_exclusion_regex_is_present_only_matching_projects_should_be_excluded() + { + // Create a project + var projectName = "PurchasingManagement"; + var msBuildProject = this.Repository.CreateCsProject(projectName); + + // Create another project that depends on the first one + var dependantProjectName = "PurchasingManagement.Tests"; + var dependantMsBuildProject = this.Repository.CreateCsProject( + dependantProjectName, + p => p.AddProjectDependency(msBuildProject.FullPath)); + + // Create projects to be excluded + var otherProject = this.Repository.CreateCsProject("InventoryManagement"); + this.Repository.CreateCsProject("InventoryManagement.Api", + p => p.AddProjectDependency(otherProject.FullPath)); + this.Repository.CreateCsProject("InventoryManagement.Tests"); + + // Commit so there are no changes + this.Repository.StageAndCommit(); + + // Create changes in the first project + var targetFilePath = Path.Combine(projectName, "file.cs"); + await this.Repository.CreateTextFileAsync(targetFilePath, "// Initial content"); + + // Create changes in the excluded project + var excludedFilePath = Path.Combine(otherProject.GetName(), "excluded_file.cs"); + await this.Repository.CreateTextFileAsync(excludedFilePath, "// Initial content"); + + Assert.Single(AffectedSummary.ProjectsWithChangedFiles); + Assert.Single(AffectedSummary.AffectedProjects); + + var changedProject = AffectedSummary.ProjectsWithChangedFiles.Single(); + Assert.Equal(projectName, changedProject.GetProjectName()); + Assert.Equal(msBuildProject.FullPath, changedProject.GetFullPath()); + + var affectedProject = AffectedSummary.AffectedProjects.Single(); + Assert.Equal(dependantProjectName, affectedProject.GetProjectName()); + Assert.Equal(dependantMsBuildProject.FullPath, affectedProject.GetFullPath()); + + Assert.Single(AffectedSummary.ExcludedProjects); + } + + [Fact] + public async Task When_exclusion_regex_is_present_affected_projects_should_be_excluded() + { + // Create a project + var projectName = "PurchasingManagement"; + var msBuildProject = this.Repository.CreateCsProject(projectName); + + // Create another project that depends on the first one + var dependantProjectName = "InventoryManagement"; + var dependantMsBuildProject = this.Repository.CreateCsProject( + dependantProjectName, + p => p.AddProjectDependency(msBuildProject.FullPath)); + + // Other project to be excluded by discovery + var otherProjectName = "InventoryManagement.Api"; + var otherProject = this.Repository.CreateCsProject(otherProjectName); + // Commit so there are no changes + this.Repository.StageAndCommit(); + + // Create changes in the first project + var targetFilePath = Path.Combine(projectName, "file.cs"); + await this.Repository.CreateTextFileAsync(targetFilePath, "// Initial content"); + + // Create changes in the other project + var otherTargetFilePath = Path.Combine(otherProjectName, "other_file.cs"); + await this.Repository.CreateTextFileAsync(otherTargetFilePath, "// Initial content"); + + Assert.Single(AffectedSummary.ProjectsWithChangedFiles); + Assert.Empty(AffectedSummary.AffectedProjects); + + var changedProject = AffectedSummary.ProjectsWithChangedFiles.Single(); + Assert.Equal(projectName, changedProject.GetProjectName()); + Assert.Equal(msBuildProject.FullPath, changedProject.GetFullPath()); + + // Both discovered and affected projects should be excluded + Assert.Equal(2, AffectedSummary.ExcludedProjects.Length); + } + } +} diff --git a/test/dotnet-affected.Tests/AffectedCommandTests.cs b/test/dotnet-affected.Tests/AffectedCommandTests.cs index 12b752f..cc093b6 100644 --- a/test/dotnet-affected.Tests/AffectedCommandTests.cs +++ b/test/dotnet-affected.Tests/AffectedCommandTests.cs @@ -101,5 +101,29 @@ public async Task When_no_changes_should_exit_with_code() Assert.Contains($"No affected projects where found for the current changes", output); } + + [Fact] + public async Task When_any_changes_using_exclusion_should_exclude_projects() + { + // Create a project + var projectName = "InventoryManagement"; + var msBuildProject = this.Repository.CreateCsProject(projectName); + + // Create projects to exclude + var otherProject = this.Repository.CreateCsProject("PurchasingManagement"); + this.Repository.CreateFsProject("PurchasingManagement.Api"); + this.Repository.CreateVbProject("PurchasingManagement.Api2"); + + var (output, exitCode) = + await this.InvokeAsync($"-p {Repository.Path} --dry-run --verbose --exclude .Purchasing."); + + Assert.Equal(0, exitCode); + + Assert.Contains($"Include=\"{msBuildProject.FullPath}\"", output); + Assert.DoesNotContain($"Include=\"{otherProject.FullPath}\"", output); + + Assert.Contains($"3 projects were excluded", output); + Assert.Contains($"Excluded Projects", output); + } } }