Skip to content

Commit c4afff5

Browse files
committed
Use DefaultExcludeItems globs to determine items to ignore.
Implement watching project, props and targets files.
1 parent 84975c3 commit c4afff5

File tree

15 files changed

+438
-80
lines changed

15 files changed

+438
-80
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: 28 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,8 @@ 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);
107+
var changeFilter = new Predicate<ChangedPath>(change => AcceptChange(change, evaluationResult));
109108

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

@@ -180,7 +179,7 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke
180179
return;
181180
}
182181

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

185184
var changedFilesAccumulator = ImmutableList<ChangedPath>.Empty;
186185

@@ -426,17 +425,21 @@ async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableDict
426425

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

432-
if (hasAddedFile)
434+
if (evaluationRequired)
433435
{
434-
Context.Reporter.Report(MessageDescriptor.FileAdditionTriggeredReEvaluation);
436+
Context.Reporter.Report(fileAdded ? MessageDescriptor.FileAdditionTriggeredReEvaluation : MessageDescriptor.ProjectChangeTriggeredReEvaluation);
435437

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

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

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

@@ -482,7 +485,7 @@ async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableDict
482485

483486
ReportFileChanges(changedFiles);
484487

485-
if (!hasAddedFile)
488+
if (!evaluationRequired)
486489
{
487490
// update the workspace to reflect changes in the file content:
488491
await compilationHandler.Workspace.UpdateFileContentAsync(changedFiles, iterationCancellationToken);
@@ -551,7 +554,7 @@ private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatche
551554
{
552555
if (!fileWatcher.WatchingDirectories)
553556
{
554-
fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys, includeSubdirectories: true);
557+
evaluationResult.WatchFiles(fileWatcher);
555558
}
556559

557560
_ = await fileWatcher.WaitForFileChangeAsync(
@@ -571,7 +574,7 @@ private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatche
571574
}
572575
}
573576

574-
private bool AcceptChange(ChangedPath change, EvaluationResult evaluationResult, IReadOnlySet<string> outputDirectories)
577+
private bool AcceptChange(ChangedPath change, EvaluationResult evaluationResult)
575578
{
576579
var (path, kind) = change;
577580

@@ -581,21 +584,27 @@ private bool AcceptChange(ChangedPath change, EvaluationResult evaluationResult,
581584
return true;
582585
}
583586

584-
// Ignore other changes to output and intermediate output directories.
587+
if (!AcceptChange(change))
588+
{
589+
return false;
590+
}
591+
592+
// changes in *.*proj, *.props, *.targets:
593+
if (evaluationResult.BuildFiles.Contains(path))
594+
{
595+
return true;
596+
}
597+
598+
// Ignore other changes that match DefaultItemExcludes glob if EnableDefaultItems is true,
599+
// otherwise changes under output and intermediate output directories.
585600
//
586601
// Unsupported scenario:
587602
// - msbuild target adds source files to intermediate output directory and Compile items
588603
// based on the content of non-source file.
589604
//
590605
// On the other hand, changes to source files produced by source generators will be registered
591606
// 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);
607+
return !evaluationResult.ItemExclusions.IsExcluded(path, kind, Context.Reporter);
599608
}
600609

601610
private bool AcceptChange(ChangedPath change)
@@ -618,54 +627,6 @@ private bool AcceptChange(ChangedPath change)
618627
private static bool IsHiddenDirectory(string dir)
619628
=> Path.GetFileName(dir).StartsWith('.');
620629

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-
669630
internal static IEnumerable<ChangedPath> NormalizePathChanges(IEnumerable<ChangedPath> changes)
670631
=> changes
671632
.GroupBy(keySelector: change => change.Path)

src/BuiltInTools/dotnet-watch/Internal/IReporter.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,10 @@ public MessageDescriptor ToErrorWhen(bool condition)
8585
public static readonly MessageDescriptor ConfiguredToUseBrowserRefresh = new("Configuring the app to use browser-refresh middleware", WatchEmoji, MessageSeverity.Verbose, s_id++);
8686
public static readonly MessageDescriptor IgnoringChangeInHiddenDirectory = new("Ignoring change in hidden directory '{0}': {1} '{2}'", WatchEmoji, MessageSeverity.Verbose, s_id++);
8787
public static readonly MessageDescriptor IgnoringChangeInOutputDirectory = new("Ignoring change in output directory: {0} '{1}'", WatchEmoji, MessageSeverity.Verbose, s_id++);
88+
public static readonly MessageDescriptor IgnoringChangeInExcludedFile = new("Ignoring change in excluded file '{0}': {1}. Path matches {2} glob '{3}' set in '{4}'.", WatchEmoji, MessageSeverity.Verbose, s_id++);
8889
public static readonly MessageDescriptor FileAdditionTriggeredReEvaluation = new("File addition triggered re-evaluation.", WatchEmoji, MessageSeverity.Verbose, s_id++);
8990
public static readonly MessageDescriptor ReEvaluationCompleted = new("Re-evaluation completed.", WatchEmoji, MessageSeverity.Verbose, s_id++);
91+
public static readonly MessageDescriptor ProjectChangeTriggeredReEvaluation = new("Project change triggered re-evaluation.", WatchEmoji, MessageSeverity.Verbose, s_id++);
9092
public static readonly MessageDescriptor NoCSharpChangesToApply = new("No C# changes to apply.", WatchEmoji, MessageSeverity.Output, s_id++);
9193
public static readonly MessageDescriptor Exited = new("Exited", WatchEmoji, MessageSeverity.Output, s_id++);
9294
public static readonly MessageDescriptor ExitedWithUnknownErrorCode = new("Exited with unknown error code", ErrorEmoji, MessageSeverity.Error, s_id++);

src/BuiltInTools/dotnet-watch/Internal/MsBuildFileSetFactory.cs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Collections.Concurrent;
45
using System.Diagnostics;
56
using System.Text.Json;
7+
using Microsoft.Build.Evaluation;
8+
using Microsoft.Build.Execution;
9+
using Microsoft.Build.FileSystem;
610
using Microsoft.Build.Graph;
711

812
namespace Microsoft.DotNet.Watch
@@ -121,7 +125,7 @@ void AddFile(string filePath, string? staticWebAssetPath)
121125
ProjectGraph? projectGraph = null;
122126
if (requireProjectGraph != null)
123127
{
124-
projectGraph = TryLoadProjectGraph(requireProjectGraph.Value);
128+
projectGraph = TryLoadProjectGraph(requireProjectGraph.Value, cancellationToken);
125129
if (projectGraph == null && requireProjectGraph == true)
126130
{
127131
return null;
@@ -194,7 +198,11 @@ private static string FindTargetsFile()
194198
}
195199

196200
// internal for testing
197-
internal ProjectGraph? TryLoadProjectGraph(bool projectGraphRequired)
201+
202+
/// <summary>
203+
/// Tries to create a project graph by running the build evaluation phase on the <see cref="rootProjectFile"/>.
204+
/// </summary>
205+
internal ProjectGraph? TryLoadProjectGraph(bool projectGraphRequired, CancellationToken cancellationToken)
198206
{
199207
var globalOptions = new Dictionary<string, string>();
200208

@@ -203,9 +211,11 @@ private static string FindTargetsFile()
203211
globalOptions[name] = value;
204212
}
205213

214+
var entryPoint = new ProjectGraphEntryPoint(rootProjectFile, globalOptions);
215+
206216
try
207217
{
208-
return new ProjectGraph(rootProjectFile, globalOptions);
218+
return new ProjectGraph([entryPoint], ProjectCollection.GlobalProjectCollection, projectInstanceFactory: null, cancellationToken);
209219
}
210220
catch (Exception e)
211221
{

0 commit comments

Comments
 (0)