Refactor dotnet-watch to isolate project.json dependency
This commit is contained in:
Родитель
62df63ada8
Коммит
721cbe3435
|
@ -1,11 +1,11 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Microsoft.DotNet.Cli.Utils;
|
||||
using Microsoft.DotNet.Watcher.Tools;
|
||||
using Microsoft.DotNet.Watcher.Internal;
|
||||
using Microsoft.Extensions.CommandLineUtils;
|
||||
|
||||
namespace Microsoft.DotNet.Watcher
|
||||
|
@ -18,10 +18,7 @@ namespace Microsoft.DotNet.Watcher
|
|||
public IList<string> RemainingArguments { get; private set; }
|
||||
public static CommandLineOptions Parse(string[] args, TextWriter stdout, TextWriter stderr)
|
||||
{
|
||||
if (args == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(args));
|
||||
}
|
||||
Ensure.NotNull(args, nameof(args));
|
||||
|
||||
var app = new CommandLineApplication(throwOnUnexpectedArg: false)
|
||||
{
|
||||
|
|
|
@ -1,12 +1,8 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.DotNet.Cli.Utils;
|
||||
using Microsoft.DotNet.Watcher.Internal;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
|
@ -14,163 +10,68 @@ namespace Microsoft.DotNet.Watcher
|
|||
{
|
||||
public class DotNetWatcher
|
||||
{
|
||||
private readonly Func<IFileWatcher> _fileWatcherFactory;
|
||||
private readonly Func<IProcessWatcher> _processWatcherFactory;
|
||||
private readonly IProjectProvider _projectProvider;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
|
||||
private readonly ILogger _logger;
|
||||
private readonly ProcessRunner _processRunner;
|
||||
|
||||
public DotNetWatcher(
|
||||
Func<IFileWatcher> fileWatcherFactory,
|
||||
Func<IProcessWatcher> processWatcherFactory,
|
||||
IProjectProvider projectProvider,
|
||||
ILoggerFactory loggerFactory)
|
||||
public DotNetWatcher(ILogger logger)
|
||||
{
|
||||
_fileWatcherFactory = fileWatcherFactory;
|
||||
_processWatcherFactory = processWatcherFactory;
|
||||
_projectProvider = projectProvider;
|
||||
_loggerFactory = loggerFactory;
|
||||
Ensure.NotNull(logger, nameof(logger));
|
||||
|
||||
_logger = _loggerFactory.CreateLogger(nameof(DotNetWatcher));
|
||||
_logger = logger;
|
||||
_processRunner = new ProcessRunner(logger);
|
||||
}
|
||||
|
||||
public async Task WatchAsync(string projectFile, IEnumerable<string> dotnetArguments, CancellationToken cancellationToken)
|
||||
public async Task WatchAsync(ProcessSpec processSpec, IFileSetFactory fileSetFactory, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(projectFile))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(projectFile));
|
||||
}
|
||||
if (dotnetArguments == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(dotnetArguments));
|
||||
}
|
||||
if (cancellationToken == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(cancellationToken));
|
||||
}
|
||||
Ensure.NotNull(processSpec, nameof(processSpec));
|
||||
|
||||
var dotnetArgumentsAsString = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(dotnetArguments);
|
||||
|
||||
var workingDir = Path.GetDirectoryName(projectFile);
|
||||
var cancelledTaskSource = new TaskCompletionSource<object>();
|
||||
cancellationToken.Register(state => ((TaskCompletionSource<object>)state).TrySetResult(null), cancelledTaskSource);
|
||||
|
||||
while (true)
|
||||
{
|
||||
await WaitForValidProjectJsonAsync(projectFile, cancellationToken);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var fileSet = await fileSetFactory.CreateAsync(cancellationToken);
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using (var currentRunCancellationSource = new CancellationTokenSource())
|
||||
using (var combinedCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken,
|
||||
currentRunCancellationSource.Token))
|
||||
using (var fileSetWatcher = new FileSetWatcher(fileSet))
|
||||
{
|
||||
var fileWatchingTask = WaitForProjectFileToChangeAsync(projectFile, combinedCancellationSource.Token);
|
||||
var dotnetTask = WaitForDotnetToExitAsync(dotnetArgumentsAsString, workingDir, combinedCancellationSource.Token);
|
||||
var fileSetTask = fileSetWatcher.GetChangedFileAsync(combinedCancellationSource.Token);
|
||||
var processTask = _processRunner.RunAsync(processSpec, combinedCancellationSource.Token);
|
||||
|
||||
var tasksToWait = new Task[] { dotnetTask, fileWatchingTask };
|
||||
var finishedTask = await Task.WhenAny(processTask, fileSetTask, cancelledTaskSource.Task);
|
||||
|
||||
int finishedTaskIndex = Task.WaitAny(tasksToWait, cancellationToken);
|
||||
|
||||
// Regardless of the outcome, make sure everything is cancelled
|
||||
// Regardless of the which task finished first, make sure everything is cancelled
|
||||
// and wait for dotnet to exit. We don't want orphan processes
|
||||
currentRunCancellationSource.Cancel();
|
||||
Task.WaitAll(tasksToWait);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await Task.WhenAll(processTask, fileSetTask);
|
||||
|
||||
string changedFile;
|
||||
if (finishedTaskIndex == 0)
|
||||
{
|
||||
// This is the dotnet task
|
||||
var dotnetExitCode = dotnetTask.Result;
|
||||
|
||||
if (dotnetExitCode == 0)
|
||||
{
|
||||
_logger.LogInformation($"dotnet exit code: {dotnetExitCode}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError($"dotnet exit code: {dotnetExitCode}");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Waiting for a file to change before restarting dotnet...");
|
||||
// Now wait for a file to change before restarting dotnet
|
||||
changedFile = await WaitForProjectFileToChangeAsync(projectFile, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// This is a file watcher task
|
||||
changedFile = fileWatchingTask.Result;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(changedFile))
|
||||
{
|
||||
_logger.LogInformation($"File changed: {changedFile}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> WaitForProjectFileToChangeAsync(string projectFile, CancellationToken cancellationToken)
|
||||
{
|
||||
using (var projectWatcher = CreateProjectWatcher(projectFile, watchProjectJsonOnly: false))
|
||||
{
|
||||
return await projectWatcher.WaitForChangeAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private Task<int> WaitForDotnetToExitAsync(string dotnetArguments, string workingDir, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug($"Running dotnet with the following arguments: {dotnetArguments}");
|
||||
|
||||
var dotnetWatcher = _processWatcherFactory();
|
||||
int dotnetProcessId = dotnetWatcher.Start("dotnet", dotnetArguments, workingDir);
|
||||
_logger.LogInformation($"dotnet process id: {dotnetProcessId}");
|
||||
|
||||
return dotnetWatcher.WaitForExitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task WaitForValidProjectJsonAsync(string projectFile, CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
IProject project;
|
||||
string errors;
|
||||
if (_projectProvider.TryReadProject(projectFile, out project, out errors))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogError($"Error(s) reading project file '{projectFile}': ");
|
||||
_logger.LogError(errors);
|
||||
_logger.LogInformation("Fix the error to continue.");
|
||||
|
||||
using (var projectWatcher = CreateProjectWatcher(projectFile, watchProjectJsonOnly: true))
|
||||
{
|
||||
await projectWatcher.WaitForChangeAsync(cancellationToken);
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
if (finishedTask == cancelledTaskSource.Task || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation($"File changed: {projectFile}");
|
||||
if (finishedTask == processTask)
|
||||
{
|
||||
_logger.LogInformation("Waiting for a file to change before restarting dotnet...");
|
||||
|
||||
// Now wait for a file to change before restarting process
|
||||
await fileSetWatcher.GetChangedFileAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(fileSetTask.Result))
|
||||
{
|
||||
_logger.LogInformation($"File changed: {fileSetTask.Result}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ProjectWatcher CreateProjectWatcher(string projectFile, bool watchProjectJsonOnly)
|
||||
{
|
||||
return new ProjectWatcher(projectFile, watchProjectJsonOnly, _fileWatcherFactory, _projectProvider);
|
||||
}
|
||||
|
||||
public static DotNetWatcher CreateDefault(ILoggerFactory loggerFactory)
|
||||
{
|
||||
return new DotNetWatcher(
|
||||
fileWatcherFactory: () => new FileWatcher(),
|
||||
processWatcherFactory: () => new ProcessWatcher(),
|
||||
projectProvider: new ProjectProvider(),
|
||||
loggerFactory: loggerFactory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
namespace Microsoft.DotNet.Watcher.Internal
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.DotNet.Watcher
|
||||
{
|
||||
public interface IProjectProvider
|
||||
public interface IFileSet : IEnumerable<string>
|
||||
{
|
||||
bool TryReadProject(string projectFile, out IProject project, out string errors);
|
||||
bool Contains(string filePath);
|
||||
}
|
||||
}
|
|
@ -1,16 +1,13 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.DotNet.Watcher.Internal
|
||||
namespace Microsoft.DotNet.Watcher
|
||||
{
|
||||
public interface IProcessWatcher
|
||||
public interface IFileSetFactory
|
||||
{
|
||||
int Start(string executable, string arguments, string workingDir);
|
||||
|
||||
Task<int> WaitForExitAsync(CancellationToken cancellationToken);
|
||||
Task<IFileSet> CreateAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using Microsoft.DotNet.Watcher.Tools;
|
||||
|
||||
namespace Microsoft.DotNet.Watcher.Internal
|
||||
{
|
||||
internal static class Ensure
|
||||
{
|
||||
public static T NotNull<T>(T obj, string paramName)
|
||||
where T : class
|
||||
{
|
||||
if (obj == null)
|
||||
{
|
||||
throw new ArgumentNullException(paramName);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
public static string NotNullOrEmpty(string obj, string paramName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(obj))
|
||||
{
|
||||
throw new ArgumentException(Resources.Error_StringNullOrEmpty, paramName);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.DotNet.Watcher.Internal
|
||||
{
|
||||
public class FileSet : IFileSet
|
||||
{
|
||||
private readonly HashSet<string> _files;
|
||||
|
||||
public FileSet(IEnumerable<string> files)
|
||||
{
|
||||
_files = new HashSet<string>(files, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public bool Contains(string filePath) => _files.Contains(filePath);
|
||||
|
||||
public IEnumerator<string> GetEnumerator() => _files.GetEnumerator();
|
||||
IEnumerator IEnumerable.GetEnumerator() => _files.GetEnumerator();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.DotNet.Watcher.Internal
|
||||
{
|
||||
public class FileSetWatcher : IDisposable
|
||||
{
|
||||
private readonly IFileWatcher _fileWatcher;
|
||||
private readonly IFileSet _fileSet;
|
||||
|
||||
public FileSetWatcher(IFileSet fileSet)
|
||||
{
|
||||
_fileSet = fileSet;
|
||||
_fileWatcher = new FileWatcher();
|
||||
}
|
||||
|
||||
public async Task<string> GetChangedFileAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var file in _fileSet)
|
||||
{
|
||||
_fileWatcher.WatchDirectory(Path.GetDirectoryName(file));
|
||||
}
|
||||
|
||||
var tcs = new TaskCompletionSource<string>();
|
||||
cancellationToken.Register(() => tcs.TrySetResult(null));
|
||||
|
||||
Action<string> callback = path =>
|
||||
{
|
||||
if (_fileSet.Contains(path))
|
||||
{
|
||||
tcs.TrySetResult(path);
|
||||
}
|
||||
};
|
||||
|
||||
_fileWatcher.OnFileChange += callback;
|
||||
var changedFile = await tcs.Task;
|
||||
_fileWatcher.OnFileChange -= callback;
|
||||
|
||||
return changedFile;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_fileWatcher.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -22,10 +22,8 @@ namespace Microsoft.DotNet.Watcher.Internal
|
|||
|
||||
internal DotnetFileWatcher(string watchedDirectory, Func<string, FileSystemWatcher> fileSystemWatcherFactory)
|
||||
{
|
||||
if (string.IsNullOrEmpty(watchedDirectory))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(watchedDirectory));
|
||||
}
|
||||
Ensure.NotNull(fileSystemWatcherFactory, nameof(fileSystemWatcherFactory));
|
||||
Ensure.NotNullOrEmpty(watchedDirectory, nameof(watchedDirectory));
|
||||
|
||||
_watchedDirectory = watchedDirectory;
|
||||
_watcherFactory = fileSystemWatcherFactory;
|
||||
|
@ -38,10 +36,7 @@ namespace Microsoft.DotNet.Watcher.Internal
|
|||
|
||||
private static FileSystemWatcher DefaultWatcherFactory(string watchedDirectory)
|
||||
{
|
||||
if (string.IsNullOrEmpty(watchedDirectory))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(watchedDirectory));
|
||||
}
|
||||
Ensure.NotNullOrEmpty(watchedDirectory, nameof(watchedDirectory));
|
||||
|
||||
return new FileSystemWatcher(watchedDirectory);
|
||||
}
|
||||
|
@ -51,10 +46,7 @@ namespace Microsoft.DotNet.Watcher.Internal
|
|||
// Recreate the watcher
|
||||
CreateFileSystemWatcher();
|
||||
|
||||
if (OnError != null)
|
||||
{
|
||||
OnError(this, null);
|
||||
}
|
||||
OnError?.Invoke(this, null);
|
||||
}
|
||||
|
||||
private void WatcherRenameHandler(object sender, RenamedEventArgs e)
|
||||
|
@ -81,11 +73,8 @@ namespace Microsoft.DotNet.Watcher.Internal
|
|||
|
||||
private void NotifyChange(string fullPath)
|
||||
{
|
||||
if (OnFileChange != null)
|
||||
{
|
||||
// Only report file changes
|
||||
OnFileChange(this, fullPath);
|
||||
}
|
||||
// Only report file changes
|
||||
OnFileChange?.Invoke(this, fullPath);
|
||||
}
|
||||
|
||||
private void CreateFileSystemWatcher()
|
||||
|
|
|
@ -27,10 +27,7 @@ namespace Microsoft.DotNet.Watcher.Internal
|
|||
|
||||
public PollingFileWatcher(string watchedDirectory)
|
||||
{
|
||||
if (string.IsNullOrEmpty(watchedDirectory))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(watchedDirectory));
|
||||
}
|
||||
Ensure.NotNullOrEmpty(watchedDirectory, nameof(watchedDirectory));
|
||||
|
||||
_watchedDirectory = new DirectoryInfo(watchedDirectory);
|
||||
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.DotNet.Watcher.Internal
|
||||
{
|
||||
public interface IProject
|
||||
{
|
||||
string ProjectFile { get; }
|
||||
|
||||
IEnumerable<string> Files { get; }
|
||||
|
||||
IEnumerable<string> ProjectDependencies { get; }
|
||||
}
|
||||
}
|
|
@ -1,59 +0,0 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Internal;
|
||||
|
||||
namespace Microsoft.DotNet.Watcher.Internal
|
||||
{
|
||||
public class ProcessWatcher : IProcessWatcher
|
||||
{
|
||||
private Process _runningProcess;
|
||||
|
||||
public int Start(string executable, string arguments, string workingDir)
|
||||
{
|
||||
// This is not thread safe but it will not run in a multithreaded environment so don't worry
|
||||
if (_runningProcess != null)
|
||||
{
|
||||
throw new InvalidOperationException("The previous process is still running");
|
||||
}
|
||||
|
||||
_runningProcess = new Process();
|
||||
_runningProcess.StartInfo = new ProcessStartInfo()
|
||||
{
|
||||
FileName = executable,
|
||||
Arguments = arguments,
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = workingDir
|
||||
};
|
||||
|
||||
_runningProcess.Start();
|
||||
|
||||
return _runningProcess.Id;
|
||||
}
|
||||
|
||||
public Task<int> WaitForExitAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.Register(() =>
|
||||
{
|
||||
if (_runningProcess != null)
|
||||
{
|
||||
_runningProcess.KillTree();
|
||||
}
|
||||
});
|
||||
|
||||
return Task.Run(() =>
|
||||
{
|
||||
_runningProcess.WaitForExit();
|
||||
|
||||
var exitCode = _runningProcess.ExitCode;
|
||||
_runningProcess = null;
|
||||
|
||||
return exitCode;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.DotNet.ProjectModel.Files;
|
||||
|
@ -12,10 +11,7 @@ namespace Microsoft.DotNet.Watcher.Internal
|
|||
{
|
||||
public static IEnumerable<string> ResolveFiles(this IncludeContext context)
|
||||
{
|
||||
if (context == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
Ensure.NotNull(context, nameof(context));
|
||||
|
||||
return IncludeFilesResolver
|
||||
.GetIncludeFiles(context, "/", diagnostics: null)
|
||||
|
|
|
@ -0,0 +1,126 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Internal;
|
||||
using Microsoft.DotNet.Cli.Utils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.DotNet.Watcher.Internal
|
||||
{
|
||||
public class ProcessRunner
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public ProcessRunner(ILogger logger)
|
||||
{
|
||||
Ensure.NotNull(logger, nameof(logger));
|
||||
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// May not be necessary in the future. See https://github.com/dotnet/corefx/issues/12039
|
||||
public async Task<int> RunAsync(ProcessSpec processSpec, CancellationToken cancellationToken)
|
||||
{
|
||||
Ensure.NotNull(processSpec, nameof(processSpec));
|
||||
|
||||
int exitCode;
|
||||
|
||||
using (var process = CreateProcess(processSpec))
|
||||
using (var processState = new ProcessState(process))
|
||||
{
|
||||
cancellationToken.Register(() => processState.TryKill());
|
||||
|
||||
process.Start();
|
||||
_logger.LogInformation("{execName} process id: {pid}", processSpec.ShortDisplayName(), process.Id);
|
||||
|
||||
await processState.Task;
|
||||
|
||||
exitCode = process.ExitCode;
|
||||
}
|
||||
|
||||
LogResult(processSpec, exitCode);
|
||||
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
private Process CreateProcess(ProcessSpec processSpec)
|
||||
{
|
||||
var arguments = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(processSpec.Arguments);
|
||||
|
||||
_logger.LogInformation("Running {execName} with the following arguments: {args}", processSpec.ShortDisplayName(), arguments);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = processSpec.Executable,
|
||||
Arguments = arguments,
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = processSpec.WorkingDirectory
|
||||
};
|
||||
var process = new Process
|
||||
{
|
||||
StartInfo = startInfo,
|
||||
EnableRaisingEvents = true
|
||||
};
|
||||
return process;
|
||||
}
|
||||
|
||||
private void LogResult(ProcessSpec processSpec, int exitCode)
|
||||
{
|
||||
var processName = processSpec.ShortDisplayName();
|
||||
if (exitCode == 0)
|
||||
{
|
||||
_logger.LogInformation("{execName} exit code: {code}", processName, exitCode);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("{execName} exit code: {code}", processName, exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
private class ProcessState : IDisposable
|
||||
{
|
||||
private readonly Process _process;
|
||||
private readonly TaskCompletionSource<object> _tcs = new TaskCompletionSource<object>();
|
||||
private volatile bool _disposed;
|
||||
|
||||
public ProcessState(Process process)
|
||||
{
|
||||
_process = process;
|
||||
_process.Exited += OnExited;
|
||||
}
|
||||
|
||||
public Task Task => _tcs.Task;
|
||||
|
||||
public void TryKill()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_process.HasExited)
|
||||
{
|
||||
_process.KillTree();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{ }
|
||||
}
|
||||
|
||||
private void OnExited(object sender, EventArgs args)
|
||||
=> _tcs.TrySetResult(null);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_disposed = true;
|
||||
TryKill();
|
||||
_process.Exited -= OnExited;
|
||||
_process.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,16 +1,14 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.DotNet.ProjectModel.Files;
|
||||
using Microsoft.DotNet.ProjectModel.Graph;
|
||||
|
||||
namespace Microsoft.DotNet.Watcher.Internal
|
||||
{
|
||||
internal class Project : IProject
|
||||
public class Project
|
||||
{
|
||||
public Project(ProjectModel.Project runtimeProject)
|
||||
{
|
|
@ -0,0 +1,95 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.DotNet.Watcher.Internal
|
||||
{
|
||||
public class ProjectJsonFileSet : IFileSet
|
||||
{
|
||||
private readonly string _projectFile;
|
||||
private ISet<string> _currentFiles;
|
||||
|
||||
public ProjectJsonFileSet(string projectFile)
|
||||
{
|
||||
_projectFile = projectFile;
|
||||
}
|
||||
|
||||
public bool Contains(string filePath)
|
||||
{
|
||||
// if it was in the original list of files we were watching
|
||||
if (_currentFiles?.Contains(filePath) == true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// It's possible the new file was not in the old set but will be in the new set.
|
||||
// Additions should be considered part of this.
|
||||
RefreshFileList();
|
||||
|
||||
return _currentFiles.Contains(filePath);
|
||||
}
|
||||
|
||||
public IEnumerator<string> GetEnumerator()
|
||||
{
|
||||
EnsureInitialized();
|
||||
return _currentFiles.GetEnumerator();
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
{
|
||||
EnsureInitialized();
|
||||
return _currentFiles.GetEnumerator();
|
||||
}
|
||||
|
||||
private void EnsureInitialized()
|
||||
{
|
||||
if (_currentFiles == null)
|
||||
{
|
||||
RefreshFileList();
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshFileList()
|
||||
{
|
||||
_currentFiles = new HashSet<string>(FindFiles(), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private IEnumerable<string> FindFiles()
|
||||
{
|
||||
var projects = new HashSet<string>(); // temporary store to prevent re-parsing a project multiple times
|
||||
return GetProjectFilesClosure(_projectFile, projects);
|
||||
}
|
||||
|
||||
private IEnumerable<string> GetProjectFilesClosure(string projectFile, ISet<string> projects)
|
||||
{
|
||||
if (projects.Contains(projectFile))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
projects.Add(projectFile);
|
||||
|
||||
Project project;
|
||||
string errors;
|
||||
|
||||
if (ProjectReader.TryReadProject(projectFile, out project, out errors))
|
||||
{
|
||||
foreach (var file in project.Files)
|
||||
{
|
||||
yield return file;
|
||||
}
|
||||
|
||||
foreach (var dependency in project.ProjectDependencies)
|
||||
{
|
||||
foreach (var file in GetProjectFilesClosure(dependency, projects))
|
||||
{
|
||||
yield return file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.DotNet.Watcher.Internal
|
||||
{
|
||||
public class ProjectJsonFileSetFactory : IFileSetFactory
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly string _projectFile;
|
||||
public ProjectJsonFileSetFactory(ILogger logger, string projectFile)
|
||||
{
|
||||
Ensure.NotNull(logger, nameof(logger));
|
||||
Ensure.NotNullOrEmpty(projectFile, nameof(projectFile));
|
||||
|
||||
_logger = logger;
|
||||
_projectFile = projectFile;
|
||||
}
|
||||
|
||||
public async Task<IFileSet> CreateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
Project project;
|
||||
string errors;
|
||||
if (ProjectReader.TryReadProject(_projectFile, out project, out errors))
|
||||
{
|
||||
return new ProjectJsonFileSet(_projectFile);
|
||||
}
|
||||
|
||||
_logger.LogError($"Error(s) reading project file '{_projectFile}': ");
|
||||
_logger.LogError(errors);
|
||||
_logger.LogInformation("Fix the error to continue.");
|
||||
|
||||
var fileSet = new FileSet(new[] { _projectFile });
|
||||
|
||||
using (var watcher = new FileSetWatcher(fileSet))
|
||||
{
|
||||
await watcher.GetChangedFileAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation($"File changed: {_projectFile}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,16 +2,14 @@
|
|||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.DotNet.ProjectModel;
|
||||
|
||||
namespace Microsoft.DotNet.Watcher.Internal
|
||||
{
|
||||
public class ProjectProvider : IProjectProvider
|
||||
public class ProjectReader
|
||||
{
|
||||
public bool TryReadProject(string projectFile, out IProject project, out string errors)
|
||||
public static bool TryReadProject(string projectFile, out Project project, out string errors)
|
||||
{
|
||||
errors = null;
|
||||
project = null;
|
||||
|
@ -35,12 +33,11 @@ namespace Microsoft.DotNet.Watcher.Internal
|
|||
return true;
|
||||
}
|
||||
|
||||
// Same as TryGetProject but it doesn't throw
|
||||
private bool TryGetProject(string projectFile, out ProjectModel.Project project, out string errorMessage)
|
||||
private static bool TryGetProject(string projectFile, out ProjectModel.Project project, out string errorMessage)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!ProjectReader.TryGetProject(projectFile, out project))
|
||||
if (!ProjectModel.ProjectReader.TryGetProject(projectFile, out project))
|
||||
{
|
||||
if (project?.Diagnostics != null && project.Diagnostics.Any())
|
||||
{
|
||||
|
@ -66,7 +63,7 @@ namespace Microsoft.DotNet.Watcher.Internal
|
|||
return false;
|
||||
}
|
||||
|
||||
private string CollectMessages(Exception exception)
|
||||
private static string CollectMessages(Exception exception)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine(exception.Message);
|
|
@ -1,119 +0,0 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.DotNet.Watcher.Internal
|
||||
{
|
||||
public class ProjectWatcher : IDisposable
|
||||
{
|
||||
private readonly IProjectProvider _projectProvider;
|
||||
private readonly IFileWatcher _fileWatcher;
|
||||
|
||||
private readonly string _rootProject;
|
||||
private readonly bool _watchProjectJsonOnly;
|
||||
|
||||
private ISet<string> _watchedFiles;
|
||||
|
||||
public ProjectWatcher(
|
||||
string projectToWatch,
|
||||
bool watchProjectJsonOnly,
|
||||
Func<IFileWatcher> fileWatcherFactory,
|
||||
IProjectProvider projectProvider)
|
||||
{
|
||||
_projectProvider = projectProvider;
|
||||
_fileWatcher = fileWatcherFactory();
|
||||
|
||||
_rootProject = projectToWatch;
|
||||
_watchProjectJsonOnly = watchProjectJsonOnly;
|
||||
}
|
||||
|
||||
public async Task<string> WaitForChangeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_watchedFiles = GetProjectFilesClosure(_rootProject);
|
||||
|
||||
foreach (var file in _watchedFiles)
|
||||
{
|
||||
_fileWatcher.WatchDirectory(Path.GetDirectoryName(file));
|
||||
}
|
||||
|
||||
var tcs = new TaskCompletionSource<string>();
|
||||
cancellationToken.Register(() => tcs.TrySetResult(null));
|
||||
|
||||
Action<string> callback = path =>
|
||||
{
|
||||
// If perf becomes a problem, this could be a good starting point
|
||||
// because it reparses the project on every change
|
||||
// Maybe it could time-buffer the changes in case there are a lot
|
||||
// of files changed at the same time
|
||||
if (IsFileInTheWatchedSet(path))
|
||||
{
|
||||
tcs.TrySetResult(path);
|
||||
}
|
||||
};
|
||||
|
||||
_fileWatcher.OnFileChange += callback;
|
||||
var changedFile = await tcs.Task;
|
||||
_fileWatcher.OnFileChange -= callback;
|
||||
|
||||
return changedFile;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_fileWatcher?.Dispose();
|
||||
}
|
||||
|
||||
private bool IsFileInTheWatchedSet(string file)
|
||||
{
|
||||
// If the file was already watched
|
||||
// or if the new project file closure determined
|
||||
// by file globbing patterns contains the new file
|
||||
// Note, we cannot simply rebuild the closure every time because it wouldn't
|
||||
// detect renamed files that have the new name outside of the closure
|
||||
return
|
||||
_watchedFiles.Contains(file) ||
|
||||
GetProjectFilesClosure(_rootProject).Contains(file);
|
||||
}
|
||||
|
||||
private ISet<string> GetProjectFilesClosure(string projectFile)
|
||||
{
|
||||
var closure = new HashSet<string>();
|
||||
|
||||
if (_watchProjectJsonOnly)
|
||||
{
|
||||
closure.Add(projectFile);
|
||||
}
|
||||
else
|
||||
{
|
||||
GetProjectFilesClosure(projectFile, closure);
|
||||
}
|
||||
return closure;
|
||||
}
|
||||
|
||||
private void GetProjectFilesClosure(string projectFile, ISet<string> closure)
|
||||
{
|
||||
closure.Add(projectFile);
|
||||
|
||||
IProject project;
|
||||
string errors;
|
||||
|
||||
if (_projectProvider.TryReadProject(projectFile, out project, out errors))
|
||||
{
|
||||
foreach (var file in project.Files)
|
||||
{
|
||||
closure.Add(file);
|
||||
}
|
||||
|
||||
foreach (var dependency in project.ProjectDependencies)
|
||||
{
|
||||
GetProjectFilesClosure(dependency, closure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@
|
|||
<VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">14.0</VisualStudioVersion>
|
||||
<VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VSToolsPath)\DNX\Microsoft.DNX.Props" Condition="'$(VSToolsPath)' != ''" />
|
||||
<Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.Props" Condition="'$(VSToolsPath)' != ''" />
|
||||
<PropertyGroup Label="Globals">
|
||||
<ProjectGuid>8a8ceabc-ac47-43ff-a5df-69224f7e1f46</ProjectGuid>
|
||||
<BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)'=='' ">.\obj</BaseIntermediateOutputPath>
|
||||
|
@ -16,5 +16,5 @@
|
|||
<PropertyGroup>
|
||||
<SchemaVersion>2.0</SchemaVersion>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VSToolsPath)\DNX\Microsoft.DNX.targets" Condition="'$(VSToolsPath)' != ''" />
|
||||
<Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.targets" Condition="'$(VSToolsPath)' != ''" />
|
||||
</Project>
|
|
@ -0,0 +1,18 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace Microsoft.DotNet.Watcher
|
||||
{
|
||||
public class ProcessSpec
|
||||
{
|
||||
public string Executable { get; set; }
|
||||
public string WorkingDirectory { get; set; }
|
||||
public IEnumerable<string> Arguments { get; set; }
|
||||
|
||||
public string ShortDisplayName()
|
||||
=> Path.GetFileNameWithoutExtension(Executable);
|
||||
}
|
||||
}
|
|
@ -5,29 +5,23 @@ using System;
|
|||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.DotNet.Cli.Utils;
|
||||
using Microsoft.DotNet.Watcher.Internal;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.DotNet.Watcher
|
||||
{
|
||||
public class Program
|
||||
{
|
||||
private readonly ILoggerFactory _loggerFactory = new LoggerFactory();
|
||||
private const string LoggerName = "DotNetWatcher";
|
||||
private readonly CancellationToken _cancellationToken;
|
||||
private readonly TextWriter _stdout;
|
||||
private readonly TextWriter _stderr;
|
||||
|
||||
public Program(TextWriter consoleOutput, TextWriter consoleError, CancellationToken cancellationToken)
|
||||
{
|
||||
if (consoleOutput == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(consoleOutput));
|
||||
}
|
||||
|
||||
if (cancellationToken == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(cancellationToken));
|
||||
}
|
||||
Ensure.NotNull(consoleOutput, nameof(consoleOutput));
|
||||
Ensure.NotNull(consoleError, nameof(consoleError));
|
||||
|
||||
_cancellationToken = cancellationToken;
|
||||
_stdout = consoleOutput;
|
||||
|
@ -36,28 +30,43 @@ namespace Microsoft.DotNet.Watcher
|
|||
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
DebugHelper.HandleDebugSwitch(ref args);
|
||||
|
||||
using (CancellationTokenSource ctrlCTokenSource = new CancellationTokenSource())
|
||||
{
|
||||
Console.CancelKeyPress += (sender, ev) =>
|
||||
{
|
||||
if (!ctrlCTokenSource.IsCancellationRequested)
|
||||
{
|
||||
Console.WriteLine($"[{LoggerName}] Shutdown requested. Press CTRL+C again to force exit.");
|
||||
ev.Cancel = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
ev.Cancel = false;
|
||||
}
|
||||
ctrlCTokenSource.Cancel();
|
||||
ev.Cancel = false;
|
||||
};
|
||||
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
exitCode = new Program(Console.Out, Console.Error, ctrlCTokenSource.Token)
|
||||
return new Program(Console.Out, Console.Error, ctrlCTokenSource.Token)
|
||||
.MainInternalAsync(args)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
catch (Exception ex)
|
||||
{
|
||||
// swallow when only exception is the CTRL+C exit cancellation task
|
||||
exitCode = 0;
|
||||
if (ex is TaskCanceledException || ex is OperationCanceledException)
|
||||
{
|
||||
// swallow when only exception is the CTRL+C forced an exit
|
||||
return 0;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(ex.ToString());
|
||||
Console.Error.WriteLine($"[{LoggerName}] An unexpected error occurred".Bold().Red());
|
||||
return 1;
|
||||
}
|
||||
return exitCode;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -75,17 +84,25 @@ namespace Microsoft.DotNet.Watcher
|
|||
return 2;
|
||||
}
|
||||
|
||||
var loggerFactory = new LoggerFactory();
|
||||
var commandProvider = new CommandOutputProvider
|
||||
{
|
||||
LogLevel = ResolveLogLevel(options)
|
||||
};
|
||||
_loggerFactory.AddProvider(commandProvider);
|
||||
loggerFactory.AddProvider(commandProvider);
|
||||
var logger = loggerFactory.CreateLogger(LoggerName);
|
||||
|
||||
var projectToWatch = Path.Combine(Directory.GetCurrentDirectory(), ProjectModel.Project.FileName);
|
||||
var projectFile = Path.Combine(Directory.GetCurrentDirectory(), ProjectModel.Project.FileName);
|
||||
var projectFileSetFactory = new ProjectJsonFileSetFactory(logger, projectFile);
|
||||
var processInfo = new ProcessSpec
|
||||
{
|
||||
Executable = new Muxer().MuxerPath,
|
||||
WorkingDirectory = Path.GetDirectoryName(projectFile),
|
||||
Arguments = options.RemainingArguments
|
||||
};
|
||||
|
||||
await DotNetWatcher
|
||||
.CreateDefault(_loggerFactory)
|
||||
.WatchAsync(projectToWatch, options.RemainingArguments, _cancellationToken);
|
||||
await new DotNetWatcher(logger)
|
||||
.WatchAsync(processInfo, projectFileSetFactory, _cancellationToken);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
@ -97,7 +114,7 @@ namespace Microsoft.DotNet.Watcher
|
|||
return LogLevel.Warning;
|
||||
}
|
||||
|
||||
bool globalVerbose;
|
||||
bool globalVerbose;
|
||||
bool.TryParse(Environment.GetEnvironmentVariable(CommandContext.Variables.Verbose), out globalVerbose);
|
||||
|
||||
if (options.IsVerbose // dotnet watch --verbose
|
||||
|
|
|
@ -26,6 +26,22 @@ namespace Microsoft.DotNet.Watcher.Tools
|
|||
return GetString("Error_QuietAndVerboseSpecified");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Value cannot be null or an empty string.
|
||||
/// </summary>
|
||||
internal static string Error_StringNullOrEmpty
|
||||
{
|
||||
get { return GetString("Error_StringNullOrEmpty"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Value cannot be null or an empty string.
|
||||
/// </summary>
|
||||
internal static string FormatError_StringNullOrEmpty()
|
||||
{
|
||||
return GetString("Error_StringNullOrEmpty");
|
||||
}
|
||||
|
||||
private static string GetString(string name, params string[] formatterNames)
|
||||
{
|
||||
var value = _resourceManager.GetString(name);
|
||||
|
|
|
@ -120,4 +120,7 @@
|
|||
<data name="Error_QuietAndVerboseSpecified" xml:space="preserve">
|
||||
<value>Cannot specify both '--quiet' and '--verbose' options.</value>
|
||||
</data>
|
||||
<data name="Error_StringNullOrEmpty" xml:space="preserve">
|
||||
<value>Value cannot be null or an empty string.</value>
|
||||
</data>
|
||||
</root>
|
|
@ -5,14 +5,11 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Xml.Linq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Microsoft.DotNet.Cli.Utils;
|
||||
using Microsoft.Extensions.DependencyModel;
|
||||
using Microsoft.DotNet.ProjectModel;
|
||||
using System.Reflection;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using Microsoft.DotNet.Cli.Utils;
|
||||
using Microsoft.DotNet.ProjectModel;
|
||||
|
||||
namespace Microsoft.DotNet.Watcher.Tools.FunctionalTests
|
||||
{
|
||||
|
|
Загрузка…
Ссылка в новой задаче