diff --git a/docfx/docs/nbgv-cli.md b/docfx/docs/nbgv-cli.md index ef084358..1f35c3f1 100644 --- a/docfx/docs/nbgv-cli.md +++ b/docfx/docs/nbgv-cli.md @@ -250,4 +250,6 @@ usage: nbgv [] prepare-release Prepares a release by creating a release branch for the current version and adjusting the version on the current branch. + path-filters Manages the pathFilters property in version.json files + based on MSBuild project references and imports. ``` diff --git a/docfx/docs/path-filters.md b/docfx/docs/path-filters.md index a0b0a3fd..63729c9e 100644 --- a/docfx/docs/path-filters.md +++ b/docfx/docs/path-filters.md @@ -57,3 +57,138 @@ Multiple path filters may also be specified. The order is irrelevant. After a pa | `/root-file.txt`
`:/dir/file.txt` | File will be included. Path is absolute (i.e., relative to the root of the repository). | | `:!bar.txt`
`:^../foo/baz.txt` | File will be excluded. Path is relative to the `version.json` file. `:!` and `:^` prefixes are synonymous. | | `:!/root-file.txt` | File will be excluded. Path is absolute (i.e., relative to the root of the repository). | + +## Managing path filters with `nbgv path-filters` + +For repositories with multiple projects and version.json files, manually maintaining `pathFilters` can be error-prone. The `nbgv path-filters` command automates this process by analyzing your MSBuild project structure and computing the correct path filters based on project references and shared build files. + +### When to use path-filters command + +Use the `nbgv path-filters` command in the following scenarios: + +- **Monorepo with multiple projects** - You have multiple projects in different directories, each with their own `version.json` +- **Complex project dependencies** - Projects reference each other, and you need path filters to reflect these dependencies +- **Shared build files** - You use `Directory.Build.props` or other shared MSBuild imports that should be tracked by multiple projects +- **Maintaining accuracy** - You want to ensure path filters automatically stay in sync with your project structure + +### How it works + +The `nbgv path-filters` command uses the MSBuild project graph API to: + +1. **Discover project files** - Finds all MSBuild project files (`.csproj`, `.vbproj`, etc.) associated with each `version.json` +2. **Compute transitive dependencies** - Uses the MSBuild project graph to determine the complete set of projects that each project depends on +3. **Include shared build files** - Identifies MSBuild imports like `Directory.Build.props` that reside within the repository +4. **Respect boundaries** - Stops searching for projects at directories containing their own `version.json` files, ensuring clean separation of concerns +5. **Generate path filters** - Converts the discovered projects and files into appropriate `pathFilters` entries + +### Important behaviors + +- **Only processes version.json files with projects** - A `version.json` file with no associated MSBuild projects is skipped and left unchanged +- **Respects version.json hierarchy** - When searching for projects under a `version.json`, the search stops at subdirectories that have their own `version.json` files +- **Includes project directories** - Path filters include entire project directories (e.g., `/ProjectA`) rather than individual `.csproj` files so that all source changes under those directories result in a new version of the project +- **Filters ignored files** - Automatically excludes files that match `.gitignore` patterns (including generated directories like `obj/` and `bin/`) + +### Usage + +#### Check current path filters + +To see what path filters should be present without making changes: + +```ps1 +nbgv path-filters check +``` + +This command will: +- Compare the computed path filters against what's currently in each `version.json` +- Display mismatches (missing or extra filters) +- Exit with non-zero code if any mismatches are found (useful for CI validation) + +#### Update path filters + +To automatically compute and update all `version.json` files: + +```ps1 +nbgv path-filters update +``` + +This command will: +- Compute the correct path filters for each `version.json` +- Update each file that needs changes +- Display which files were updated +- Skip any `version.json` files that have no associated projects + +#### Specify which version.json files to process + +By default, both commands search from the current directory. You can specify specific paths: + +```ps1 +nbgv path-filters check ./src/ProjectA ./src/ProjectB +``` + +#### Include additional project file extensions + +By default, the tool searches for `.csproj` and `.vbproj` files. You can include other extensions: + +```ps1 +nbgv path-filters update --ext .fsproj --ext .csproj +``` + +### Example + +Consider a monorepo with this structure: + +``` +/ + version.json (version: "1.0") + Directory.Build.props + /ProjectA + version.json (version: "2.0") + ProjectA.csproj + /ProjectB + version.json (version: "3.0") + ProjectB.csproj + (ProjectB.csproj references ProjectA.csproj) +``` + +Running `nbgv path-filters update` would produce: + +**Root version.json** - Left unchanged (has no projects directly under it) + +**ProjectA/version.json**: +```json +{ + "version": "2.0", + "pathFilters": [ + "/ProjectA", + "/Directory.Build.props" + ] +} +``` + +**ProjectB/version.json**: +```json +{ + "version": "3.0", + "pathFilters": [ + "/ProjectA", + "/ProjectB", + "/Directory.Build.props" + ] +} +``` + +Note that ProjectB's filters include ProjectA because ProjectB depends on it. Any change to ProjectA's source files will now correctly trigger a version bump for ProjectB as well. + +### CI Integration + +You can use the `path-filters check` command in your CI pipeline to validate that `pathFilters` are correctly maintained: + +```ps1 +nbgv path-filters check +if ($LASTEXITCODE -ne 0) { + Write-Error "Path filters are out of date. Run 'nbgv path-filters update' locally." + exit 1 +} +``` + +This ensures that developers keep path filters in sync with project structure changes. diff --git a/src/NerdBank.GitVersioning/GitContext.cs b/src/NerdBank.GitVersioning/GitContext.cs index 74601ae4..de2aed45 100644 --- a/src/NerdBank.GitVersioning/GitContext.cs +++ b/src/NerdBank.GitVersioning/GitContext.cs @@ -228,6 +228,13 @@ public static GitContext Create(string path, string? committish = null, Engine e } } + /// + /// Determines whether a file would be ignored by git based on common .gitignore patterns. + /// + /// The absolute file path to check. + /// True if the file is ignored by git; false otherwise. + public virtual bool IsIgnored(string path) => false; + /// public void Dispose() { @@ -294,7 +301,7 @@ internal static bool TryFindGitPaths(string? path, [NotNullWhen(true)] out strin internal abstract Version GetIdAsVersion(VersionOptions? committedVersion, VersionOptions? workingVersion, int versionHeight); - internal string GetRepoRelativePath(string absolutePath) + internal string GetRepoRelativePath(string absolutePath, bool replaceBackslashes = false) { string? repoRoot = this.WorkingTreePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); @@ -303,8 +310,9 @@ internal string GetRepoRelativePath(string absolutePath) throw new ArgumentException($"Path '{absolutePath}' is not within repository '{repoRoot}'", nameof(absolutePath)); } - return absolutePath.Substring(repoRoot.Length) + string result = absolutePath.Substring(repoRoot.Length) .TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return replaceBackslashes ? result.Replace('\\', '/') : result; } /// diff --git a/src/NerdBank.GitVersioning/LibGit2/LibGit2Context.cs b/src/NerdBank.GitVersioning/LibGit2/LibGit2Context.cs index 684c9a2e..9cd9f28b 100644 --- a/src/NerdBank.GitVersioning/LibGit2/LibGit2Context.cs +++ b/src/NerdBank.GitVersioning/LibGit2/LibGit2Context.cs @@ -88,6 +88,9 @@ public static LibGit2Context Create(string path, string? committish = null) }; } + /// + public override bool IsIgnored(string path) => this.Repository.Ignore.IsPathIgnored(this.GetRepoRelativePath(path, replaceBackslashes: true)); + /// public override void ApplyTag(string name) => this.Repository.Tags.Add(name, this.Commit); diff --git a/src/nbgv/Program.cs b/src/nbgv/Program.cs index 89bb95a1..fa45e272 100644 --- a/src/nbgv/Program.cs +++ b/src/nbgv/Program.cs @@ -4,7 +4,11 @@ using System.CommandLine; using System.Globalization; using System.Reflection; +using System.Runtime.InteropServices; using Microsoft.Build.Construction; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Graph; +using Microsoft.Build.Locator; using Nerdbank.GitVersioning.Commands; using Nerdbank.GitVersioning.LibGit2; using Newtonsoft.Json; @@ -61,6 +65,7 @@ private enum ExitCodes InternalError, InvalidTagNameSetting, InvalidUnformattedCommitMessage, + PathFiltersMismatch, } private static bool AlwaysUseLibGit2 => string.Equals(Environment.GetEnvironmentVariable("NBGV_GitEngine"), "LibGit2", StringComparison.Ordinal); @@ -374,6 +379,51 @@ private static RootCommand BuildCommandLine() }); } + Command pathFilters; + { + var paths = new Argument("path") + { + Description = "One or more paths to search. Each may be a directory (recursively searched for version.json files) or a version.json file directly (processed without recursive search). Defaults to the current directory.", + Arity = ArgumentArity.ZeroOrMore, + }; + var extraExtensions = new Option("--ext") + { + Description = "Additional MSBuild project file extensions to include (e.g. --ext .myproj). Default extensions are .csproj, .vbproj, .fsproj, and .vcxproj.", + Arity = ArgumentArity.OneOrMore, + AllowMultipleArgumentsPerToken = true, + }; + + var updateSubCommand = new Command("update", "Computes pathFilters based on MSBuild project references and imports and writes the result to each applicable version.json file.") + { + paths, + extraExtensions, + }; + updateSubCommand.SetAction(async (ParseResult parseResult, CancellationToken cancellationToken) => + { + var pathsValue = parseResult.GetValue(paths); + var extraExtensionsValue = parseResult.GetValue(extraExtensions); + return await OnPathFiltersUpdateCommand(pathsValue, extraExtensionsValue); + }); + + var checkSubCommand = new Command("check", "Computes pathFilters based on MSBuild project references and imports and verifies they match what is in each applicable version.json file. Exits with a non-zero exit code when differences are found.") + { + paths, + extraExtensions, + }; + checkSubCommand.SetAction(async (ParseResult parseResult, CancellationToken cancellationToken) => + { + var pathsValue = parseResult.GetValue(paths); + var extraExtensionsValue = parseResult.GetValue(extraExtensions); + return await OnPathFiltersCheckCommand(pathsValue, extraExtensionsValue); + }); + + pathFilters = new Command("path-filters", "Manages the pathFilters property in version.json files based on MSBuild project references and imports.") + { + updateSubCommand, + checkSubCommand, + }; + } + var root = new RootCommand($"{ThisAssembly.AssemblyTitle} v{ThisAssembly.AssemblyInformationalVersion}") { install, @@ -383,6 +433,7 @@ private static RootCommand BuildCommandLine() getCommits, cloud, prepareRelease, + pathFilters, }; return root; @@ -415,6 +466,20 @@ private static GitContext.Engine GetEffectiveGitEngine(bool preferReadWrite = fa private static int MainInner(string[] args) { + // Register MSBuild locator to ensure SDK resolvers are available for project evaluation. + // This must be called before any MSBuild code is loaded. + if (!MSBuildLocator.IsRegistered) + { + try + { + MSBuildLocator.RegisterDefaults(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Failed to register MSBuildLocator: {ex.Message}"); + } + } + try { RootCommand rootCommand = BuildCommandLine(); @@ -1030,6 +1095,328 @@ private static Task OnPrepareReleaseCommand(string project, string nextVers } } +#nullable enable + private static Task OnPathFiltersUpdateCommand(string[] paths, string[] extraExtensions) + { + return OnPathFiltersCommandCore(paths, extraExtensions, updateMode: true); + } + + private static Task OnPathFiltersCheckCommand(string[] paths, string[] extraExtensions) + { + return OnPathFiltersCommandCore(paths, extraExtensions, updateMode: false); + } + + private static Task OnPathFiltersCommandCore(string[] paths, string[] extraExtensions, bool updateMode) + { + IReadOnlyList projectExtensions = GetProjectExtensions(extraExtensions); + bool anyFound = false; + bool anyMismatch = false; + + // First, collect all version.json paths so we can use them for boundary checking + IEnumerable allVersionJsonPaths = FindVersionJsonPaths(paths).ToList(); + var allVersionJsonDirs = new HashSet( + allVersionJsonPaths.Select(p => Path.GetDirectoryName(p)!), + StringComparer.OrdinalIgnoreCase); + + foreach (string versionJsonPath in allVersionJsonPaths) + { + anyFound = true; + string versionJsonDir = Path.GetDirectoryName(versionJsonPath)!; + + using GitContext context = GitContext.Create(versionJsonDir, engine: GetEffectiveGitEngine(preferReadWrite: true)); + if (!context.IsRepository) + { + Console.Error.WriteLine($"No git repository found for version.json at: {versionJsonPath}"); + continue; + } + + try + { + IReadOnlyList computed = ComputePathFilters(versionJsonDir, context.WorkingTreePath, projectExtensions, allVersionJsonDirs, context); + + // Skip version.json files that have no associated projects + if (computed.Count == 0) + { + continue; + } + + VersionOptions? versionOptions = context.VersionFile.GetWorkingCopyVersion( + VersionFileRequirements.NonMergedResult | VersionFileRequirements.AcceptInheritingFile); + + if (versionOptions is null) + { + Console.Error.WriteLine($"Could not load version.json at: {versionJsonPath}"); + continue; + } + + if (updateMode) + { + versionOptions.PathFilters = computed.Count > 0 ? computed : null; + context.VersionFile.SetVersion(versionJsonDir, versionOptions); + Console.WriteLine($"Updated pathFilters in: {versionJsonPath}"); + } + else + { + // Check mode: compare computed with existing + IReadOnlyList existing = versionOptions.PathFilters ?? []; + var computedPaths = new SortedSet( + computed.Select(f => f.RepoRelativePath), + StringComparer.OrdinalIgnoreCase); + var existingPaths = new SortedSet( + existing.Select(f => f.RepoRelativePath), + StringComparer.OrdinalIgnoreCase); + + if (!computedPaths.SetEquals(existingPaths)) + { + anyMismatch = true; + Console.Error.WriteLine($"pathFilters mismatch in: {versionJsonPath}"); + + foreach (string m in computedPaths.Except(existingPaths, StringComparer.OrdinalIgnoreCase)) + { + Console.Error.WriteLine($" + missing: :/{m}"); + } + + foreach (string e in existingPaths.Except(computedPaths, StringComparer.OrdinalIgnoreCase)) + { + Console.Error.WriteLine($" - extra: :/{e}"); + } + + Console.Error.WriteLine("Use the 'nbgv path-filters update' command to update the pathFilters."); + } + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error processing {versionJsonPath}: {ex.Message}"); + } + } + + if (!anyFound) + { + Console.Error.WriteLine("No version.json files found."); + return Task.FromResult((int)ExitCodes.NoVersionJsonFound); + } + + return Task.FromResult(anyMismatch ? (int)ExitCodes.PathFiltersMismatch : (int)ExitCodes.OK); + } + + /// + /// Computes the list for a given version.json directory + /// by using the MSBuild project graph API to find the transitive closure of project references + /// and the MSBuild evaluation model to find all imported files within the repository. + /// + /// The directory containing the version.json file. + /// The absolute path to the root of the git repository. + /// The MSBuild project file extensions to search for. + /// Set of all other version.json directories (for boundary checking). + /// The git context for accessing ignore rules. + /// A sorted, deduplicated list of repo-root-relative objects, or empty if no projects found. + private static IReadOnlyList ComputePathFilters( + string versionJsonDir, + string repoRoot, + IReadOnlyList projectExtensions, + ISet versionJsonDirs, + GitContext gitContext) + { + // Find all project files under the version.json directory, but stop at other version.json directories. + List projectFiles = new(); + foreach (string ext in projectExtensions) + { + projectFiles.AddRange(FindProjectFilesRespectingBoundaries(versionJsonDir, ext, versionJsonDir, versionJsonDirs)); + } + + projectFiles = projectFiles + .Select(Path.GetFullPath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (projectFiles.Count == 0) + { + // No projects under this version.json, return empty to signal it should be skipped + return []; + } + + var globalProperties = new Dictionary + { + ["BuildingProject"] = "false", + }; + + // Use ProjectGraph to find the transitive closure of all project references. + IEnumerable entryPoints = projectFiles.Select(f => new ProjectGraphEntryPoint(f, globalProperties)); + ProjectGraph graph = new ProjectGraph(entryPoints); + + List allProjectFiles = graph.ProjectNodes + .Select(n => Path.GetFullPath(n.ProjectInstance.FullPath)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + // For each project in the transitive closure, add its directory to the results. + // We include entire project directories rather than individual files. + var results = new SortedSet(StringComparer.OrdinalIgnoreCase); + + foreach (string projectFile in allProjectFiles) + { + if (IsWithinRepo(projectFile, repoRoot)) + { + // Add the project directory, not the individual .csproj file + string projectDir = Path.GetDirectoryName(projectFile)!; + results.Add(projectDir); + } + } + + // For MSBuild imports, we add specific imported files (not their directories) + // since these are typically shared build files like Directory.Build.props + using var collection = new ProjectCollection(globalProperties); + foreach (string projectFile in allProjectFiles) + { + try + { + Project project = collection.LoadProject(projectFile, globalProperties, toolsVersion: null); + foreach (ResolvedImport import in project.Imports) + { + string importPath = Path.GetFullPath(import.ImportedProject.FullPath); + if (IsWithinRepo(importPath, repoRoot)) + { + // Skip files that git would ignore (e.g., bin/, obj/ directories, per .gitignore) + if (gitContext.IsIgnored(importPath)) + { + continue; + } + + results.Add(importPath); + } + } + + collection.UnloadProject(project); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Could not load imports for {projectFile}: {ex.Message}"); + } + } + + // Convert absolute paths to repo-root-relative FilterPath objects. + return results + .Select(p => + { + string repoRelative = Path.GetRelativePath(repoRoot, p).Replace('\\', '/'); + return new FilterPath(":/" + repoRelative, string.Empty); + }) + .ToList(); + } + + /// + /// Recursively finds project files starting from searchDir, but stops descending into + /// directories that contain their own version.json files (except for the root startDir). + /// + private static IEnumerable FindProjectFilesRespectingBoundaries( + string searchDir, + string projectExtension, + string startDir, + ISet versionJsonDirs) + { + // Find all projects in the current directory + foreach (string file in Directory.EnumerateFiles(searchDir, "*" + projectExtension)) + { + yield return file; + } + + // Recursively search subdirectories, but skip those with their own version.json + foreach (string subDir in Directory.EnumerateDirectories(searchDir)) + { + // Skip if this subdirectory (or any ancestor) has a version.json (except the start directory) + if (subDir != startDir && versionJsonDirs.Contains(subDir)) + { + continue; + } + + foreach (string file in FindProjectFilesRespectingBoundaries(subDir, projectExtension, startDir, versionJsonDirs)) + { + yield return file; + } + } + } + + /// + /// Returns a sequence of version.json file paths found by interpreting each of the given + /// input paths. Directories are searched recursively; a path directly to a version.json file + /// is used as-is without recursive search. + /// + private static IEnumerable FindVersionJsonPaths(string[]? paths) + { + IEnumerable searchRoots = paths is { Length: > 0 } + ? paths + : [Directory.GetCurrentDirectory()]; + + foreach (string path in searchRoots) + { + string fullPath = Path.GetFullPath(path); + + if (string.Equals(Path.GetFileName(fullPath), VersionFile.JsonFileName, StringComparison.OrdinalIgnoreCase)) + { + // Direct path to a version.json file – use it without recursive search. + if (File.Exists(fullPath)) + { + yield return fullPath; + } + else + { + Console.Error.WriteLine($"File not found: {fullPath}"); + } + } + else if (Directory.Exists(fullPath)) + { + // Directory – recursively find all version.json files. + foreach (string versionJson in Directory.EnumerateFiles(fullPath, VersionFile.JsonFileName, SearchOption.AllDirectories)) + { + yield return versionJson; + } + } + else + { + Console.Error.WriteLine($"Path not found: {fullPath}"); + } + } + } + + /// + /// Gets the default project file extensions plus any extras provided on the command line. + /// + private static IReadOnlyList GetProjectExtensions(string[]? extraExtensions) + { + var extensions = new List { ".csproj", ".vbproj", ".fsproj", ".vcxproj" }; + if (extraExtensions is { Length: > 0 }) + { + foreach (string ext in extraExtensions) + { + string normalized = ext.StartsWith('.') ? ext : "." + ext; + if (!extensions.Contains(normalized, StringComparer.OrdinalIgnoreCase)) + { + extensions.Add(normalized); + } + } + } + + return extensions; + } + + /// + /// Determines whether is located within the repository root. + /// + private static bool IsWithinRepo(string absolutePath, string repoRoot) + { + string normalizedPath = Path.GetFullPath(absolutePath); + string normalizedRoot = Path.GetFullPath(repoRoot).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + + StringComparison comparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + return normalizedPath.StartsWith(normalizedRoot, comparison); + } + +#nullable restore private static async Task GetLatestPackageVersionAsync(string packageId, string root, IReadOnlyList sources, CancellationToken cancellationToken = default) { ISettings settings = Settings.LoadDefaultSettings(root); diff --git a/src/nbgv/nbgv.csproj b/src/nbgv/nbgv.csproj index cccf8e7b..f5622193 100644 --- a/src/nbgv/nbgv.csproj +++ b/src/nbgv/nbgv.csproj @@ -10,10 +10,11 @@ - + - + +