Skip to content

Commit fe4873b

Browse files
authored
Default excludes (#49454)
1 parent c0557ec commit fe4873b

22 files changed

+723
-239
lines changed

src/BuiltInTools/dotnet-watch/EvaluationResult.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,24 @@ internal sealed class EvaluationResult(IReadOnlyDictionary<string, FileItem> fil
99
{
1010
public readonly IReadOnlyDictionary<string, FileItem> Files = files;
1111
public readonly ProjectGraph? ProjectGraph = projectGraph;
12+
13+
public readonly FilePathExclusions ItemExclusions
14+
= projectGraph != null ? FilePathExclusions.Create(projectGraph) : FilePathExclusions.Empty;
15+
16+
private readonly Lazy<IReadOnlySet<string>> _lazyBuildFiles
17+
= new(() => projectGraph != null ? CreateBuildFileSet(projectGraph) : new HashSet<string>());
18+
19+
public static IReadOnlySet<string> CreateBuildFileSet(ProjectGraph projectGraph)
20+
=> projectGraph.ProjectNodes.SelectMany(p => p.ProjectInstance.ImportPaths)
21+
.Concat(projectGraph.ProjectNodes.Select(p => p.ProjectInstance.FullPath))
22+
.ToHashSet(PathUtilities.OSSpecificPathComparer);
23+
24+
public IReadOnlySet<string> BuildFiles
25+
=> _lazyBuildFiles.Value;
26+
27+
public void WatchFiles(FileWatcher fileWatcher)
28+
{
29+
fileWatcher.WatchContainingDirectories(Files.Keys, includeSubdirectories: true);
30+
fileWatcher.WatchFiles(BuildFiles);
31+
}
1232
}

src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyDeltaApplier.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public override async Task WaitForProcessRunningAsync(CancellationToken cancella
4242

4343
public override Task<ImmutableArray<string>> GetApplyUpdateCapabilitiesAsync(CancellationToken cancellationToken)
4444
{
45-
var capabilities = project.GetWebAssemblyCapabilities();
45+
var capabilities = project.GetWebAssemblyCapabilities().ToImmutableArray();
4646

4747
if (capabilities.IsEmpty)
4848
{

src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
using Microsoft.Build.Graph;
77
using Microsoft.CodeAnalysis;
88
using Microsoft.CodeAnalysis.CSharp;
9-
using Microsoft.CodeAnalysis.EditAndContinue;
109
using Microsoft.CodeAnalysis.ExternalAccess.Watch.Api;
1110

1211
namespace Microsoft.DotNet.Watch
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.Build.Graph;
5+
using Microsoft.Build.Globbing;
6+
7+
namespace Microsoft.DotNet.Watch;
8+
9+
internal readonly struct FilePathExclusions(
10+
IEnumerable<(MSBuildGlob glob, string value, string projectDir)> exclusionGlobs,
11+
IReadOnlySet<string> outputDirectories)
12+
{
13+
public static readonly FilePathExclusions Empty = new(exclusionGlobs: [], outputDirectories: new HashSet<string>());
14+
15+
public static FilePathExclusions Create(ProjectGraph projectGraph)
16+
{
17+
var outputDirectories = new HashSet<string>(PathUtilities.OSSpecificPathComparer);
18+
var globs = new Dictionary<(string fixedDirectoryPart, string wildcardDirectoryPart, string filenamePart), (MSBuildGlob glob, string value, string projectDir)>();
19+
20+
foreach (var projectNode in projectGraph.ProjectNodes)
21+
{
22+
if (projectNode.AreDefaultItemsEnabled())
23+
{
24+
var projectDir = projectNode.ProjectInstance.Directory;
25+
26+
foreach (var globValue in projectNode.GetDefaultItemExcludes())
27+
{
28+
var glob = MSBuildGlob.Parse(projectDir, globValue);
29+
if (glob.IsLegal)
30+
{
31+
// The glob creates regex based on the three parts of the glob.
32+
// Avoid adding duplicate globs that match the same files.
33+
globs.TryAdd((glob.FixedDirectoryPart, glob.WildcardDirectoryPart, glob.FilenamePart), (glob, globValue, projectDir));
34+
}
35+
}
36+
}
37+
else
38+
{
39+
// If default items are not enabled exclude just the output directories.
40+
41+
TryAddOutputDir(projectNode.GetOutputDirectory());
42+
TryAddOutputDir(projectNode.GetIntermediateOutputDirectory());
43+
44+
void TryAddOutputDir(string? dir)
45+
{
46+
try
47+
{
48+
if (dir != null)
49+
{
50+
// msbuild properties may use '\' as a directory separator even on Unix.
51+
// GetFullPath does not normalize '\' to '/' on Unix.
52+
if (Path.DirectorySeparatorChar == '/')
53+
{
54+
dir = dir.Replace('\\', '/');
55+
}
56+
57+
outputDirectories.Add(Path.TrimEndingDirectorySeparator(Path.GetFullPath(dir)));
58+
}
59+
}
60+
catch
61+
{
62+
// ignore
63+
}
64+
}
65+
}
66+
}
67+
68+
return new FilePathExclusions(globs.Values, outputDirectories);
69+
}
70+
71+
public void Report(IReporter reporter)
72+
{
73+
foreach (var globsPerDirectory in exclusionGlobs.GroupBy(keySelector: static g => g.projectDir, elementSelector: static g => g.value))
74+
{
75+
reporter.Verbose($"Exclusion glob: '{string.Join(";", globsPerDirectory)}' under project '{globsPerDirectory.Key}'");
76+
}
77+
78+
foreach (var dir in outputDirectories)
79+
{
80+
reporter.Verbose($"Excluded directory: '{dir}'");
81+
}
82+
}
83+
84+
internal bool IsExcluded(string fullPath, ChangeKind changeKind, IReporter reporter)
85+
{
86+
if (PathUtilities.ContainsPath(outputDirectories, fullPath))
87+
{
88+
reporter.Report(MessageDescriptor.IgnoringChangeInOutputDirectory, changeKind, fullPath);
89+
return true;
90+
}
91+
92+
foreach (var (glob, globValue, projectDir) in exclusionGlobs)
93+
{
94+
if (glob.IsMatch(fullPath))
95+
{
96+
reporter.Report(MessageDescriptor.IgnoringChangeInExcludedFile, fullPath, changeKind, "DefaultItemExcludes", globValue, projectDir);
97+
return true;
98+
}
99+
}
100+
101+
return false;
102+
}
103+
}

src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs

Lines changed: 39 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,7 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke
103103
compilationHandler = new CompilationHandler(Context.Reporter, Context.ProcessRunner);
104104
var scopedCssFileHandler = new ScopedCssFileHandler(Context.Reporter, projectMap, browserConnector);
105105
var projectLauncher = new ProjectLauncher(Context, projectMap, browserConnector, compilationHandler, iteration);
106-
var outputDirectories = GetProjectOutputDirectories(evaluationResult.ProjectGraph);
107-
ReportOutputDirectories(outputDirectories);
108-
var changeFilter = new Predicate<ChangedPath>(change => AcceptChange(change, evaluationResult, outputDirectories));
106+
evaluationResult.ItemExclusions.Report(Context.Reporter);
109107

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

@@ -180,13 +178,13 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke
180178
return;
181179
}
182180

183-
fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys, includeSubdirectories: true);
181+
evaluationResult.WatchFiles(fileWatcher);
184182

185183
var changedFilesAccumulator = ImmutableList<ChangedPath>.Empty;
186184

187185
void FileChangedCallback(ChangedPath change)
188186
{
189-
if (changeFilter(change))
187+
if (AcceptChange(change, evaluationResult))
190188
{
191189
Context.Reporter.Verbose($"File change: {change.Kind} '{change.Path}'.");
192190
ImmutableInterlocked.Update(ref changedFilesAccumulator, changedPaths => changedPaths.Add(change));
@@ -350,7 +348,7 @@ void FileChangedCallback(ChangedPath change)
350348
iterationCancellationToken.ThrowIfCancellationRequested();
351349

352350
_ = await fileWatcher.WaitForFileChangeAsync(
353-
changeFilter,
351+
change => AcceptChange(change, evaluationResult),
354352
startedWatching: () => Context.Reporter.Report(MessageDescriptor.FixBuildError),
355353
shutdownCancellationToken);
356354
}
@@ -424,19 +422,25 @@ async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableDict
424422
})
425423
.ToImmutableList();
426424

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

432-
if (hasAddedFile)
435+
if (evaluationRequired)
433436
{
434-
Context.Reporter.Report(MessageDescriptor.FileAdditionTriggeredReEvaluation);
437+
Context.Reporter.Report(fileAdded ? MessageDescriptor.FileAdditionTriggeredReEvaluation : MessageDescriptor.ProjectChangeTriggeredReEvaluation);
435438

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

438442
// additional directories may have been added:
439-
fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys, includeSubdirectories: true);
443+
evaluationResult.WatchFiles(fileWatcher);
440444

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

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

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

454457
Context.Reporter.Report(MessageDescriptor.ReEvaluationCompleted);
455458
}
@@ -477,12 +480,11 @@ async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableDict
477480
}
478481

479482
changedFiles = newChangedFiles;
483+
480484
ImmutableInterlocked.Update(ref changedFilesAccumulator, accumulator => accumulator.AddRange(newAccumulator));
481485
}
482486

483-
ReportFileChanges(changedFiles);
484-
485-
if (!hasAddedFile)
487+
if (!evaluationRequired)
486488
{
487489
// update the workspace to reflect changes in the file content:
488490
await compilationHandler.Workspace.UpdateFileContentAsync(changedFiles, iterationCancellationToken);
@@ -551,7 +553,7 @@ private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatche
551553
{
552554
if (!fileWatcher.WatchingDirectories)
553555
{
554-
fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys, includeSubdirectories: true);
556+
evaluationResult.WatchFiles(fileWatcher);
555557
}
556558

557559
_ = await fileWatcher.WaitForFileChangeAsync(
@@ -571,31 +573,42 @@ private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatche
571573
}
572574
}
573575

574-
private bool AcceptChange(ChangedPath change, EvaluationResult evaluationResult, IReadOnlySet<string> outputDirectories)
576+
private Predicate<ChangedPath> CreateChangeFilter(EvaluationResult evaluationResult)
577+
=> new(change => AcceptChange(change, evaluationResult));
578+
579+
private bool AcceptChange(ChangedPath change, EvaluationResult evaluationResult)
575580
{
576581
var (path, kind) = change;
577582

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

584-
// Ignore other changes to output and intermediate output directories.
591+
if (!AcceptChange(change))
592+
{
593+
return false;
594+
}
595+
596+
// changes in *.*proj, *.props, *.targets:
597+
if (evaluationResult.BuildFiles.Contains(path))
598+
{
599+
return true;
600+
}
601+
602+
// Ignore other changes that match DefaultItemExcludes glob if EnableDefaultItems is true,
603+
// otherwise changes under output and intermediate output directories.
585604
//
586605
// Unsupported scenario:
587606
// - msbuild target adds source files to intermediate output directory and Compile items
588607
// based on the content of non-source file.
589608
//
590609
// On the other hand, changes to source files produced by source generators will be registered
591610
// since the changes to additional file will trigger workspace update, which will trigger the source generator.
592-
if (PathUtilities.ContainsPath(outputDirectories, path))
593-
{
594-
Context.Reporter.Report(MessageDescriptor.IgnoringChangeInOutputDirectory, kind, path);
595-
return false;
596-
}
597-
598-
return AcceptChange(change);
611+
return !evaluationResult.ItemExclusions.IsExcluded(path, kind, Context.Reporter);
599612
}
600613

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

621-
private static IReadOnlySet<string> GetProjectOutputDirectories(ProjectGraph projectGraph)
622-
{
623-
// TODO: https://github.com/dotnet/sdk/issues/45539
624-
// Consider evaluating DefaultItemExcludes and DefaultExcludesInProjectFolder msbuild properties using
625-
// https://github.com/dotnet/msbuild/blob/37eb419ad2c986ac5530292e6ee08e962390249e/src/Build/Globbing/MSBuildGlob.cs
626-
// to determine which directories should be excluded.
627-
628-
var projectOutputDirectories = new HashSet<string>(PathUtilities.OSSpecificPathComparer);
629-
630-
foreach (var projectNode in projectGraph.ProjectNodes)
631-
{
632-
TryAdd(projectNode.GetOutputDirectory());
633-
TryAdd(projectNode.GetIntermediateOutputDirectory());
634-
}
635-
636-
return projectOutputDirectories;
637-
638-
void TryAdd(string? dir)
639-
{
640-
try
641-
{
642-
if (dir != null)
643-
{
644-
// msbuild properties may use '\' as a directory separator even on Unix.
645-
// GetFullPath does not normalize '\' to '/' on Unix.
646-
if (Path.DirectorySeparatorChar == '/')
647-
{
648-
dir = dir.Replace('\\', '/');
649-
}
650-
651-
projectOutputDirectories.Add(Path.TrimEndingDirectorySeparator(Path.GetFullPath(dir)));
652-
}
653-
}
654-
catch
655-
{
656-
// ignore
657-
}
658-
}
659-
}
660-
661-
private void ReportOutputDirectories(IReadOnlySet<string> directories)
662-
{
663-
foreach (var dir in directories)
664-
{
665-
Context.Reporter.Verbose($"Output directory: '{dir}'");
666-
}
667-
}
668-
669634
internal static IEnumerable<ChangedPath> NormalizePathChanges(IEnumerable<ChangedPath> changes)
670635
=> changes
671636
.GroupBy(keySelector: change => change.Path)

0 commit comments

Comments
 (0)