Skip to content

Default excludes #49454

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/BuiltInTools/dotnet-watch/EvaluationResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,24 @@ internal sealed class EvaluationResult(IReadOnlyDictionary<string, FileItem> fil
{
public readonly IReadOnlyDictionary<string, FileItem> Files = files;
public readonly ProjectGraph? ProjectGraph = projectGraph;

public readonly FilePathExclusions ItemExclusions
= projectGraph != null ? FilePathExclusions.Create(projectGraph) : FilePathExclusions.Empty;

private readonly Lazy<IReadOnlySet<string>> _lazyBuildFiles
= new(() => projectGraph != null ? CreateBuildFileSet(projectGraph) : new HashSet<string>());

public static IReadOnlySet<string> CreateBuildFileSet(ProjectGraph projectGraph)
=> projectGraph.ProjectNodes.SelectMany(p => p.ProjectInstance.ImportPaths)
.Concat(projectGraph.ProjectNodes.Select(p => p.ProjectInstance.FullPath))
.ToHashSet(PathUtilities.OSSpecificPathComparer);

public IReadOnlySet<string> BuildFiles
=> _lazyBuildFiles.Value;

public void WatchFiles(FileWatcher fileWatcher)
{
fileWatcher.WatchContainingDirectories(Files.Keys, includeSubdirectories: true);
fileWatcher.WatchFiles(BuildFiles);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public override async Task WaitForProcessRunningAsync(CancellationToken cancella

public override Task<ImmutableArray<string>> GetApplyUpdateCapabilitiesAsync(CancellationToken cancellationToken)
{
var capabilities = project.GetWebAssemblyCapabilities();
var capabilities = project.GetWebAssemblyCapabilities().ToImmutableArray();

if (capabilities.IsEmpty)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
using Microsoft.Build.Graph;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.EditAndContinue;
using Microsoft.CodeAnalysis.ExternalAccess.Watch.Api;

namespace Microsoft.DotNet.Watch
Expand Down
103 changes: 103 additions & 0 deletions src/BuiltInTools/dotnet-watch/HotReload/FilePathExclusions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.Build.Graph;
using Microsoft.Build.Globbing;

namespace Microsoft.DotNet.Watch;

internal readonly struct FilePathExclusions(
IEnumerable<(MSBuildGlob glob, string value, string projectDir)> exclusionGlobs,
IReadOnlySet<string> outputDirectories)
{
public static readonly FilePathExclusions Empty = new(exclusionGlobs: [], outputDirectories: new HashSet<string>());

public static FilePathExclusions Create(ProjectGraph projectGraph)
{
var outputDirectories = new HashSet<string>(PathUtilities.OSSpecificPathComparer);
var globs = new Dictionary<(string fixedDirectoryPart, string wildcardDirectoryPart, string filenamePart), (MSBuildGlob glob, string value, string projectDir)>();

foreach (var projectNode in projectGraph.ProjectNodes)
{
if (projectNode.AreDefaultItemsEnabled())
{
var projectDir = projectNode.ProjectInstance.Directory;

foreach (var globValue in projectNode.GetDefaultItemExcludes())
{
var glob = MSBuildGlob.Parse(projectDir, globValue);
if (glob.IsLegal)
{
// The glob creates regex based on the three parts of the glob.
// Avoid adding duplicate globs that match the same files.
globs.TryAdd((glob.FixedDirectoryPart, glob.WildcardDirectoryPart, glob.FilenamePart), (glob, globValue, projectDir));
}
}
}
else
{
// If default items are not enabled exclude just the output directories.

TryAddOutputDir(projectNode.GetOutputDirectory());
TryAddOutputDir(projectNode.GetIntermediateOutputDirectory());

void TryAddOutputDir(string? dir)
{
try
{
if (dir != null)
{
// msbuild properties may use '\' as a directory separator even on Unix.
// GetFullPath does not normalize '\' to '/' on Unix.
if (Path.DirectorySeparatorChar == '/')
{
dir = dir.Replace('\\', '/');
}

outputDirectories.Add(Path.TrimEndingDirectorySeparator(Path.GetFullPath(dir)));
}
}
catch
{
// ignore
}
}
}
}

return new FilePathExclusions(globs.Values, outputDirectories);
}

public void Report(IReporter reporter)
{
foreach (var globsPerDirectory in exclusionGlobs.GroupBy(keySelector: static g => g.projectDir, elementSelector: static g => g.value))
{
reporter.Verbose($"Exclusion glob: '{string.Join(";", globsPerDirectory)}' under project '{globsPerDirectory.Key}'");
}

foreach (var dir in outputDirectories)
{
reporter.Verbose($"Excluded directory: '{dir}'");
}
}

internal bool IsExcluded(string fullPath, ChangeKind changeKind, IReporter reporter)
{
if (PathUtilities.ContainsPath(outputDirectories, fullPath))
{
reporter.Report(MessageDescriptor.IgnoringChangeInOutputDirectory, changeKind, fullPath);
return true;
}

foreach (var (glob, globValue, projectDir) in exclusionGlobs)
{
if (glob.IsMatch(fullPath))
{
reporter.Report(MessageDescriptor.IgnoringChangeInExcludedFile, fullPath, changeKind, "DefaultItemExcludes", globValue, projectDir);
return true;
}
}

return false;
}
}
113 changes: 39 additions & 74 deletions src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,7 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke
compilationHandler = new CompilationHandler(Context.Reporter, Context.ProcessRunner);
var scopedCssFileHandler = new ScopedCssFileHandler(Context.Reporter, projectMap, browserConnector);
var projectLauncher = new ProjectLauncher(Context, projectMap, browserConnector, compilationHandler, iteration);
var outputDirectories = GetProjectOutputDirectories(evaluationResult.ProjectGraph);
ReportOutputDirectories(outputDirectories);
var changeFilter = new Predicate<ChangedPath>(change => AcceptChange(change, evaluationResult, outputDirectories));
evaluationResult.ItemExclusions.Report(Context.Reporter);

var rootProjectNode = evaluationResult.ProjectGraph.GraphRoots.Single();

Expand Down Expand Up @@ -180,13 +178,13 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke
return;
}

fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys, includeSubdirectories: true);
evaluationResult.WatchFiles(fileWatcher);

var changedFilesAccumulator = ImmutableList<ChangedPath>.Empty;

void FileChangedCallback(ChangedPath change)
{
if (changeFilter(change))
if (AcceptChange(change, evaluationResult))
{
Context.Reporter.Verbose($"File change: {change.Kind} '{change.Path}'.");
ImmutableInterlocked.Update(ref changedFilesAccumulator, changedPaths => changedPaths.Add(change));
Expand Down Expand Up @@ -350,7 +348,7 @@ void FileChangedCallback(ChangedPath change)
iterationCancellationToken.ThrowIfCancellationRequested();

_ = await fileWatcher.WaitForFileChangeAsync(
changeFilter,
change => AcceptChange(change, evaluationResult),
startedWatching: () => Context.Reporter.Report(MessageDescriptor.FixBuildError),
shutdownCancellationToken);
}
Expand Down Expand Up @@ -424,19 +422,25 @@ async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableDict
})
.ToImmutableList();

ReportFileChanges(changedFiles);

// When a new file is added we need to run design-time build to find out
// what kind of the file it is and which project(s) does it belong to (can be linked, web asset, etc.).
// We also need to re-evaluate the project if any project files have been modified.
// We don't need to rebuild and restart the application though.
var hasAddedFile = changedFiles.Any(f => f.Kind is ChangeKind.Add);
var fileAdded = changedFiles.Any(f => f.Kind is ChangeKind.Add);
var projectChanged = !fileAdded && changedFiles.Any(f => evaluationResult.BuildFiles.Contains(f.Item.FilePath));
var evaluationRequired = fileAdded || projectChanged;

if (hasAddedFile)
if (evaluationRequired)
{
Context.Reporter.Report(MessageDescriptor.FileAdditionTriggeredReEvaluation);
Context.Reporter.Report(fileAdded ? MessageDescriptor.FileAdditionTriggeredReEvaluation : MessageDescriptor.ProjectChangeTriggeredReEvaluation);

// TODO: consider re-evaluating only affected projects instead of the whole graph.
evaluationResult = await EvaluateRootProjectAsync(iterationCancellationToken);

// additional directories may have been added:
fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys, includeSubdirectories: true);
evaluationResult.WatchFiles(fileWatcher);

await compilationHandler.Workspace.UpdateProjectConeAsync(RootFileSetFactory.RootProjectFile, iterationCancellationToken);

Expand All @@ -447,9 +451,8 @@ async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableDict
}

// Update files in the change set with new evaluation info.
changedFiles = changedFiles
.Select(f => evaluationResult.Files.TryGetValue(f.Item.FilePath, out var evaluatedFile) ? f with { Item = evaluatedFile } : f)
.ToImmutableList();
changedFiles = [.. changedFiles
.Select(f => evaluationResult.Files.TryGetValue(f.Item.FilePath, out var evaluatedFile) ? f with { Item = evaluatedFile } : f)];

Context.Reporter.Report(MessageDescriptor.ReEvaluationCompleted);
}
Expand Down Expand Up @@ -477,12 +480,11 @@ async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableDict
}

changedFiles = newChangedFiles;

ImmutableInterlocked.Update(ref changedFilesAccumulator, accumulator => accumulator.AddRange(newAccumulator));
}

ReportFileChanges(changedFiles);

if (!hasAddedFile)
if (!evaluationRequired)
{
// update the workspace to reflect changes in the file content:
await compilationHandler.Workspace.UpdateFileContentAsync(changedFiles, iterationCancellationToken);
Expand Down Expand Up @@ -551,7 +553,7 @@ private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatche
{
if (!fileWatcher.WatchingDirectories)
{
fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys, includeSubdirectories: true);
evaluationResult.WatchFiles(fileWatcher);
}

_ = await fileWatcher.WaitForFileChangeAsync(
Expand All @@ -571,31 +573,42 @@ private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatche
}
}

private bool AcceptChange(ChangedPath change, EvaluationResult evaluationResult, IReadOnlySet<string> outputDirectories)
private Predicate<ChangedPath> CreateChangeFilter(EvaluationResult evaluationResult)
=> new(change => AcceptChange(change, evaluationResult));

private bool AcceptChange(ChangedPath change, EvaluationResult evaluationResult)
{
var (path, kind) = change;

// Handle changes to files that are known to be project build inputs from its evaluation.
// Compile items might be explicitly added by targets to directories that are excluded by default
// (e.g. global usings in obj directory). Changes to these files should not be ignored.
if (evaluationResult.Files.ContainsKey(path))
{
return true;
}

// Ignore other changes to output and intermediate output directories.
if (!AcceptChange(change))
{
return false;
}

// changes in *.*proj, *.props, *.targets:
if (evaluationResult.BuildFiles.Contains(path))
{
return true;
}

// Ignore other changes that match DefaultItemExcludes glob if EnableDefaultItems is true,
// otherwise changes under output and intermediate output directories.
//
// Unsupported scenario:
// - msbuild target adds source files to intermediate output directory and Compile items
// based on the content of non-source file.
//
// On the other hand, changes to source files produced by source generators will be registered
// since the changes to additional file will trigger workspace update, which will trigger the source generator.
if (PathUtilities.ContainsPath(outputDirectories, path))
{
Context.Reporter.Report(MessageDescriptor.IgnoringChangeInOutputDirectory, kind, path);
return false;
}

return AcceptChange(change);
return !evaluationResult.ItemExclusions.IsExcluded(path, kind, Context.Reporter);
}

private bool AcceptChange(ChangedPath change)
Expand All @@ -618,54 +631,6 @@ private bool AcceptChange(ChangedPath change)
private static bool IsHiddenDirectory(string dir)
=> Path.GetFileName(dir).StartsWith('.');

private static IReadOnlySet<string> GetProjectOutputDirectories(ProjectGraph projectGraph)
{
// TODO: https://github.com/dotnet/sdk/issues/45539
// Consider evaluating DefaultItemExcludes and DefaultExcludesInProjectFolder msbuild properties using
// https://github.com/dotnet/msbuild/blob/37eb419ad2c986ac5530292e6ee08e962390249e/src/Build/Globbing/MSBuildGlob.cs
// to determine which directories should be excluded.

var projectOutputDirectories = new HashSet<string>(PathUtilities.OSSpecificPathComparer);

foreach (var projectNode in projectGraph.ProjectNodes)
{
TryAdd(projectNode.GetOutputDirectory());
TryAdd(projectNode.GetIntermediateOutputDirectory());
}

return projectOutputDirectories;

void TryAdd(string? dir)
{
try
{
if (dir != null)
{
// msbuild properties may use '\' as a directory separator even on Unix.
// GetFullPath does not normalize '\' to '/' on Unix.
if (Path.DirectorySeparatorChar == '/')
{
dir = dir.Replace('\\', '/');
}

projectOutputDirectories.Add(Path.TrimEndingDirectorySeparator(Path.GetFullPath(dir)));
}
}
catch
{
// ignore
}
}
}

private void ReportOutputDirectories(IReadOnlySet<string> directories)
{
foreach (var dir in directories)
{
Context.Reporter.Verbose($"Output directory: '{dir}'");
}
}

internal static IEnumerable<ChangedPath> NormalizePathChanges(IEnumerable<ChangedPath> changes)
=> changes
.GroupBy(keySelector: change => change.Path)
Expand Down
Loading