From 383bff458b9d4b970e5ca5d5962b3dbd5c32fa17 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Tue, 23 Jun 2026 11:59:56 -0600 Subject: [PATCH 1/8] Fix: Use MSBuildLocator properly and filter generated imports - Register MSBuildLocator at application startup (MainInner) instead of per-command - MSBuildLocator must be registered before any MSBuild code is loaded - Add Microsoft.Build.Locator package reference - Filter out obj/ and bin/ directory files from pathFilters (these are generated) - Fix StyleCop warning SA1507 (multiple blank lines) This ensures the path-filters command correctly uses the installed MSBuild and SDK resolvers when evaluating projects, and includes only source files in the computed pathFilters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/nbgv/Program.cs | 329 +++++++++++++++++++++++++++++++++++++++++++ src/nbgv/nbgv.csproj | 5 +- 2 files changed, 332 insertions(+), 2 deletions(-) diff --git a/src/nbgv/Program.cs b/src/nbgv/Program.cs index 89bb95a1..1c751142 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", ["-e"]) + { + 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,270 @@ 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; + + foreach (string versionJsonPath in FindVersionJsonPaths(paths)) + { + anyFound = true; + string versionJsonDir = Path.GetDirectoryName(versionJsonPath)!; + + using GitContext context = GitContext.Create(versionJsonDir, engine: GetEffectiveGitEngine()); + if (!context.IsRepository) + { + Console.Error.WriteLine($"No git repository found for version.json at: {versionJsonPath}"); + continue; + } + + try + { + IReadOnlyList computed = ComputePathFilters(versionJsonDir, context.WorkingTreePath, projectExtensions); + 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}"); + } + } + } + } + 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. + /// A sorted, deduplicated list of repo-root-relative objects. + private static IReadOnlyList ComputePathFilters( + string versionJsonDir, + string repoRoot, + IReadOnlyList projectExtensions) + { + // Find all project files under the version.json directory. + List projectFiles = projectExtensions + .SelectMany(ext => Directory.EnumerateFiles(versionJsonDir, "*" + ext, SearchOption.AllDirectories)) + .Select(Path.GetFullPath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (projectFiles.Count == 0) + { + Console.Error.WriteLine($"Warning: No project files found under: {versionJsonDir}"); + 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, use the MSBuild evaluation model + // to collect all imported files that reside within the repository. + var results = new SortedSet(StringComparer.OrdinalIgnoreCase); + + foreach (string projectFile in allProjectFiles) + { + if (IsWithinRepo(projectFile, repoRoot)) + { + results.Add(projectFile); + } + } + + 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 in obj and bin directories as they are generated + string normalizedPath = importPath.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + if (normalizedPath.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}") || + normalizedPath.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")) + { + 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(); + } + + /// + /// 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 @@ - + - + + From 2bd4103a8b2768212c4230bb25ae2249eb0c16f1 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Tue, 23 Jun 2026 12:50:08 -0600 Subject: [PATCH 2/8] Refine: Include project directories in pathFilters instead of individual project files - Include the directory containing each project (e.g., /A) instead of the .csproj file - Keep individual files for MSBuild imports like Directory.Build.props since these are shared build files not contained in project directories - This makes pathFilters cleaner and more intuitive For example, B's pathFilters now includes /A and /B (directories) plus /Directory.Build.props (shared file) instead of /A/A.csproj and /B/B.csproj. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/nbgv/Program.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/nbgv/Program.cs b/src/nbgv/Program.cs index 1c751142..6bbfb599 100644 --- a/src/nbgv/Program.cs +++ b/src/nbgv/Program.cs @@ -1226,18 +1226,22 @@ private static IReadOnlyList ComputePathFilters( .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); - // For each project in the transitive closure, use the MSBuild evaluation model - // to collect all imported files that reside within the repository. + // 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)) { - results.Add(projectFile); + // 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) { From de2c16b430b275fda4aaaea58ba3e492252e50d9 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Tue, 23 Jun 2026 13:06:05 -0600 Subject: [PATCH 3/8] Refine: Respect version.json boundaries and skip orphaned version.json files - Only process version.json files that have at least one MSBuild project - When searching for projects, stop at directories containing other version.json files - This prevents root version.json files with no direct projects from being processed - Each version.json boundary is respected, ensuring clean separation of concerns Example: In a directory structure with root/A/version.json and root/B/version.json, the root version.json is left untouched because all its projects are in subdirectories with their own version.json files. Only A and B are processed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/nbgv/Program.cs | 68 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/src/nbgv/Program.cs b/src/nbgv/Program.cs index 6bbfb599..f20e8996 100644 --- a/src/nbgv/Program.cs +++ b/src/nbgv/Program.cs @@ -1112,7 +1112,13 @@ private static Task OnPathFiltersCommandCore(string[] paths, string[] extra bool anyFound = false; bool anyMismatch = false; - foreach (string versionJsonPath in FindVersionJsonPaths(paths)) + // 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)!; @@ -1126,7 +1132,14 @@ private static Task OnPathFiltersCommandCore(string[] paths, string[] extra try { - IReadOnlyList computed = ComputePathFilters(versionJsonDir, context.WorkingTreePath, projectExtensions); + IReadOnlyList computed = ComputePathFilters(versionJsonDir, context.WorkingTreePath, projectExtensions, allVersionJsonDirs); + + // Skip version.json files that have no associated projects + if (computed.Count == 0) + { + continue; + } + VersionOptions? versionOptions = context.VersionFile.GetWorkingCopyVersion( VersionFileRequirements.NonMergedResult | VersionFileRequirements.AcceptInheritingFile); @@ -1193,22 +1206,29 @@ private static Task OnPathFiltersCommandCore(string[] paths, string[] extra /// 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. - /// A sorted, deduplicated list of repo-root-relative objects. + /// Set of all other version.json directories (for boundary checking). + /// 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) + IReadOnlyList projectExtensions, + ISet versionJsonDirs) { - // Find all project files under the version.json directory. - List projectFiles = projectExtensions - .SelectMany(ext => Directory.EnumerateFiles(versionJsonDir, "*" + ext, SearchOption.AllDirectories)) + // 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) { - Console.Error.WriteLine($"Warning: No project files found under: {versionJsonDir}"); + // No projects under this version.json, return empty to signal it should be skipped return []; } @@ -1283,6 +1303,38 @@ private static IReadOnlyList ComputePathFilters( .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 From 0cd18d0b653607e7db13257ed2f8b0c290920c82 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Tue, 23 Jun 2026 13:13:17 -0600 Subject: [PATCH 4/8] Drop alias for `--ext` parameter --- src/nbgv/Program.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/nbgv/Program.cs b/src/nbgv/Program.cs index f20e8996..778924d7 100644 --- a/src/nbgv/Program.cs +++ b/src/nbgv/Program.cs @@ -386,7 +386,7 @@ private static RootCommand BuildCommandLine() 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", ["-e"]) + 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, @@ -1180,6 +1180,8 @@ private static Task OnPathFiltersCommandCore(string[] paths, string[] extra { Console.Error.WriteLine($" - extra: :/{e}"); } + + Console.Error.WriteLine("Use the 'nbgv path-filters update' command to update the pathFilters."); } } } From e94174e187417067fd94cd1c81df390ca4554e01 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Tue, 23 Jun 2026 13:14:53 -0600 Subject: [PATCH 5/8] docs: Document the nbgv path-filters command - Add comprehensive documentation to path-filters.md covering: - When to use path-filters command (monorepos, complex dependencies) - How it works (project discovery, transitive dependencies, shared files) - Important behaviors (orphan skipping, boundary respect, directory inclusion) - Usage examples (check, update commands with options) - Real-world example with multiple projects - CI integration guidance - Add reference in nbgv-cli.md command list linking to path-filters documentation The path-filters command automates computation and validation of pathFilters based on MSBuild project structure, making it easy to maintain correct filters in monorepos with multiple versioned projects. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docfx/docs/nbgv-cli.md | 4 ++ docfx/docs/path-filters.md | 135 +++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/docfx/docs/nbgv-cli.md b/docfx/docs/nbgv-cli.md index ef084358..dd21af72 100644 --- a/docfx/docs/nbgv-cli.md +++ b/docfx/docs/nbgv-cli.md @@ -250,4 +250,8 @@ 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. + See Path filters for more information. + ``` diff --git a/docfx/docs/path-filters.md b/docfx/docs/path-filters.md index a0b0a3fd..c565fa24 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, making filters cleaner and more intuitive +- **Filters generated files** - Automatically excludes files in `obj/` and `bin/` directories (NuGet-generated imports) + +### 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 --path ./src/ProjectA --path ./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. From 250982960c67435c7038264efc2df7ad4a9a0b60 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Tue, 23 Jun 2026 13:30:17 -0600 Subject: [PATCH 6/8] refine: Use git ignore-aware filtering instead of hard-coded bin/obj paths - Added virtual IsIgnored(path) method to GitContext base class - Implemented in LibGit2Context to check against common .gitignore patterns (bin/, obj/, packages/, .vs/, .vscode/, node_modules/) - This ensures generated files are excluded from pathFilters more comprehensively - Simplistic but effective approach that covers the most common use cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docfx/docs/nbgv-cli.md | 2 -- docfx/docs/path-filters.md | 6 ++--- src/NerdBank.GitVersioning/GitContext.cs | 7 +++++ .../LibGit2/LibGit2Context.cs | 27 +++++++++++++++++++ src/nbgv/Program.cs | 12 ++++----- 5 files changed, 43 insertions(+), 11 deletions(-) diff --git a/docfx/docs/nbgv-cli.md b/docfx/docs/nbgv-cli.md index dd21af72..1f35c3f1 100644 --- a/docfx/docs/nbgv-cli.md +++ b/docfx/docs/nbgv-cli.md @@ -252,6 +252,4 @@ usage: nbgv [] current branch. path-filters Manages the pathFilters property in version.json files based on MSBuild project references and imports. - See Path filters for more information. - ``` diff --git a/docfx/docs/path-filters.md b/docfx/docs/path-filters.md index c565fa24..63729c9e 100644 --- a/docfx/docs/path-filters.md +++ b/docfx/docs/path-filters.md @@ -85,8 +85,8 @@ The `nbgv path-filters` command uses the MSBuild project graph API to: - **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, making filters cleaner and more intuitive -- **Filters generated files** - Automatically excludes files in `obj/` and `bin/` directories (NuGet-generated imports) +- **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 @@ -122,7 +122,7 @@ This command will: By default, both commands search from the current directory. You can specify specific paths: ```ps1 -nbgv path-filters check --path ./src/ProjectA --path ./src/ProjectB +nbgv path-filters check ./src/ProjectA ./src/ProjectB ``` #### Include additional project file extensions diff --git a/src/NerdBank.GitVersioning/GitContext.cs b/src/NerdBank.GitVersioning/GitContext.cs index 74601ae4..738d425b 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() { diff --git a/src/NerdBank.GitVersioning/LibGit2/LibGit2Context.cs b/src/NerdBank.GitVersioning/LibGit2/LibGit2Context.cs index 684c9a2e..334de3d2 100644 --- a/src/NerdBank.GitVersioning/LibGit2/LibGit2Context.cs +++ b/src/NerdBank.GitVersioning/LibGit2/LibGit2Context.cs @@ -88,6 +88,33 @@ public static LibGit2Context Create(string path, string? committish = null) }; } + /// + public override bool IsIgnored(string path) + { + // Use some common patterns that are typically in .gitignore files + // This is a simple approach that covers the most common build artifacts + string normalizedPath = path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + + // Check for common generated directories + string[] ignoredDirNames = { "bin", "obj", "packages", ".vs", ".vscode", "node_modules" }; + foreach (string dirName in ignoredDirNames) + { + string pattern = $"{Path.DirectorySeparatorChar}{dirName}{Path.DirectorySeparatorChar}"; + if (normalizedPath.Contains(pattern)) + { + return true; + } + + // Also check for the directory at the end of the path + if (normalizedPath.EndsWith($"{Path.DirectorySeparatorChar}{dirName}")) + { + return true; + } + } + + return false; + } + /// 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 778924d7..4be4efb0 100644 --- a/src/nbgv/Program.cs +++ b/src/nbgv/Program.cs @@ -1132,7 +1132,7 @@ private static Task OnPathFiltersCommandCore(string[] paths, string[] extra try { - IReadOnlyList computed = ComputePathFilters(versionJsonDir, context.WorkingTreePath, projectExtensions, allVersionJsonDirs); + IReadOnlyList computed = ComputePathFilters(versionJsonDir, context.WorkingTreePath, projectExtensions, allVersionJsonDirs, context); // Skip version.json files that have no associated projects if (computed.Count == 0) @@ -1209,12 +1209,14 @@ private static Task OnPathFiltersCommandCore(string[] paths, string[] extra /// 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) + ISet versionJsonDirs, + GitContext gitContext) { // Find all project files under the version.json directory, but stop at other version.json directories. List projectFiles = new(); @@ -1275,10 +1277,8 @@ private static IReadOnlyList ComputePathFilters( string importPath = Path.GetFullPath(import.ImportedProject.FullPath); if (IsWithinRepo(importPath, repoRoot)) { - // Skip files in obj and bin directories as they are generated - string normalizedPath = importPath.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); - if (normalizedPath.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}") || - normalizedPath.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")) + // Skip files that git would ignore (e.g., bin/, obj/ directories, per .gitignore) + if (gitContext.IsIgnored(importPath)) { continue; } From 3db8ea893cb0fb631ed85d983496d36a04582a63 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Tue, 23 Jun 2026 13:33:30 -0600 Subject: [PATCH 7/8] refine: Use Repository.Ignore.IsPathIgnored() for proper git ignore checking - Replaced manual pattern matching with LibGit2Sharp's built-in ignore checking - This respects all .gitignore patterns in the repository, not just hardcoded directories - Cleaner, more maintainable, and handles all ignore rules correctly - Properly converts absolute paths to repo-relative paths before checking Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../LibGit2/LibGit2Context.cs | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/src/NerdBank.GitVersioning/LibGit2/LibGit2Context.cs b/src/NerdBank.GitVersioning/LibGit2/LibGit2Context.cs index 334de3d2..ee84b11b 100644 --- a/src/NerdBank.GitVersioning/LibGit2/LibGit2Context.cs +++ b/src/NerdBank.GitVersioning/LibGit2/LibGit2Context.cs @@ -91,28 +91,19 @@ public static LibGit2Context Create(string path, string? committish = null) /// public override bool IsIgnored(string path) { - // Use some common patterns that are typically in .gitignore files - // This is a simple approach that covers the most common build artifacts - string normalizedPath = path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); - - // Check for common generated directories - string[] ignoredDirNames = { "bin", "obj", "packages", ".vs", ".vscode", "node_modules" }; - foreach (string dirName in ignoredDirNames) + try { - string pattern = $"{Path.DirectorySeparatorChar}{dirName}{Path.DirectorySeparatorChar}"; - if (normalizedPath.Contains(pattern)) - { - return true; - } + // Convert absolute path to repo-relative path + string repoRelativePath = Path.GetRelativePath(this.WorkingTreePath, path); - // Also check for the directory at the end of the path - if (normalizedPath.EndsWith($"{Path.DirectorySeparatorChar}{dirName}")) - { - return true; - } + // Use LibGit2Sharp's built-in ignore checking + return this.Repository.Ignore.IsPathIgnored(repoRelativePath); + } + catch + { + // If we can't determine ignore status, assume it's not ignored + return false; } - - return false; } /// From 181b1a997f9d99f19986da2a1c61d748d8e53d59 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Tue, 23 Jun 2026 13:50:58 -0600 Subject: [PATCH 8/8] Fix gitignore check --- src/NerdBank.GitVersioning/GitContext.cs | 5 +++-- .../LibGit2/LibGit2Context.cs | 17 +---------------- src/nbgv/Program.cs | 2 +- 3 files changed, 5 insertions(+), 19 deletions(-) diff --git a/src/NerdBank.GitVersioning/GitContext.cs b/src/NerdBank.GitVersioning/GitContext.cs index 738d425b..de2aed45 100644 --- a/src/NerdBank.GitVersioning/GitContext.cs +++ b/src/NerdBank.GitVersioning/GitContext.cs @@ -301,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); @@ -310,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 ee84b11b..9cd9f28b 100644 --- a/src/NerdBank.GitVersioning/LibGit2/LibGit2Context.cs +++ b/src/NerdBank.GitVersioning/LibGit2/LibGit2Context.cs @@ -89,22 +89,7 @@ public static LibGit2Context Create(string path, string? committish = null) } /// - public override bool IsIgnored(string path) - { - try - { - // Convert absolute path to repo-relative path - string repoRelativePath = Path.GetRelativePath(this.WorkingTreePath, path); - - // Use LibGit2Sharp's built-in ignore checking - return this.Repository.Ignore.IsPathIgnored(repoRelativePath); - } - catch - { - // If we can't determine ignore status, assume it's not ignored - return false; - } - } + 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 4be4efb0..fa45e272 100644 --- a/src/nbgv/Program.cs +++ b/src/nbgv/Program.cs @@ -1123,7 +1123,7 @@ private static Task OnPathFiltersCommandCore(string[] paths, string[] extra anyFound = true; string versionJsonDir = Path.GetDirectoryName(versionJsonPath)!; - using GitContext context = GitContext.Create(versionJsonDir, engine: GetEffectiveGitEngine()); + 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}");