Skip to content

Commit 49dade0

Browse files
authored
File watcher: ignore directories, allow watching non-recursively (#49150)
1 parent 9a1c6fa commit 49dade0

File tree

9 files changed

+298
-260
lines changed

9 files changed

+298
-260
lines changed

src/BuiltInTools/dotnet-watch/DotNetWatcher.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke
7979
using var combinedCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, currentRunCancellationSource.Token);
8080
using var fileSetWatcher = new FileWatcher(Context.Reporter);
8181

82-
fileSetWatcher.WatchContainingDirectories(evaluationResult.Files.Keys);
82+
fileSetWatcher.WatchContainingDirectories(evaluationResult.Files.Keys, includeSubdirectories: true);
8383

8484
var processTask = ProcessRunner.RunAsync(processSpec, Context.Reporter, isUserApplication: true, launchResult: null, combinedCancellationSource.Token);
8585

src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke
180180
return;
181181
}
182182

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

185185
var changedFilesAccumulator = ImmutableList<ChangedPath>.Empty;
186186

@@ -441,7 +441,7 @@ async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableDict
441441
evaluationResult = await EvaluateRootProjectAsync(iterationCancellationToken);
442442

443443
// additional directories may have been added:
444-
fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys);
444+
fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys, includeSubdirectories: true);
445445

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

@@ -554,7 +554,7 @@ private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatche
554554
{
555555
if (!fileWatcher.WatchingDirectories)
556556
{
557-
fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys);
557+
fileWatcher.WatchContainingDirectories(evaluationResult.Files.Keys, includeSubdirectories: true);
558558
}
559559

560560
_ = await fileWatcher.WaitForFileChangeAsync(
@@ -564,8 +564,8 @@ private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatche
564564
}
565565
else
566566
{
567-
// evaluation cancelled - watch for any changes in the directory containing the root project:
568-
fileWatcher.WatchContainingDirectories([RootFileSetFactory.RootProjectFile]);
567+
// evaluation cancelled - watch for any changes in the directory tree containing the root project:
568+
fileWatcher.WatchContainingDirectories([RootFileSetFactory.RootProjectFile], includeSubdirectories: true);
569569

570570
_ = await fileWatcher.WaitForFileChangeAsync(
571571
acceptChange: change => AcceptChange(change),
@@ -605,12 +605,6 @@ private bool AcceptChange(ChangedPath change)
605605
{
606606
var (path, kind) = change;
607607

608-
// only handle file changes:
609-
if (Directory.Exists(path))
610-
{
611-
return false;
612-
}
613-
614608
if (PathUtilities.GetContainingDirectories(path).FirstOrDefault(IsHiddenDirectory) is { } containingHiddenDir)
615609
{
616610
Context.Reporter.Report(MessageDescriptor.IgnoringChangeInHiddenDirectory, containingHiddenDir, kind, path);

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

Lines changed: 46 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,13 @@ namespace Microsoft.DotNet.Watch
55
{
66
internal sealed class FileWatcher(IReporter reporter) : IDisposable
77
{
8-
// Directory watcher for each watched directory
9-
private readonly Dictionary<string, IDirectoryWatcher> _watchers = [];
8+
// Directory watcher for each watched directory tree.
9+
// Keyed by full path to the root directory with a trailing directory separator.
10+
private readonly Dictionary<string, IDirectoryWatcher> _directoryTreeWatchers = new(PathUtilities.OSSpecificPathComparer);
11+
12+
// Directory watcher for each watched directory (non-recursive).
13+
// Keyed by full path to the root directory with a trailing directory separator.
14+
private readonly Dictionary<string, IDirectoryWatcher> _directoryWatchers = new(PathUtilities.OSSpecificPathComparer);
1015

1116
private bool _disposed;
1217
public event Action<ChangedPath>? OnFileChange;
@@ -22,7 +27,7 @@ public void Dispose()
2227

2328
_disposed = true;
2429

25-
foreach (var (_, watcher) in _watchers)
30+
foreach (var (_, watcher) in _directoryTreeWatchers)
2631
{
2732
watcher.OnFileChange -= WatcherChangedHandler;
2833
watcher.OnError -= WatcherErrorHandler;
@@ -31,39 +36,33 @@ public void Dispose()
3136
}
3237

3338
public bool WatchingDirectories
34-
=> _watchers.Count > 0;
39+
=> _directoryTreeWatchers.Count > 0 || _directoryWatchers.Count > 0;
3540

36-
public void WatchContainingDirectories(IEnumerable<string> filePaths)
37-
=> WatchDirectories(filePaths.Select(path => Path.GetDirectoryName(path)!));
41+
public void WatchContainingDirectories(IEnumerable<string> filePaths, bool includeSubdirectories)
42+
=> WatchDirectories(filePaths.Select(path => Path.GetDirectoryName(path)!), includeSubdirectories);
3843

39-
public void WatchDirectories(IEnumerable<string> directories)
44+
public void WatchDirectories(IEnumerable<string> directories, bool includeSubdirectories)
4045
{
4146
ObjectDisposedException.ThrowIf(_disposed, this);
4247

43-
foreach (var dir in directories)
48+
foreach (var dir in directories.Distinct())
4449
{
45-
var directory = EnsureTrailingSlash(dir);
46-
47-
var alreadyWatched = _watchers
48-
.Where(d => directory.StartsWith(d.Key))
49-
.Any();
50+
var directory = PathUtilities.EnsureTrailingSlash(PathUtilities.NormalizeDirectorySeparators(dir));
5051

51-
if (alreadyWatched)
52+
// the directory is watched by active directory watcher:
53+
if (!includeSubdirectories && _directoryWatchers.ContainsKey(directory))
5254
{
5355
continue;
5456
}
5557

56-
var redundantWatchers = _watchers
57-
.Where(d => d.Key.StartsWith(directory))
58-
.Select(d => d.Key)
59-
.ToList();
60-
61-
foreach (var watcher in redundantWatchers)
58+
// the directory is a root or subdirectory of active directory tree watcher:
59+
var alreadyWatched = _directoryTreeWatchers.Any(d => directory.StartsWith(d.Key, PathUtilities.OSSpecificPathComparison));
60+
if (alreadyWatched)
6261
{
63-
DisposeWatcher(watcher);
62+
continue;
6463
}
6564

66-
var newWatcher = FileWatcherFactory.CreateWatcher(directory);
65+
var newWatcher = FileWatcherFactory.CreateWatcher(directory, includeSubdirectories);
6766
if (newWatcher is EventBasedDirectoryWatcher eventBasedWatcher)
6867
{
6968
eventBasedWatcher.Logger = message => reporter.Verbose(message);
@@ -73,7 +72,30 @@ public void WatchDirectories(IEnumerable<string> directories)
7372
newWatcher.OnError += WatcherErrorHandler;
7473
newWatcher.EnableRaisingEvents = true;
7574

76-
_watchers.Add(directory, newWatcher);
75+
// watchers that are now redundant (covered by the new directory watcher):
76+
if (includeSubdirectories)
77+
{
78+
var watchersToRemove = _directoryTreeWatchers
79+
.Where(d => d.Key.StartsWith(directory, PathUtilities.OSSpecificPathComparison))
80+
.ToList();
81+
82+
foreach (var (watchedDirectory, watcher) in watchersToRemove)
83+
{
84+
_directoryTreeWatchers.Remove(watchedDirectory);
85+
86+
watcher.EnableRaisingEvents = false;
87+
watcher.OnFileChange -= WatcherChangedHandler;
88+
watcher.OnError -= WatcherErrorHandler;
89+
90+
watcher.Dispose();
91+
}
92+
93+
_directoryTreeWatchers.Add(directory, newWatcher);
94+
}
95+
else
96+
{
97+
_directoryWatchers.Add(directory, newWatcher);
98+
}
7799
}
78100
}
79101

@@ -93,21 +115,6 @@ private void WatcherChangedHandler(object? sender, ChangedPath change)
93115
}
94116
}
95117

96-
private void DisposeWatcher(string directory)
97-
{
98-
var watcher = _watchers[directory];
99-
_watchers.Remove(directory);
100-
101-
watcher.EnableRaisingEvents = false;
102-
watcher.OnFileChange -= WatcherChangedHandler;
103-
watcher.OnError -= WatcherErrorHandler;
104-
105-
watcher.Dispose();
106-
}
107-
108-
private static string EnsureTrailingSlash(string path)
109-
=> (path is [.., var last] && last != Path.DirectorySeparatorChar) ? path + Path.DirectorySeparatorChar : path;
110-
111118
public async Task<ChangedFile?> WaitForFileChangeAsync(IReadOnlyDictionary<string, FileItem> fileSet, Action? startedWatching, CancellationToken cancellationToken)
112119
{
113120
var changedPath = await WaitForFileChangeAsync(
@@ -151,7 +158,7 @@ public static async ValueTask WaitForFileChangeAsync(string filePath, IReporter
151158
{
152159
using var watcher = new FileWatcher(reporter);
153160

154-
watcher.WatchDirectories([Path.GetDirectoryName(filePath)!]);
161+
watcher.WatchContainingDirectories([filePath], includeSubdirectories: false);
155162

156163
var fileChange = await watcher.WaitForFileChangeAsync(
157164
acceptChange: change => change.Path == filePath,

src/BuiltInTools/dotnet-watch/Internal/FileWatcher/EventBasedDirectoryWatcher.cs

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,21 @@ namespace Microsoft.DotNet.Watch
88
internal sealed class EventBasedDirectoryWatcher : IDirectoryWatcher
99
{
1010
public event EventHandler<ChangedPath>? OnFileChange;
11-
1211
public event EventHandler<Exception>? OnError;
1312

1413
public string WatchedDirectory { get; }
15-
16-
internal Action<string>? Logger { get; set; }
14+
public bool IncludeSubdirectories { get; }
15+
public Action<string>? Logger { get; set; }
1716

1817
private volatile bool _disposed;
19-
2018
private FileSystemWatcher? _fileSystemWatcher;
19+
private readonly Lock _createLock = new();
2120

22-
private readonly object _createLock = new();
23-
24-
internal EventBasedDirectoryWatcher(string watchedDirectory)
21+
internal EventBasedDirectoryWatcher(string watchedDirectory, bool includeSubdirectories)
2522
{
2623
WatchedDirectory = watchedDirectory;
24+
IncludeSubdirectories = includeSubdirectories;
25+
2726
CreateFileSystemWatcher();
2827
}
2928

@@ -40,7 +39,7 @@ private void WatcherErrorHandler(object sender, ErrorEventArgs e)
4039
return;
4140
}
4241

43-
Logger?.Invoke("Error");
42+
Logger?.Invoke("[FW] Error");
4443

4544
var exception = e.GetException();
4645

@@ -65,21 +64,23 @@ private void WatcherRenameHandler(object sender, RenamedEventArgs e)
6564
return;
6665
}
6766

68-
Logger?.Invoke($"Renamed '{e.OldFullPath}' to '{e.FullPath}'.");
69-
70-
NotifyChange(e.OldFullPath, ChangeKind.Delete);
71-
NotifyChange(e.FullPath, ChangeKind.Add);
67+
Logger?.Invoke($"[FW] Renamed '{e.OldFullPath}' to '{e.FullPath}'.");
7268

7369
if (Directory.Exists(e.FullPath))
7470
{
75-
foreach (var newLocation in Directory.EnumerateFileSystemEntries(e.FullPath, "*", SearchOption.AllDirectories))
71+
foreach (var newLocation in Directory.EnumerateFiles(e.FullPath, "*", SearchOption.AllDirectories))
7672
{
7773
// Calculated previous path of this moved item.
7874
var oldLocation = Path.Combine(e.OldFullPath, newLocation.Substring(e.FullPath.Length + 1));
7975
NotifyChange(oldLocation, ChangeKind.Delete);
8076
NotifyChange(newLocation, ChangeKind.Add);
8177
}
8278
}
79+
else
80+
{
81+
NotifyChange(e.OldFullPath, ChangeKind.Delete);
82+
NotifyChange(e.FullPath, ChangeKind.Add);
83+
}
8384
}
8485

8586
private void WatcherDeletedHandler(object sender, FileSystemEventArgs e)
@@ -89,7 +90,16 @@ private void WatcherDeletedHandler(object sender, FileSystemEventArgs e)
8990
return;
9091
}
9192

92-
Logger?.Invoke($"Deleted '{e.FullPath}'.");
93+
var isDir = Directory.Exists(e.FullPath);
94+
95+
Logger?.Invoke($"[FW] Deleted '{e.FullPath}'.");
96+
97+
// ignore directory changes:
98+
if (isDir)
99+
{
100+
return;
101+
}
102+
93103
NotifyChange(e.FullPath, ChangeKind.Delete);
94104
}
95105

@@ -100,7 +110,16 @@ private void WatcherChangeHandler(object sender, FileSystemEventArgs e)
100110
return;
101111
}
102112

103-
Logger?.Invoke($"Updated '{e.FullPath}'.");
113+
var isDir = Directory.Exists(e.FullPath);
114+
115+
Logger?.Invoke($"[FW] Updated '{e.FullPath}'.");
116+
117+
// ignore directory changes:
118+
if (isDir)
119+
{
120+
return;
121+
}
122+
104123
NotifyChange(e.FullPath, ChangeKind.Update);
105124
}
106125

@@ -111,7 +130,15 @@ private void WatcherAddedHandler(object sender, FileSystemEventArgs e)
111130
return;
112131
}
113132

114-
Logger?.Invoke($"Added '{e.FullPath}'.");
133+
var isDir = Directory.Exists(e.FullPath);
134+
135+
Logger?.Invoke($"[FW] Added '{e.FullPath}'.");
136+
137+
if (isDir)
138+
{
139+
return;
140+
}
141+
115142
NotifyChange(e.FullPath, ChangeKind.Add);
116143
}
117144

@@ -136,7 +163,7 @@ private void CreateFileSystemWatcher()
136163

137164
_fileSystemWatcher = new FileSystemWatcher(WatchedDirectory)
138165
{
139-
IncludeSubdirectories = true
166+
IncludeSubdirectories = IncludeSubdirectories
140167
};
141168

142169
_fileSystemWatcher.Created += WatcherAddedHandler;

src/BuiltInTools/dotnet-watch/Internal/FileWatcher/FileWatcherFactory.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ namespace Microsoft.DotNet.Watch
55
{
66
internal static class FileWatcherFactory
77
{
8-
public static IDirectoryWatcher CreateWatcher(string watchedDirectory)
9-
=> CreateWatcher(watchedDirectory, EnvironmentVariables.IsPollingEnabled);
8+
public static IDirectoryWatcher CreateWatcher(string watchedDirectory, bool includeSubdirectories)
9+
=> CreateWatcher(watchedDirectory, EnvironmentVariables.IsPollingEnabled, includeSubdirectories);
1010

11-
public static IDirectoryWatcher CreateWatcher(string watchedDirectory, bool usePollingWatcher)
11+
public static IDirectoryWatcher CreateWatcher(string watchedDirectory, bool usePollingWatcher, bool includeSubdirectories)
1212
{
1313
return usePollingWatcher ?
14-
new PollingDirectoryWatcher(watchedDirectory) :
15-
new EventBasedDirectoryWatcher(watchedDirectory);
14+
new PollingDirectoryWatcher(watchedDirectory, includeSubdirectories) :
15+
new EventBasedDirectoryWatcher(watchedDirectory, includeSubdirectories);
1616
}
1717
}
1818
}
Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
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-
namespace Microsoft.DotNet.Watch
4+
namespace Microsoft.DotNet.Watch;
5+
6+
/// <summary>
7+
/// Watches for changes in a <see cref="WatchedDirectory"/> and its subdirectories.
8+
/// </summary>
9+
internal interface IDirectoryWatcher : IDisposable
510
{
6-
internal interface IDirectoryWatcher : IDisposable
7-
{
8-
event EventHandler<ChangedPath> OnFileChange;
11+
event EventHandler<ChangedPath> OnFileChange;
912

10-
event EventHandler<Exception> OnError;
13+
event EventHandler<Exception> OnError;
1114

12-
string WatchedDirectory { get; }
15+
string WatchedDirectory { get; }
1316

14-
bool EnableRaisingEvents { get; set; }
15-
}
17+
bool EnableRaisingEvents { get; set; }
1618
}

0 commit comments

Comments
 (0)