diff --git a/README.md b/README.md index 6b31024..b5cd7a7 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,10 @@ Options: -f, --format Output format, if --output is specified format will be derived from file extension. Otherwise this defaults to 'plain' -o, --out, --output Output file, if not set stdout will be used --ignore-changed-file Ignore changes in specific files. If these files are a part of the build evaluation process they will still be evaluated, however these files will be considered unchanged by the diff process [] - --log-level Set the log level for the command [default: Information] + --log-level Set the log level for the command [default: Warning] --msbuild-traversal-version Set the version of the Microsoft.Build.Traversal SDK when using traversal output format + --exclude-projects Exclude projects from the output, can be matched multiple times, supports glob patterns + --include-projects Include only projects matching the specified patterns, can be matched multiple times, supports glob patterns ``` The cli should have some sensible defaults, so you can run it without any arguments and get a list of projects that have @@ -66,7 +68,7 @@ dotnet-proj-diff --base main --head feature/new-feature dotnet-proj-diff --base HEAD | dotnet test # Test all changed test projects in test/ directory -dotnet-proj-diff | grep 'test/' | dotnet test +dotnet-proj-diff --include-projects test/**/*.csproj | dotnet test ``` ## CI/CD Integration examples diff --git a/src/dotnet-proj-diff/IConsole.cs b/src/dotnet-proj-diff/IConsole.cs index 3b8f2e8..4cb62b5 100644 --- a/src/dotnet-proj-diff/IConsole.cs +++ b/src/dotnet-proj-diff/IConsole.cs @@ -2,9 +2,6 @@ namespace ProjectDiff.Tool; public interface IConsole { - TextWriter Error { get; } - TextWriter Out { get; } - Stream OpenStandardOutput(); string WorkingDirectory { get; } } diff --git a/src/dotnet-proj-diff/Program.cs b/src/dotnet-proj-diff/Program.cs index 9513a67..1afbadf 100644 --- a/src/dotnet-proj-diff/Program.cs +++ b/src/dotnet-proj-diff/Program.cs @@ -3,6 +3,8 @@ MSBuildLocator.RegisterDefaults(); -var tool = ProjectDiffTool.BuildCli(new SystemConsole()); +var tool = ProjectDiffTool.BuildCli( + new SystemConsole() +); return await tool.InvokeAsync(args); diff --git a/src/dotnet-proj-diff/ProjectDiffCommand.cs b/src/dotnet-proj-diff/ProjectDiffCommand.cs index f379349..7601404 100644 --- a/src/dotnet-proj-diff/ProjectDiffCommand.cs +++ b/src/dotnet-proj-diff/ProjectDiffCommand.cs @@ -1,6 +1,7 @@ using System.CommandLine; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Extensions.FileSystemGlobbing; using Microsoft.Extensions.Logging; using ProjectDiff.Core; using ProjectDiff.Core.Entrypoints; @@ -128,6 +129,20 @@ public sealed class ProjectDiffCommand : RootCommand Description = "Set the version of the Microsoft.Build.Traversal SDK when using traversal output format", }; + private static readonly Option ExcludeProjectsOption = new("--exclude-projects") + { + Arity = ArgumentArity.ZeroOrMore, + Description = "Exclude projects from the output, can be matched multiple times, supports glob patterns", + }; + + private static readonly Option IncludeProjectsOption = new("--include-projects") + { + Arity = ArgumentArity.ZeroOrMore, + Description = + "Include only projects matching the specified patterns, can be matched multiple times, supports glob patterns" + }; + + private readonly IConsole _console; @@ -149,6 +164,8 @@ public ProjectDiffCommand(IConsole console) Options.Add(IgnoreChangedFilesOption); Options.Add(LogLevelOption); Options.Add(MicrosoftBuildTraversalVersionOption); + Options.Add(ExcludeProjectsOption); + Options.Add(IncludeProjectsOption); SetAction(ExecuteAsync); } @@ -159,7 +176,7 @@ private Task ExecuteAsync(ParseResult parseResult, CancellationToken cancel Format = parseResult.GetValue(Format), Output = parseResult.GetValue(OutputOption), Solution = parseResult.GetValue(SolutionOption), - BaseRef = parseResult.GetRequiredValue(BaseCommitOption), + BaseRef = parseResult.GetValue(BaseCommitOption) ?? "HEAD", HeadRef = parseResult.GetValue(HeadCommitOption), MergeBase = parseResult.GetValue(MergeBaseOption), IncludeDeleted = parseResult.GetValue(IncludeDeleted), @@ -167,9 +184,11 @@ private Task ExecuteAsync(ParseResult parseResult, CancellationToken cancel IncludeAdded = parseResult.GetValue(IncludeAdded), IncludeReferencing = parseResult.GetValue(IncludeReferencing), AbsolutePaths = parseResult.GetValue(AbsolutePaths), - IgnoreChangedFile = parseResult.GetRequiredValue(IgnoreChangedFilesOption), + IgnoreChangedFile = parseResult.GetValue(IgnoreChangedFilesOption) ?? [], LogLevel = parseResult.GetValue(LogLevelOption), - MicrosoftBuildTraversalVersion = parseResult.GetValue(MicrosoftBuildTraversalVersionOption) + MicrosoftBuildTraversalVersion = parseResult.GetValue(MicrosoftBuildTraversalVersionOption), + ExcludeProjects = parseResult.GetValue(ExcludeProjectsOption) ?? [], + IncludeProjects = parseResult.GetValue(IncludeProjectsOption) ?? [] }; return ExecuteCoreAsync(settings, cancellationToken); @@ -183,7 +202,7 @@ CancellationToken cancellationToken using var loggerFactory = LoggerFactory.Create(x => { x.AddConsole(c => c.LogToStandardErrorThreshold = LogLevel.Trace); // Log everything to stderr - x.AddSimpleConsole(x => x.IncludeScopes = true); + x.AddSimpleConsole(c => c.IncludeScopes = true); x.SetMinimumLevel(settings.LogLevel); } ); @@ -245,6 +264,19 @@ CancellationToken cancellationToken return 1; } + var matcher = new Matcher(); + if (settings.IncludeProjects.Length > 0) + { + matcher.AddIncludePatterns(settings.IncludeProjects); + } + else + { + matcher.AddInclude("**/*") + .AddInclude("*"); + } + + + matcher.AddExcludePatterns(settings.ExcludeProjects); var projects = result.Projects .Where(ShouldInclude) .ToList(); @@ -255,7 +287,7 @@ CancellationToken cancellationToken logger.LogDebug( "Diff projects: {Projects}", projects.Select(it => new - { it.Path, it.Status, ReferencedProjects = string.Join(',', it.ReferencedProjects) } + { it.Path, it.Status, ReferencedProjects = string.Join(',', it.ReferencedProjects) } ) ); } @@ -272,8 +304,9 @@ await formatter.WriteAsync( return 0; - bool ShouldInclude(DiffProject project) => - project.Status switch + bool ShouldInclude(DiffProject project) + { + var shouldIncludeStatus = project.Status switch { DiffStatus.Removed when settings.IncludeDeleted => true, DiffStatus.Added when settings.IncludeAdded => true, @@ -281,6 +314,15 @@ bool ShouldInclude(DiffProject project) => DiffStatus.ReferenceChanged when settings.IncludeReferencing => true, _ => false }; + if (!shouldIncludeStatus) + { + return false; + } + + + var matchResult = matcher.Match(_console.WorkingDirectory, project.Path); + return matchResult.HasMatches; + } } private static IOutputFormatter GetFormatter(OutputFormat format, ProjectDiffSettings settings) => format switch diff --git a/src/dotnet-proj-diff/ProjectDiffSettings.cs b/src/dotnet-proj-diff/ProjectDiffSettings.cs index 5f98dc7..119d315 100644 --- a/src/dotnet-proj-diff/ProjectDiffSettings.cs +++ b/src/dotnet-proj-diff/ProjectDiffSettings.cs @@ -18,4 +18,6 @@ public sealed class ProjectDiffSettings public required FileInfo[] IgnoreChangedFile { get; init; } = []; public string? MicrosoftBuildTraversalVersion { get; init; } public LogLevel LogLevel { get; init; } = LogLevel.Information; + public required string[] ExcludeProjects { get; init; } + public required string[] IncludeProjects { get; init; } } diff --git a/src/dotnet-proj-diff/ProjectDiffTool.cs b/src/dotnet-proj-diff/ProjectDiffTool.cs index 03ba845..dbb2edd 100644 --- a/src/dotnet-proj-diff/ProjectDiffTool.cs +++ b/src/dotnet-proj-diff/ProjectDiffTool.cs @@ -4,12 +4,19 @@ namespace ProjectDiff.Tool; public static class ProjectDiffTool { - public static CommandLineConfiguration BuildCli(IConsole console) + public static CommandLineConfiguration BuildCli(IConsole console, TextWriter? stderr = null, TextWriter? stdout = null) { - return new CommandLineConfiguration(new ProjectDiffCommand(console)) + var cli = new CommandLineConfiguration(new ProjectDiffCommand(console)); + if (stderr is not null) { - Error = console.Error, - Output = console.Out, - }; + cli.Error = stderr; + } + + if (stdout is not null) + { + cli.Output = stdout; + } + + return cli; } } diff --git a/src/dotnet-proj-diff/SystemConsole.cs b/src/dotnet-proj-diff/SystemConsole.cs index 34c9792..51927e4 100644 --- a/src/dotnet-proj-diff/SystemConsole.cs +++ b/src/dotnet-proj-diff/SystemConsole.cs @@ -2,9 +2,6 @@ namespace ProjectDiff.Tool; public sealed class SystemConsole : IConsole { - public TextWriter Error { get; } = Console.Error; - public TextWriter Out { get; } = Console.Out; - public Stream OpenStandardOutput() => Console.OpenStandardOutput(); public string WorkingDirectory { get; } = Directory.GetCurrentDirectory(); } diff --git a/src/dotnet-proj-diff/dotnet-proj-diff.csproj b/src/dotnet-proj-diff/dotnet-proj-diff.csproj index 2f3f9d2..e04a588 100644 --- a/src/dotnet-proj-diff/dotnet-proj-diff.csproj +++ b/src/dotnet-proj-diff/dotnet-proj-diff.csproj @@ -14,6 +14,7 @@ + all diff --git a/test/ProjectDiff.Tests/Tool/ProjectDiffTests.ExcludeProjects_excludePattern=Tests---.verified.txt b/test/ProjectDiff.Tests/Tool/ProjectDiffTests.ExcludeProjects_excludePattern=Tests---.verified.txt new file mode 100644 index 0000000..619a1e6 --- /dev/null +++ b/test/ProjectDiff.Tests/Tool/ProjectDiffTests.ExcludeProjects_excludePattern=Tests---.verified.txt @@ -0,0 +1,7 @@ +[ + { + path: Sample/Sample.csproj, + name: Sample, + status: Modified + } +] \ No newline at end of file diff --git a/test/ProjectDiff.Tests/Tool/ProjectDiffTests.ExcludeProjects_excludePattern=Tests--.csproj.verified.txt b/test/ProjectDiff.Tests/Tool/ProjectDiffTests.ExcludeProjects_excludePattern=Tests--.csproj.verified.txt new file mode 100644 index 0000000..619a1e6 --- /dev/null +++ b/test/ProjectDiff.Tests/Tool/ProjectDiffTests.ExcludeProjects_excludePattern=Tests--.csproj.verified.txt @@ -0,0 +1,7 @@ +[ + { + path: Sample/Sample.csproj, + name: Sample, + status: Modified + } +] \ No newline at end of file diff --git a/test/ProjectDiff.Tests/Tool/ProjectDiffTests.ExcludeProjects_excludePattern=Tests-Tests.csproj.verified.txt b/test/ProjectDiff.Tests/Tool/ProjectDiffTests.ExcludeProjects_excludePattern=Tests-Tests.csproj.verified.txt new file mode 100644 index 0000000..619a1e6 --- /dev/null +++ b/test/ProjectDiff.Tests/Tool/ProjectDiffTests.ExcludeProjects_excludePattern=Tests-Tests.csproj.verified.txt @@ -0,0 +1,7 @@ +[ + { + path: Sample/Sample.csproj, + name: Sample, + status: Modified + } +] \ No newline at end of file diff --git a/test/ProjectDiff.Tests/Tool/ProjectDiffTests.IncludeProjects_includePattern=Sample---.verified.txt b/test/ProjectDiff.Tests/Tool/ProjectDiffTests.IncludeProjects_includePattern=Sample---.verified.txt new file mode 100644 index 0000000..619a1e6 --- /dev/null +++ b/test/ProjectDiff.Tests/Tool/ProjectDiffTests.IncludeProjects_includePattern=Sample---.verified.txt @@ -0,0 +1,7 @@ +[ + { + path: Sample/Sample.csproj, + name: Sample, + status: Modified + } +] \ No newline at end of file diff --git a/test/ProjectDiff.Tests/Tool/ProjectDiffTests.IncludeProjects_includePattern=Sample--.csproj.verified.txt b/test/ProjectDiff.Tests/Tool/ProjectDiffTests.IncludeProjects_includePattern=Sample--.csproj.verified.txt new file mode 100644 index 0000000..619a1e6 --- /dev/null +++ b/test/ProjectDiff.Tests/Tool/ProjectDiffTests.IncludeProjects_includePattern=Sample--.csproj.verified.txt @@ -0,0 +1,7 @@ +[ + { + path: Sample/Sample.csproj, + name: Sample, + status: Modified + } +] \ No newline at end of file diff --git a/test/ProjectDiff.Tests/Tool/ProjectDiffTests.IncludeProjects_includePattern=Sample-Sample.csproj.verified.txt b/test/ProjectDiff.Tests/Tool/ProjectDiffTests.IncludeProjects_includePattern=Sample-Sample.csproj.verified.txt new file mode 100644 index 0000000..619a1e6 --- /dev/null +++ b/test/ProjectDiff.Tests/Tool/ProjectDiffTests.IncludeProjects_includePattern=Sample-Sample.csproj.verified.txt @@ -0,0 +1,7 @@ +[ + { + path: Sample/Sample.csproj, + name: Sample, + status: Modified + } +] \ No newline at end of file diff --git a/test/ProjectDiff.Tests/Tool/ProjectDiffTests.cs b/test/ProjectDiff.Tests/Tool/ProjectDiffTests.cs index d35aaa8..200f2e6 100644 --- a/test/ProjectDiff.Tests/Tool/ProjectDiffTests.cs +++ b/test/ProjectDiff.Tests/Tool/ProjectDiffTests.cs @@ -229,11 +229,64 @@ await repo.UpdateSolutionAsync( x.RemoveProject(proj); } ); - var output = await ExecuteAndReadStdout(repo, $"--solution={sln}"); + var output = await ExecuteAndReadStdout(repo, "--solution", sln); await VerifyJson(output); } + [Theory] + [InlineData("Sample/*.csproj")] + [InlineData("Sample/Sample.csproj")] + [InlineData("Sample/**")] + public async Task IncludeProjects(string includePattern) + { + using var repo = await TestRepository.SetupAsync(static r => + { + r.CreateDirectory("Sample"); + r.CreateDirectory("Tests"); + + r.CreateProject("Sample/Sample.csproj"); + r.CreateProject( + "Tests/Tests.csproj", + p => p.AddItem("ProjectReference", @"..\Sample\Sample.csproj") + ); + return Task.CompletedTask; + } + ); + await repo.WriteAllTextAsync("Sample/MyClass.cs", "// Some new content"); + + var output = await ExecuteAndReadStdout(repo, $"--include-projects={includePattern}"); + + await VerifyJson(output) + .UseParameters(includePattern); + } + + [Theory] + [InlineData("Tests/Tests.csproj")] + [InlineData("Tests/**")] + [InlineData("Tests/*.csproj")] + public async Task ExcludeProjects(string excludePattern) + { + using var repo = await TestRepository.SetupAsync(static r => + { + r.CreateDirectory("Sample"); + r.CreateDirectory("Tests"); + + r.CreateProject("Sample/Sample.csproj"); + r.CreateProject( + "Tests/Tests.csproj", + p => p.AddItem("ProjectReference", @"..\Sample\Sample.csproj") + ); + return Task.CompletedTask; + } + ); + await repo.WriteAllTextAsync("Sample/MyClass.cs", "// Some new content"); + + var output = await ExecuteAndReadStdout(repo, $"--exclude-projects={excludePattern}"); + + await VerifyJson(output) + .UseParameters(excludePattern); + } [Fact] public async Task DetectsAddedProjects() @@ -303,14 +356,15 @@ params string[] args "--log-level=Error", "--format=json", ]; + var stderr = new StringWriter(); var console = new TestConsole(repository.WorkingDirectory); - var cli = ProjectDiffTool.BuildCli(console); + var cli = ProjectDiffTool.BuildCli(console, stderr: stderr); var exitCode = await cli.InvokeAsync([.. args, .. defaultArgs]); if (exitCode != 0) { - var stderr = console.GetStandardError(); - Assert.Fail($"Program exited with exit code {exitCode}: {stderr}"); + var error = stderr.ToString(); + Assert.Fail($"Program exited with exit code {exitCode}: {error}"); } return console.GetStandardOutput(); diff --git a/test/ProjectDiff.Tests/Utils/TestConsole.cs b/test/ProjectDiff.Tests/Utils/TestConsole.cs index 8acf04f..f955d39 100644 --- a/test/ProjectDiff.Tests/Utils/TestConsole.cs +++ b/test/ProjectDiff.Tests/Utils/TestConsole.cs @@ -1,4 +1,5 @@ using System.IO.Pipelines; +using System.Text; using ProjectDiff.Tool; namespace ProjectDiff.Tests.Utils; @@ -6,36 +7,17 @@ namespace ProjectDiff.Tests.Utils; public class TestConsole : IConsole { private readonly MemoryStream _outStream = new MemoryStream(); - private readonly MemoryStream _errorStream = new MemoryStream(); - private readonly StreamWriter _out; - private readonly StreamWriter _error; public TestConsole(string workingDirectory) { - _out = new StreamWriter(_outStream); - _error = new StreamWriter(_errorStream); WorkingDirectory = workingDirectory; } public string WorkingDirectory { get; } - public TextWriter Error => _error; - public TextWriter Out => _out; - public string GetStandardOutput() { - _out.Flush(); - _outStream.Seek(0, SeekOrigin.Begin); - using var reader = new StreamReader(_outStream); - return reader.ReadToEnd(); - } - - public string GetStandardError() - { - _error.Flush(); - _errorStream.Seek(0, SeekOrigin.Begin); - using var reader = new StreamReader(_errorStream); - return reader.ReadToEnd(); + return Encoding.UTF8.GetString(_outStream.ToArray()); } public Stream OpenStandardOutput()