diff --git a/CHANGELOG.md b/CHANGELOG.md index 657bf0c9..70a0392d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Print date & time log at the end of each completed instance sync (#165). +- Add status indicator when cloning or updating git repos. ### Changed diff --git a/src/Recyclarr.Cli/Console/Commands/BaseCommandSettings.cs b/src/Recyclarr.Cli/Console/Commands/BaseCommandSettings.cs index 55906214..0390c5b1 100644 --- a/src/Recyclarr.Cli/Console/Commands/BaseCommandSettings.cs +++ b/src/Recyclarr.Cli/Console/Commands/BaseCommandSettings.cs @@ -6,6 +6,8 @@ namespace Recyclarr.Cli.Console.Commands; public class BaseCommandSettings : CommandSettings { + public CancellationToken CancellationToken { get; set; } + [CommandOption("-d|--debug")] [Description("Show debug logs in console output.")] [UsedImplicitly(ImplicitUseKindFlags.Assign)] diff --git a/src/Recyclarr.Cli/Console/Commands/ConfigCreateCommand.cs b/src/Recyclarr.Cli/Console/Commands/ConfigCreateCommand.cs index 70dadffa..95265aac 100644 --- a/src/Recyclarr.Cli/Console/Commands/ConfigCreateCommand.cs +++ b/src/Recyclarr.Cli/Console/Commands/ConfigCreateCommand.cs @@ -4,6 +4,7 @@ using JetBrains.Annotations; using Recyclarr.Cli.Console.Settings; using Recyclarr.Cli.Processors.Config; using Recyclarr.TrashLib.ExceptionTypes; +using Recyclarr.TrashLib.Repo; using Spectre.Console.Cli; namespace Recyclarr.Cli.Console.Commands; @@ -13,6 +14,7 @@ namespace Recyclarr.Cli.Console.Commands; public class ConfigCreateCommand : AsyncCommand { private readonly IConfigCreationProcessor _processor; + private readonly IMultiRepoUpdater _repoUpdater; private readonly ILogger _log; [UsedImplicitly] @@ -40,9 +42,10 @@ public class ConfigCreateCommand : AsyncCommand public bool Force { get; init; } } - public ConfigCreateCommand(ILogger log, IConfigCreationProcessor processor) + public ConfigCreateCommand(ILogger log, IConfigCreationProcessor processor, IMultiRepoUpdater repoUpdater) { _processor = processor; + _repoUpdater = repoUpdater; _log = log; } @@ -50,7 +53,8 @@ public class ConfigCreateCommand : AsyncCommand { try { - await _processor.Process(settings); + await _repoUpdater.UpdateAllRepositories(settings.CancellationToken); + _processor.Process(settings); } catch (FileExistsException e) { diff --git a/src/Recyclarr.Cli/Console/Commands/ConfigListCommand.cs b/src/Recyclarr.Cli/Console/Commands/ConfigListCommand.cs index 105b495b..2b422e3f 100644 --- a/src/Recyclarr.Cli/Console/Commands/ConfigListCommand.cs +++ b/src/Recyclarr.Cli/Console/Commands/ConfigListCommand.cs @@ -6,6 +6,7 @@ using Recyclarr.Cli.Processors.Config; using Recyclarr.TrashLib.Config.Listers; using Recyclarr.TrashLib.Config.Parsing.ErrorHandling; using Recyclarr.TrashLib.ExceptionTypes; +using Recyclarr.TrashLib.Repo; using Spectre.Console.Cli; namespace Recyclarr.Cli.Console.Commands; @@ -16,6 +17,7 @@ public class ConfigListCommand : AsyncCommand { private readonly ILogger _log; private readonly ConfigListProcessor _processor; + private readonly IMultiRepoUpdater _repoUpdater; [SuppressMessage("Design", "CA1034:Nested types should not be visible")] public class CliSettings : BaseCommandSettings @@ -26,17 +28,19 @@ public class ConfigListCommand : AsyncCommand public ConfigCategory ListCategory { get; [UsedImplicitly] init; } = ConfigCategory.Local; } - public ConfigListCommand(ILogger log, ConfigListProcessor processor) + public ConfigListCommand(ILogger log, ConfigListProcessor processor, IMultiRepoUpdater repoUpdater) { _log = log; _processor = processor; + _repoUpdater = repoUpdater; } public override async Task ExecuteAsync(CommandContext context, CliSettings settings) { try { - await _processor.Process(settings.ListCategory); + await _repoUpdater.UpdateAllRepositories(settings.CancellationToken); + _processor.Process(settings.ListCategory); } catch (FileExistsException e) { diff --git a/src/Recyclarr.Cli/Console/Commands/ListCustomFormatsCommand.cs b/src/Recyclarr.Cli/Console/Commands/ListCustomFormatsCommand.cs index 7e9a531e..a7f70f6c 100644 --- a/src/Recyclarr.Cli/Console/Commands/ListCustomFormatsCommand.cs +++ b/src/Recyclarr.Cli/Console/Commands/ListCustomFormatsCommand.cs @@ -14,10 +14,10 @@ namespace Recyclarr.Cli.Console.Commands; [UsedImplicitly] [Description("List custom formats in the guide for a particular service.")] -internal class ListCustomFormatsCommand : AsyncCommand +public class ListCustomFormatsCommand : AsyncCommand { private readonly CustomFormatDataLister _lister; - private readonly ITrashGuidesRepo _repo; + private readonly IMultiRepoUpdater _repoUpdater; [UsedImplicitly] [SuppressMessage("Design", "CA1034:Nested types should not be visible")] @@ -37,15 +37,15 @@ internal class ListCustomFormatsCommand : AsyncCommand ExecuteAsync(CommandContext context, CliSettings settings) { - await _repo.Update(); + await _repoUpdater.UpdateAllRepositories(settings.CancellationToken); _lister.List(settings); return 0; } diff --git a/src/Recyclarr.Cli/Console/Commands/ListQualitiesCommand.cs b/src/Recyclarr.Cli/Console/Commands/ListQualitiesCommand.cs index f8d325b0..a8333865 100644 --- a/src/Recyclarr.Cli/Console/Commands/ListQualitiesCommand.cs +++ b/src/Recyclarr.Cli/Console/Commands/ListQualitiesCommand.cs @@ -12,10 +12,10 @@ namespace Recyclarr.Cli.Console.Commands; #pragma warning disable CS8765 [UsedImplicitly] [Description("List quality definitions in the guide for a particular service.")] -internal class ListQualitiesCommand : AsyncCommand +public class ListQualitiesCommand : AsyncCommand { private readonly QualitySizeDataLister _lister; - private readonly ITrashGuidesRepo _repoUpdater; + private readonly IMultiRepoUpdater _repoUpdater; [UsedImplicitly] [SuppressMessage("Design", "CA1034:Nested types should not be visible")] @@ -27,15 +27,15 @@ internal class ListQualitiesCommand : AsyncCommand ExecuteAsync(CommandContext context, CliSettings settings) { - await _repoUpdater.Update(); + await _repoUpdater.UpdateAllRepositories(settings.CancellationToken); _lister.ListQualities(settings.Service); return 0; } diff --git a/src/Recyclarr.Cli/Console/Commands/ListReleaseProfilesCommand.cs b/src/Recyclarr.Cli/Console/Commands/ListReleaseProfilesCommand.cs index 5f8a8c20..f8fe24a7 100644 --- a/src/Recyclarr.Cli/Console/Commands/ListReleaseProfilesCommand.cs +++ b/src/Recyclarr.Cli/Console/Commands/ListReleaseProfilesCommand.cs @@ -11,11 +11,11 @@ namespace Recyclarr.Cli.Console.Commands; [UsedImplicitly] [Description("List Sonarr release profiles in the guide for a particular service.")] -internal class ListReleaseProfilesCommand : AsyncCommand +public class ListReleaseProfilesCommand : AsyncCommand { private readonly ILogger _log; private readonly ReleaseProfileDataLister _lister; - private readonly ITrashGuidesRepo _repoUpdater; + private readonly IMultiRepoUpdater _repoUpdater; [UsedImplicitly] [SuppressMessage("Design", "CA1034:Nested types should not be visible")] @@ -29,19 +29,19 @@ internal class ListReleaseProfilesCommand : AsyncCommand ExecuteAsync(CommandContext context, CliSettings settings) { + await _repoUpdater.UpdateAllRepositories(settings.CancellationToken); + try { - await _repoUpdater.Update(); - if (settings.ListTerms is not null) { // Ignore nullability of ListTerms since the Settings.Validate() method will check for null/empty. diff --git a/src/Recyclarr.Cli/Console/Commands/SyncCommand.cs b/src/Recyclarr.Cli/Console/Commands/SyncCommand.cs index e7e60e62..07c3423b 100644 --- a/src/Recyclarr.Cli/Console/Commands/SyncCommand.cs +++ b/src/Recyclarr.Cli/Console/Commands/SyncCommand.cs @@ -16,7 +16,7 @@ namespace Recyclarr.Cli.Console.Commands; public class SyncCommand : AsyncCommand { private readonly IMigrationExecutor _migration; - private readonly ITrashGuidesRepo _repoUpdater; + private readonly IMultiRepoUpdater _repoUpdater; private readonly ISyncProcessor _syncProcessor; [UsedImplicitly] @@ -48,10 +48,10 @@ public class SyncCommand : AsyncCommand public IReadOnlyCollection Instances => InstancesOption; } - public SyncCommand(IMigrationExecutor migration, ITrashGuidesRepo repo, ISyncProcessor syncProcessor) + public SyncCommand(IMigrationExecutor migration, IMultiRepoUpdater repoUpdater, ISyncProcessor syncProcessor) { _migration = migration; - _repoUpdater = repo; + _repoUpdater = repoUpdater; _syncProcessor = syncProcessor; } @@ -61,7 +61,7 @@ public class SyncCommand : AsyncCommand // Will throw if migration is required, otherwise just a warning is issued. _migration.CheckNeededMigrations(); - await _repoUpdater.Update(); + await _repoUpdater.UpdateAllRepositories(settings.CancellationToken); return (int) await _syncProcessor.ProcessConfigs(settings); } diff --git a/src/Recyclarr.Cli/Console/Helpers/ConsoleAppCancellationTokenSource.cs b/src/Recyclarr.Cli/Console/Helpers/ConsoleAppCancellationTokenSource.cs new file mode 100644 index 00000000..861bdae6 --- /dev/null +++ b/src/Recyclarr.Cli/Console/Helpers/ConsoleAppCancellationTokenSource.cs @@ -0,0 +1,41 @@ +namespace Recyclarr.Cli.Console.Helpers; + +// Taken from: https://github.com/spectreconsole/spectre.console/issues/701#issuecomment-1081834778 +internal sealed class ConsoleAppCancellationTokenSource +{ + private readonly CancellationTokenSource _cts = new(); + + public CancellationToken Token => _cts.Token; + + public ConsoleAppCancellationTokenSource() + { + System.Console.CancelKeyPress += OnCancelKeyPress; + AppDomain.CurrentDomain.ProcessExit += OnProcessExit; + + using var _ = _cts.Token.Register(() => + { + AppDomain.CurrentDomain.ProcessExit -= OnProcessExit; + System.Console.CancelKeyPress -= OnCancelKeyPress; + }); + } + + private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) + { + // NOTE: cancel event, don't terminate the process + e.Cancel = true; + + _cts.Cancel(); + } + + private void OnProcessExit(object? sender, EventArgs e) + { + if (_cts.IsCancellationRequested) + { + // NOTE: SIGINT (cancel key was pressed, this shouldn't ever actually hit however, as we remove the event + // handler upon cancellation of the `cancellationSource`) + return; + } + + _cts.Cancel(); + } +} diff --git a/src/Recyclarr.Cli/Console/Setup/CliInterceptor.cs b/src/Recyclarr.Cli/Console/Setup/CliInterceptor.cs index 18d7607d..151eecdc 100644 --- a/src/Recyclarr.Cli/Console/Setup/CliInterceptor.cs +++ b/src/Recyclarr.Cli/Console/Setup/CliInterceptor.cs @@ -2,6 +2,7 @@ using System.Reactive.Linq; using System.Reactive.Subjects; using Recyclarr.Cli.Console.Commands; +using Recyclarr.Cli.Console.Helpers; using Recyclarr.TrashLib.Startup; using Serilog.Core; using Serilog.Events; @@ -14,6 +15,7 @@ public class CliInterceptor : ICommandInterceptor private readonly LoggingLevelSwitch _loggingLevelSwitch; private readonly AppDataPathProvider _appDataPathProvider; private readonly Subject _interceptedSubject = new(); + private readonly ConsoleAppCancellationTokenSource _ct = new(); public IObservable OnIntercepted => _interceptedSubject.AsObservable(); @@ -49,6 +51,8 @@ public class CliInterceptor : ICommandInterceptor private void HandleBaseCommand(BaseCommandSettings cmd) { + cmd.CancellationToken = _ct.Token; + _loggingLevelSwitch.MinimumLevel = cmd.Debug switch { true => LogEventLevel.Debug, diff --git a/src/Recyclarr.Cli/Processors/Config/ConfigCreationProcessor.cs b/src/Recyclarr.Cli/Processors/Config/ConfigCreationProcessor.cs index fad976ce..85f619cd 100644 --- a/src/Recyclarr.Cli/Processors/Config/ConfigCreationProcessor.cs +++ b/src/Recyclarr.Cli/Processors/Config/ConfigCreationProcessor.cs @@ -12,7 +12,7 @@ public class ConfigCreationProcessor : IConfigCreationProcessor _creators = creators; } - public async Task Process(ICreateConfigSettings settings) + public void Process(ICreateConfigSettings settings) { var creator = _creators.FirstOrDefault(x => x.CanHandle(settings)); if (creator is null) @@ -20,6 +20,6 @@ public class ConfigCreationProcessor : IConfigCreationProcessor throw new FatalException("Unable to determine which config creation logic to use"); } - await creator.Create(settings); + creator.Create(settings); } } diff --git a/src/Recyclarr.Cli/Processors/Config/ConfigListProcessor.cs b/src/Recyclarr.Cli/Processors/Config/ConfigListProcessor.cs index 56791e47..90725a6c 100644 --- a/src/Recyclarr.Cli/Processors/Config/ConfigListProcessor.cs +++ b/src/Recyclarr.Cli/Processors/Config/ConfigListProcessor.cs @@ -14,7 +14,7 @@ public class ConfigListProcessor _configListers = configListers; } - public async Task Process(ConfigCategory listCategory) + public void Process(ConfigCategory listCategory) { _log.Debug("Listing configuration for category {Category}", listCategory); if (!_configListers.TryGetValue(listCategory, out var lister)) @@ -22,6 +22,6 @@ public class ConfigListProcessor throw new ArgumentOutOfRangeException(nameof(listCategory), listCategory, "Unknown list category"); } - await lister.List(); + lister.List(); } } diff --git a/src/Recyclarr.Cli/Processors/Config/IConfigCreationProcessor.cs b/src/Recyclarr.Cli/Processors/Config/IConfigCreationProcessor.cs index 48d751be..4c0be204 100644 --- a/src/Recyclarr.Cli/Processors/Config/IConfigCreationProcessor.cs +++ b/src/Recyclarr.Cli/Processors/Config/IConfigCreationProcessor.cs @@ -4,5 +4,5 @@ namespace Recyclarr.Cli.Processors.Config; public interface IConfigCreationProcessor { - Task Process(ICreateConfigSettings settings); + void Process(ICreateConfigSettings settings); } diff --git a/src/Recyclarr.Cli/Processors/Config/IConfigCreator.cs b/src/Recyclarr.Cli/Processors/Config/IConfigCreator.cs index 0be899ed..88a96fcc 100644 --- a/src/Recyclarr.Cli/Processors/Config/IConfigCreator.cs +++ b/src/Recyclarr.Cli/Processors/Config/IConfigCreator.cs @@ -5,5 +5,5 @@ namespace Recyclarr.Cli.Processors.Config; public interface IConfigCreator { bool CanHandle(ICreateConfigSettings settings); - Task Create(ICreateConfigSettings settings); + void Create(ICreateConfigSettings settings); } diff --git a/src/Recyclarr.Cli/Processors/Config/LocalConfigCreator.cs b/src/Recyclarr.Cli/Processors/Config/LocalConfigCreator.cs index 6c22a5ab..e8c9e142 100644 --- a/src/Recyclarr.Cli/Processors/Config/LocalConfigCreator.cs +++ b/src/Recyclarr.Cli/Processors/Config/LocalConfigCreator.cs @@ -27,7 +27,7 @@ public class LocalConfigCreator : IConfigCreator return true; } - public async Task Create(ICreateConfigSettings settings) + public void Create(ICreateConfigSettings settings) { var configFile = settings.Path is null ? _paths.AppDataDirectory.File("recyclarr.yml") @@ -39,10 +39,10 @@ public class LocalConfigCreator : IConfigCreator } configFile.CreateParentDirectory(); - await using var stream = configFile.CreateText(); + using var stream = configFile.CreateText(); var ymlData = _resources.ReadData("config-template.yml"); - await stream.WriteAsync(ymlData); + stream.Write(ymlData); _log.Information("Created configuration at: {Path}", configFile.FullName); } diff --git a/src/Recyclarr.Cli/Processors/Config/TemplateConfigCreator.cs b/src/Recyclarr.Cli/Processors/Config/TemplateConfigCreator.cs index adfdd6d4..7fcbebde 100644 --- a/src/Recyclarr.Cli/Processors/Config/TemplateConfigCreator.cs +++ b/src/Recyclarr.Cli/Processors/Config/TemplateConfigCreator.cs @@ -29,11 +29,11 @@ public class TemplateConfigCreator : IConfigCreator return settings.Templates.Any(); } - public async Task Create(ICreateConfigSettings settings) + public void Create(ICreateConfigSettings settings) { _log.Debug("Creating config from templates: {Templates}", settings.Templates); - var matchingTemplateData = (await _templates.LoadTemplateData()) + var matchingTemplateData = _templates.LoadTemplateData() .IntersectBy(settings.Templates, path => path.Id, StringComparer.CurrentCultureIgnoreCase) .Select(x => x.TemplateFile); diff --git a/src/Recyclarr.Common/Extensions/FileSystemExtensions.cs b/src/Recyclarr.Common/Extensions/FileSystemExtensions.cs index 5d533de5..d7a2a631 100644 --- a/src/Recyclarr.Common/Extensions/FileSystemExtensions.cs +++ b/src/Recyclarr.Common/Extensions/FileSystemExtensions.cs @@ -105,4 +105,25 @@ public static class FileSystemExtensions dir.Delete(true); } + + public static void DeleteReadOnlyDirectory(this IDirectoryInfo directory) + { + if (!directory.Exists) + { + return; + } + + foreach (var subdirectory in directory.EnumerateDirectories()) + { + DeleteReadOnlyDirectory(subdirectory); + } + + foreach (var fileInfo in directory.EnumerateFiles()) + { + fileInfo.Attributes = FileAttributes.Normal; + fileInfo.Delete(); + } + + directory.Delete(); + } } diff --git a/src/Recyclarr.Common/FileUtilities.cs b/src/Recyclarr.Common/FileUtilities.cs deleted file mode 100644 index 4e908c9f..00000000 --- a/src/Recyclarr.Common/FileUtilities.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.IO.Abstractions; - -namespace Recyclarr.Common; - -public class FileUtilities : IFileUtilities -{ - private readonly IFileSystem _fileSystem; - - public FileUtilities(IFileSystem fileSystem) - { - _fileSystem = fileSystem; - } - - public void DeleteReadOnlyDirectory(string directory) - { - if (!_fileSystem.Directory.Exists(directory)) - { - return; - } - - foreach (var subdirectory in _fileSystem.Directory.EnumerateDirectories(directory)) - { - DeleteReadOnlyDirectory(subdirectory); - } - - foreach (var fileName in Directory.EnumerateFiles(directory)) - { - var fileInfo = _fileSystem.FileInfo.New(fileName); - fileInfo.Attributes = FileAttributes.Normal; - fileInfo.Delete(); - } - - _fileSystem.Directory.Delete(directory); - } -} diff --git a/src/Recyclarr.Common/IFileUtilities.cs b/src/Recyclarr.Common/IFileUtilities.cs deleted file mode 100644 index 39cd5e85..00000000 --- a/src/Recyclarr.Common/IFileUtilities.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Recyclarr.Common; - -public interface IFileUtilities -{ - void DeleteReadOnlyDirectory(string directory); -} diff --git a/src/Recyclarr.TrashLib/Config/Listers/ConfigLocalLister.cs b/src/Recyclarr.TrashLib/Config/Listers/ConfigLocalLister.cs index 4f2eab4c..7a456ad1 100644 --- a/src/Recyclarr.TrashLib/Config/Listers/ConfigLocalLister.cs +++ b/src/Recyclarr.TrashLib/Config/Listers/ConfigLocalLister.cs @@ -26,7 +26,7 @@ public class ConfigLocalLister : IConfigLister _paths = paths; } - public Task List() + public void List() { var tree = new Tree(_paths.AppDataDirectory.ToString()!); @@ -54,7 +54,6 @@ public class ConfigLocalLister : IConfigLister _console.WriteLine(); _console.Write(tree); - return Task.CompletedTask; } private string MakeRelative(IFileInfo path) diff --git a/src/Recyclarr.TrashLib/Config/Listers/ConfigTemplateLister.cs b/src/Recyclarr.TrashLib/Config/Listers/ConfigTemplateLister.cs index f1f1ba12..963bbaf8 100644 --- a/src/Recyclarr.TrashLib/Config/Listers/ConfigTemplateLister.cs +++ b/src/Recyclarr.TrashLib/Config/Listers/ConfigTemplateLister.cs @@ -16,9 +16,9 @@ public class ConfigTemplateLister : IConfigLister _guideService = guideService; } - public async Task List() + public void List() { - var data = await _guideService.LoadTemplateData(); + var data = _guideService.LoadTemplateData(); var table = new Table(); var empty = new Markup(""); diff --git a/src/Recyclarr.TrashLib/Config/Listers/IConfigLister.cs b/src/Recyclarr.TrashLib/Config/Listers/IConfigLister.cs index fd2221e5..411b193c 100644 --- a/src/Recyclarr.TrashLib/Config/Listers/IConfigLister.cs +++ b/src/Recyclarr.TrashLib/Config/Listers/IConfigLister.cs @@ -2,5 +2,5 @@ namespace Recyclarr.TrashLib.Config.Listers; public interface IConfigLister { - Task List(); + void List(); } diff --git a/src/Recyclarr.TrashLib/Config/Services/ConfigTemplateGuideService.cs b/src/Recyclarr.TrashLib/Config/Services/ConfigTemplateGuideService.cs index 782925c0..19f4eec2 100644 --- a/src/Recyclarr.TrashLib/Config/Services/ConfigTemplateGuideService.cs +++ b/src/Recyclarr.TrashLib/Config/Services/ConfigTemplateGuideService.cs @@ -31,10 +31,8 @@ public class ConfigTemplateGuideService : IConfigTemplateGuideService _repo = repo; } - public async Task> LoadTemplateData() + public IReadOnlyCollection LoadTemplateData() { - await _repo.Update(); - var templatesPath = _repo.Path.File("templates.json"); if (!templatesPath.Exists) { diff --git a/src/Recyclarr.TrashLib/Config/Services/IConfigTemplateGuideService.cs b/src/Recyclarr.TrashLib/Config/Services/IConfigTemplateGuideService.cs index 40ca0a00..3c8d232e 100644 --- a/src/Recyclarr.TrashLib/Config/Services/IConfigTemplateGuideService.cs +++ b/src/Recyclarr.TrashLib/Config/Services/IConfigTemplateGuideService.cs @@ -2,5 +2,5 @@ namespace Recyclarr.TrashLib.Config.Services; public interface IConfigTemplateGuideService { - Task> LoadTemplateData(); + IReadOnlyCollection LoadTemplateData(); } diff --git a/src/Recyclarr.TrashLib/Repo/ConfigTemplatesRepo.cs b/src/Recyclarr.TrashLib/Repo/ConfigTemplatesRepo.cs index d65dfb3a..39bc9ccc 100644 --- a/src/Recyclarr.TrashLib/Repo/ConfigTemplatesRepo.cs +++ b/src/Recyclarr.TrashLib/Repo/ConfigTemplatesRepo.cs @@ -2,10 +2,11 @@ using System.IO.Abstractions; using Recyclarr.Common.Extensions; using Recyclarr.TrashLib.Settings; using Recyclarr.TrashLib.Startup; +using Serilog.Context; namespace Recyclarr.TrashLib.Repo; -public class ConfigTemplatesRepo : IConfigTemplatesRepo +public class ConfigTemplatesRepo : IConfigTemplatesRepo, IUpdateableRepo { private readonly IRepoUpdater _repoUpdater; private readonly ISettingsProvider _settings; @@ -19,8 +20,9 @@ public class ConfigTemplatesRepo : IConfigTemplatesRepo public IDirectoryInfo Path { get; } - public Task Update() + public Task Update(CancellationToken token) { - return _repoUpdater.UpdateRepo(Path, _settings.Settings.Repositories.ConfigTemplates); + using var logScope = LogContext.PushProperty(LogProperty.Scope, "Config Templates Repo"); + return _repoUpdater.UpdateRepo(Path, _settings.Settings.Repositories.ConfigTemplates, token); } } diff --git a/src/Recyclarr.TrashLib/Repo/ConsoleMultiRepoUpdater.cs b/src/Recyclarr.TrashLib/Repo/ConsoleMultiRepoUpdater.cs new file mode 100644 index 00000000..4fa54fbb --- /dev/null +++ b/src/Recyclarr.TrashLib/Repo/ConsoleMultiRepoUpdater.cs @@ -0,0 +1,29 @@ +using Spectre.Console; + +namespace Recyclarr.TrashLib.Repo; + +public class ConsoleMultiRepoUpdater : IMultiRepoUpdater +{ + private readonly IAnsiConsole _console; + private readonly IReadOnlyCollection _repos; + + public ConsoleMultiRepoUpdater(IAnsiConsole console, IReadOnlyCollection repos) + { + _console = console; + _repos = repos; + } + + public async Task UpdateAllRepositories(CancellationToken token) + { + var options = new ParallelOptions + { + CancellationToken = token, + MaxDegreeOfParallelism = 3 + }; + + await _console.Status().StartAsync("Updating Git Repositories...", async _ => + { + await Parallel.ForEachAsync(_repos, options, async (repo, innerToken) => await repo.Update(innerToken)); + }); + } +} diff --git a/src/Recyclarr.TrashLib/Repo/IConfigTemplatesRepo.cs b/src/Recyclarr.TrashLib/Repo/IConfigTemplatesRepo.cs index 38ddc784..bd8687e6 100644 --- a/src/Recyclarr.TrashLib/Repo/IConfigTemplatesRepo.cs +++ b/src/Recyclarr.TrashLib/Repo/IConfigTemplatesRepo.cs @@ -5,5 +5,4 @@ namespace Recyclarr.TrashLib.Repo; public interface IConfigTemplatesRepo { IDirectoryInfo Path { get; } - Task Update(); } diff --git a/src/Recyclarr.TrashLib/Repo/IMultiRepoUpdater.cs b/src/Recyclarr.TrashLib/Repo/IMultiRepoUpdater.cs new file mode 100644 index 00000000..759495d5 --- /dev/null +++ b/src/Recyclarr.TrashLib/Repo/IMultiRepoUpdater.cs @@ -0,0 +1,6 @@ +namespace Recyclarr.TrashLib.Repo; + +public interface IMultiRepoUpdater +{ + Task UpdateAllRepositories(CancellationToken token); +} diff --git a/src/Recyclarr.TrashLib/Repo/IRepoUpdater.cs b/src/Recyclarr.TrashLib/Repo/IRepoUpdater.cs index b95f4c04..a6d5f05c 100644 --- a/src/Recyclarr.TrashLib/Repo/IRepoUpdater.cs +++ b/src/Recyclarr.TrashLib/Repo/IRepoUpdater.cs @@ -5,5 +5,5 @@ namespace Recyclarr.TrashLib.Repo; public interface IRepoUpdater { - Task UpdateRepo(IDirectoryInfo repoPath, IRepositorySettings repoSettings); + Task UpdateRepo(IDirectoryInfo repoPath, IRepositorySettings repoSettings, CancellationToken token); } diff --git a/src/Recyclarr.TrashLib/Repo/ITrashGuidesRepo.cs b/src/Recyclarr.TrashLib/Repo/ITrashGuidesRepo.cs index ba2aba38..c0ba471e 100644 --- a/src/Recyclarr.TrashLib/Repo/ITrashGuidesRepo.cs +++ b/src/Recyclarr.TrashLib/Repo/ITrashGuidesRepo.cs @@ -5,5 +5,4 @@ namespace Recyclarr.TrashLib.Repo; public interface ITrashGuidesRepo { IDirectoryInfo Path { get; } - Task Update(); } diff --git a/src/Recyclarr.TrashLib/Repo/IUpdateableRepo.cs b/src/Recyclarr.TrashLib/Repo/IUpdateableRepo.cs new file mode 100644 index 00000000..e1a2f1c7 --- /dev/null +++ b/src/Recyclarr.TrashLib/Repo/IUpdateableRepo.cs @@ -0,0 +1,6 @@ +namespace Recyclarr.TrashLib.Repo; + +public interface IUpdateableRepo +{ + Task Update(CancellationToken token); +} diff --git a/src/Recyclarr.TrashLib/Repo/RepoAutofacModule.cs b/src/Recyclarr.TrashLib/Repo/RepoAutofacModule.cs index 26a6146b..de872561 100644 --- a/src/Recyclarr.TrashLib/Repo/RepoAutofacModule.cs +++ b/src/Recyclarr.TrashLib/Repo/RepoAutofacModule.cs @@ -8,9 +8,12 @@ public class RepoAutofacModule : Module { base.Load(builder); - builder.RegisterType().As(); - builder.RegisterType().As(); + // Unique Repo Registrations + builder.RegisterType().As().As(); + builder.RegisterType().As().As(); + builder.RegisterType().As(); + builder.RegisterType().As(); builder.RegisterType().As().InstancePerLifetimeScope(); } } diff --git a/src/Recyclarr.TrashLib/Repo/RepoUpdater.cs b/src/Recyclarr.TrashLib/Repo/RepoUpdater.cs index deaef015..50ef7da9 100644 --- a/src/Recyclarr.TrashLib/Repo/RepoUpdater.cs +++ b/src/Recyclarr.TrashLib/Repo/RepoUpdater.cs @@ -1,5 +1,5 @@ using System.IO.Abstractions; -using Recyclarr.Common; +using Recyclarr.Common.Extensions; using Recyclarr.TrashLib.Repo.VersionControl; using Recyclarr.TrashLib.Settings; @@ -9,36 +9,49 @@ public class RepoUpdater : IRepoUpdater { private readonly ILogger _log; private readonly IGitRepositoryFactory _repositoryFactory; - private readonly IFileUtilities _fileUtils; - public RepoUpdater( - ILogger log, - IGitRepositoryFactory repositoryFactory, - IFileUtilities fileUtils) + public RepoUpdater(ILogger log, IGitRepositoryFactory repositoryFactory) { _log = log; _repositoryFactory = repositoryFactory; - _fileUtils = fileUtils; } - public async Task UpdateRepo(IDirectoryInfo repoPath, IRepositorySettings repoSettings) + public async Task UpdateRepo( + IDirectoryInfo repoPath, + IRepositorySettings repoSettings, + CancellationToken token) { + // Assume failure until it succeeds, to simplify the catch handlers. + var succeeded = false; + // Retry only once if there's a failure. This gives us an opportunity to delete the git repository and start // fresh. try { - await CheckoutAndUpdateRepo(repoPath, repoSettings); + await CheckoutAndUpdateRepo(repoPath, repoSettings, token); + succeeded = true; } catch (GitCmdException e) { _log.Debug(e, "Non-zero exit code {ExitCode} while executing Git command: {Error}", e.ExitCode, e.Error); - _log.Warning("Deleting local git repo '{Repodir}' and retrying git operation due to error", repoPath.Name); - _fileUtils.DeleteReadOnlyDirectory(repoPath.FullName); - await CheckoutAndUpdateRepo(repoPath, repoSettings); + } + catch (InvalidGitRepoException e) + { + _log.Debug(e, "Git repository is not valid (missing files in `.git` directory)"); + } + + if (!succeeded) + { + _log.Warning("Deleting local git repo and retrying git operation due to error"); + repoPath.DeleteReadOnlyDirectory(); + await CheckoutAndUpdateRepo(repoPath, repoSettings, token); } } - private async Task CheckoutAndUpdateRepo(IDirectoryInfo repoPath, IRepositorySettings repoSettings) + private async Task CheckoutAndUpdateRepo( + IDirectoryInfo repoPath, + IRepositorySettings repoSettings, + CancellationToken token) { var cloneUrl = repoSettings.CloneUrl; var branch = repoSettings.Branch; @@ -49,12 +62,12 @@ public class RepoUpdater : IRepoUpdater _log.Warning("Using explicit SHA1 for local repository: {Sha1}", repoSettings.Sha1); } - using var repo = await _repositoryFactory.CreateAndCloneIfNeeded(cloneUrl, repoPath, branch); - await repo.ForceCheckout(branch); + using var repo = await _repositoryFactory.CreateAndCloneIfNeeded(cloneUrl, repoPath, branch, token); + await repo.ForceCheckout(token, branch); try { - await repo.Fetch(); + await repo.Fetch(token); } catch (GitCmdException e) { @@ -65,6 +78,6 @@ public class RepoUpdater : IRepoUpdater repoPath.Name); } - await repo.ResetHard(repoSettings.Sha1 ?? $"origin/{branch}"); + await repo.ResetHard(token, repoSettings.Sha1 ?? $"origin/{branch}"); } } diff --git a/src/Recyclarr.TrashLib/Repo/TrashGuidesRepo.cs b/src/Recyclarr.TrashLib/Repo/TrashGuidesRepo.cs index 403b99d8..d294e57f 100644 --- a/src/Recyclarr.TrashLib/Repo/TrashGuidesRepo.cs +++ b/src/Recyclarr.TrashLib/Repo/TrashGuidesRepo.cs @@ -2,10 +2,11 @@ using System.IO.Abstractions; using Recyclarr.Common.Extensions; using Recyclarr.TrashLib.Settings; using Recyclarr.TrashLib.Startup; +using Serilog.Context; namespace Recyclarr.TrashLib.Repo; -public class TrashGuidesRepo : ITrashGuidesRepo +public class TrashGuidesRepo : ITrashGuidesRepo, IUpdateableRepo { private readonly IRepoUpdater _repoUpdater; private readonly ISettingsProvider _settings; @@ -19,8 +20,9 @@ public class TrashGuidesRepo : ITrashGuidesRepo public IDirectoryInfo Path { get; } - public Task Update() + public Task Update(CancellationToken token) { - return _repoUpdater.UpdateRepo(Path, _settings.Settings.Repositories.TrashGuides); + using var logScope = LogContext.PushProperty(LogProperty.Scope, "Trash Guides Repo"); + return _repoUpdater.UpdateRepo(Path, _settings.Settings.Repositories.TrashGuides, token); } } diff --git a/src/Recyclarr.TrashLib/Repo/VersionControl/GitCmdException.cs b/src/Recyclarr.TrashLib/Repo/VersionControl/GitCmdException.cs index 483cfbab..3c2c7694 100644 --- a/src/Recyclarr.TrashLib/Repo/VersionControl/GitCmdException.cs +++ b/src/Recyclarr.TrashLib/Repo/VersionControl/GitCmdException.cs @@ -14,3 +14,11 @@ public class GitCmdException : Exception ExitCode = exitCode; } } + +public class InvalidGitRepoException : Exception +{ + public InvalidGitRepoException(string? message) + : base(message) + { + } +} diff --git a/src/Recyclarr.TrashLib/Repo/VersionControl/GitRepository.cs b/src/Recyclarr.TrashLib/Repo/VersionControl/GitRepository.cs index ba012a58..549d1372 100644 --- a/src/Recyclarr.TrashLib/Repo/VersionControl/GitRepository.cs +++ b/src/Recyclarr.TrashLib/Repo/VersionControl/GitRepository.cs @@ -1,9 +1,12 @@ +using System.Diagnostics.CodeAnalysis; using System.IO.Abstractions; using System.Text; using CliWrap; namespace Recyclarr.TrashLib.Repo.VersionControl; +[SuppressMessage("Design", "CA1068:CancellationToken parameters must come last", Justification = + "Doesn't mix well with `params` (which has to be at the end)")] public sealed class GitRepository : IGitRepository { private readonly ILogger _log; @@ -17,12 +20,12 @@ public sealed class GitRepository : IGitRepository _workDir = workDir; } - private Task RunGitCmd(params string[] args) + private Task RunGitCmd(CancellationToken token, params string[] args) { - return RunGitCmd((ICollection) args); + return RunGitCmd(token, (ICollection) args); } - private async Task RunGitCmd(ICollection args) + private async Task RunGitCmd(CancellationToken token, ICollection args) { _log.Debug("Executing git command with args: {Args}", args); @@ -39,7 +42,7 @@ public sealed class GitRepository : IGitRepository .WithStandardErrorPipe(PipeTarget.ToStringBuilder(error)) .WithWorkingDirectory(_workDir.FullName); - var result = await cli.ExecuteAsync(); + var result = await cli.ExecuteAsync(token); _log.Debug("Command Output: {Output}", output.ToString().Trim()); @@ -54,32 +57,32 @@ public sealed class GitRepository : IGitRepository // Nothing to do here } - public async Task ForceCheckout(string branch) + public async Task ForceCheckout(CancellationToken token, string branch) { - await RunGitCmd("checkout", "-f", branch); + await RunGitCmd(token, "checkout", "-f", branch); } - public async Task Fetch(string remote = "origin") + public async Task Fetch(CancellationToken token, string remote = "origin") { - await RunGitCmd("fetch", remote); + await RunGitCmd(token, "fetch", remote); } - public async Task Status() + public async Task Status(CancellationToken token) { - await RunGitCmd("status"); + await RunGitCmd(token, "status"); } - public async Task ResetHard(string toBranchOrSha1) + public async Task ResetHard(CancellationToken token, string toBranchOrSha1) { - await RunGitCmd("reset", "--hard", toBranchOrSha1); + await RunGitCmd(token, "reset", "--hard", toBranchOrSha1); } - public async Task SetRemote(string name, Uri newUrl) + public async Task SetRemote(CancellationToken token, string name, Uri newUrl) { - await RunGitCmd("remote", "set-url", name, newUrl.ToString()); + await RunGitCmd(token, "remote", "set-url", name, newUrl.ToString()); } - public async Task Clone(Uri cloneUrl, string? branch = null, int depth = 0) + public async Task Clone(CancellationToken token, Uri cloneUrl, string? branch = null, int depth = 0) { var args = new List {"clone"}; if (branch is not null) @@ -93,6 +96,6 @@ public sealed class GitRepository : IGitRepository } args.AddRange(new[] {cloneUrl.ToString(), "."}); - await RunGitCmd(args); + await RunGitCmd(token, args); } } diff --git a/src/Recyclarr.TrashLib/Repo/VersionControl/GitRepositoryFactory.cs b/src/Recyclarr.TrashLib/Repo/VersionControl/GitRepositoryFactory.cs index 06d54d64..bd2bbacf 100644 --- a/src/Recyclarr.TrashLib/Repo/VersionControl/GitRepositoryFactory.cs +++ b/src/Recyclarr.TrashLib/Repo/VersionControl/GitRepositoryFactory.cs @@ -7,29 +7,48 @@ public class GitRepositoryFactory : IGitRepositoryFactory private readonly ILogger _log; private readonly IGitPath _gitPath; + // A few hand-picked files that should exist in a .git directory. + private static readonly string[] ValidGitPaths = + { + ".git/config", + ".git/index", + ".git/HEAD" + }; + public GitRepositoryFactory(ILogger log, IGitPath gitPath) { _log = log; _gitPath = gitPath; } - public async Task CreateAndCloneIfNeeded(Uri repoUrl, IDirectoryInfo repoPath, string branch) + public async Task CreateAndCloneIfNeeded( + Uri repoUrl, + IDirectoryInfo repoPath, + string branch, + CancellationToken token) { var repo = new GitRepository(_log, _gitPath, repoPath); if (!repoPath.Exists) { - _log.Information("Cloning '{RepoName}' repository...", repoPath.Name); - await repo.Clone(repoUrl, branch, 1); + _log.Information("Cloning..."); + await repo.Clone(token, repoUrl, branch, 1); } else { + // First check if the `.git` directory is present and intact. We used to just do a `git status` here, but + // this sometimes has a false positive if our repo directory is inside another repository. + if (ValidGitPaths.Select(repoPath.File).Any(x => !x.Exists)) + { + throw new InvalidGitRepoException("A `.git` directory or its files are missing"); + } + // Run just to check repository health. If unhealthy, an exception will // be thrown. That exception will propagate up and result in a re-clone. - await repo.Status(); + await repo.Status(token); } - await repo.SetRemote("origin", repoUrl); + await repo.SetRemote(token, "origin", repoUrl); _log.Debug("Remote 'origin' set to {Url}", repoUrl); return repo; diff --git a/src/Recyclarr.TrashLib/Repo/VersionControl/IGitRepository.cs b/src/Recyclarr.TrashLib/Repo/VersionControl/IGitRepository.cs index dbf8c5ec..658943b0 100644 --- a/src/Recyclarr.TrashLib/Repo/VersionControl/IGitRepository.cs +++ b/src/Recyclarr.TrashLib/Repo/VersionControl/IGitRepository.cs @@ -1,11 +1,15 @@ +using System.Diagnostics.CodeAnalysis; + namespace Recyclarr.TrashLib.Repo.VersionControl; +[SuppressMessage("Design", "CA1068:CancellationToken parameters must come last", Justification = + "Doesn't mix well with `params` (which has to be at the end)")] public interface IGitRepository : IDisposable { - Task ForceCheckout(string branch); - Task Fetch(string remote = "origin"); - Task ResetHard(string toBranchOrSha1); - Task SetRemote(string name, Uri newUrl); - Task Clone(Uri cloneUrl, string? branch = null, int depth = 0); - Task Status(); + Task ForceCheckout(CancellationToken token, string branch); + Task Fetch(CancellationToken token, string remote = "origin"); + Task ResetHard(CancellationToken token, string toBranchOrSha1); + Task SetRemote(CancellationToken token, string name, Uri newUrl); + Task Clone(CancellationToken token, Uri cloneUrl, string? branch = null, int depth = 0); + Task Status(CancellationToken token); } diff --git a/src/Recyclarr.TrashLib/Repo/VersionControl/IGitRepositoryFactory.cs b/src/Recyclarr.TrashLib/Repo/VersionControl/IGitRepositoryFactory.cs index e4836a60..060b267c 100644 --- a/src/Recyclarr.TrashLib/Repo/VersionControl/IGitRepositoryFactory.cs +++ b/src/Recyclarr.TrashLib/Repo/VersionControl/IGitRepositoryFactory.cs @@ -4,5 +4,9 @@ namespace Recyclarr.TrashLib.Repo.VersionControl; public interface IGitRepositoryFactory { - Task CreateAndCloneIfNeeded(Uri repoUrl, IDirectoryInfo repoPath, string branch); + Task CreateAndCloneIfNeeded( + Uri repoUrl, + IDirectoryInfo repoPath, + string branch, + CancellationToken token); } diff --git a/src/Recyclarr.TrashLib/TrashLibAutofacModule.cs b/src/Recyclarr.TrashLib/TrashLibAutofacModule.cs index 371a6f79..433ff482 100644 --- a/src/Recyclarr.TrashLib/TrashLibAutofacModule.cs +++ b/src/Recyclarr.TrashLib/TrashLibAutofacModule.cs @@ -50,7 +50,6 @@ public class TrashLibAutofacModule : Module private static void CommonRegistrations(ContainerBuilder builder) { builder.RegisterType().As(); - builder.RegisterType().As(); builder.RegisterType().As(); } diff --git a/src/tests/Recyclarr.Cli.Tests/Console/Commands/ConfigCommandsIntegrationTest.cs b/src/tests/Recyclarr.Cli.Tests/Console/Commands/ConfigCommandsIntegrationTest.cs index aa303886..75376e9b 100644 --- a/src/tests/Recyclarr.Cli.Tests/Console/Commands/ConfigCommandsIntegrationTest.cs +++ b/src/tests/Recyclarr.Cli.Tests/Console/Commands/ConfigCommandsIntegrationTest.cs @@ -1,10 +1,5 @@ -using System.Diagnostics.CodeAnalysis; -using System.IO.Abstractions; -using Autofac; using Recyclarr.Cli.Console.Commands; using Recyclarr.Cli.TestLibrary; -using Recyclarr.TestLibrary.Autofac; -using Recyclarr.TrashLib.Config.Listers; using Recyclarr.TrashLib.Repo; namespace Recyclarr.Cli.Tests.Console.Commands; @@ -13,52 +8,23 @@ namespace Recyclarr.Cli.Tests.Console.Commands; [Parallelizable(ParallelScope.All)] public class ConfigCommandsIntegrationTest : CliIntegrationFixture { - protected override void RegisterTypes(ContainerBuilder builder) + [Test, AutoMockData] + public async Task Repo_update_is_called_on_config_list( + [Frozen] IMultiRepoUpdater updater, + ConfigListCommand sut) { - base.RegisterTypes(builder); - builder.RegisterMockFor(x => - { - x.Path.Returns(_ => Fs.CurrentDirectory()); - }); - } - - [Test] - [SuppressMessage("Usage", "NS5000:Received check.", Justification = - "See: https://github.com/nsubstitute/NSubstitute.Analyzers/issues/211")] - public async Task Repo_update_is_called_on_config_list() - { - var repo = Resolve(); - - // Create this to make ConfigTemplateGuideService happy. It tries to parse this file, but - // it won't exist because we don't operate with real Git objects (so a clone never happens). - Fs.AddFile(repo.Path.File("templates.json"), new MockFileData("{}")); + await sut.ExecuteAsync(default!, new ConfigListCommand.CliSettings()); - var sut = Resolve(); - await sut.ExecuteAsync(default!, new ConfigListCommand.CliSettings - { - ListCategory = ConfigCategory.Templates - }); - - await repo.Received().Update(); + await updater.ReceivedWithAnyArgs().UpdateAllRepositories(default); } - [Test] - [SuppressMessage("Usage", "NS5000:Received check.", Justification = - "See: https://github.com/nsubstitute/NSubstitute.Analyzers/issues/211")] - public async Task Repo_update_is_called_on_config_create() + [Test, AutoMockData] + public async Task Repo_update_is_called_on_config_create( + [Frozen] IMultiRepoUpdater updater, + ConfigCreateCommand sut) { - var repo = Resolve(); - - // Create this to make ConfigTemplateGuideService happy. It tries to parse this file, but - // it won't exist because we don't operate with real Git objects (so a clone never happens). - Fs.AddFile(repo.Path.File("templates.json"), new MockFileData("{}")); - - var sut = Resolve(); - await sut.ExecuteAsync(default!, new ConfigCreateCommand.CliSettings - { - TemplatesOption = new[] {"some-template"} - }); + await sut.ExecuteAsync(default!, new ConfigCreateCommand.CliSettings()); - await repo.Received().Update(); + await updater.ReceivedWithAnyArgs().UpdateAllRepositories(default); } } diff --git a/src/tests/Recyclarr.Cli.Tests/Console/Commands/ListCommandsIntegrationTest.cs b/src/tests/Recyclarr.Cli.Tests/Console/Commands/ListCommandsIntegrationTest.cs new file mode 100644 index 00000000..f197b77f --- /dev/null +++ b/src/tests/Recyclarr.Cli.Tests/Console/Commands/ListCommandsIntegrationTest.cs @@ -0,0 +1,40 @@ +using Recyclarr.Cli.Console.Commands; +using Recyclarr.Cli.TestLibrary; +using Recyclarr.TrashLib.Repo; + +namespace Recyclarr.Cli.Tests.Console.Commands; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class ListCommandsIntegrationTest : CliIntegrationFixture +{ + [Test, AutoMockData] + public async Task Repo_update_is_called_on_list_custom_formats( + [Frozen] IMultiRepoUpdater updater, + ListCustomFormatsCommand sut) + { + await sut.ExecuteAsync(default!, new ListCustomFormatsCommand.CliSettings()); + + await updater.ReceivedWithAnyArgs().UpdateAllRepositories(default); + } + + [Test, AutoMockData] + public async Task Repo_update_is_called_on_list_qualities( + [Frozen] IMultiRepoUpdater updater, + ListQualitiesCommand sut) + { + await sut.ExecuteAsync(default!, new ListQualitiesCommand.CliSettings()); + + await updater.ReceivedWithAnyArgs().UpdateAllRepositories(default); + } + + [Test, AutoMockData] + public async Task Repo_update_is_called_on_list_release_profiles( + [Frozen] IMultiRepoUpdater updater, + ListReleaseProfilesCommand sut) + { + await sut.ExecuteAsync(default!, new ListReleaseProfilesCommand.CliSettings()); + + await updater.ReceivedWithAnyArgs().UpdateAllRepositories(default); + } +} diff --git a/src/tests/Recyclarr.Cli.Tests/Processors/Config/TemplateConfigCreatorIntegrationTest.cs b/src/tests/Recyclarr.Cli.Tests/Processors/Config/TemplateConfigCreatorIntegrationTest.cs new file mode 100644 index 00000000..a9ba58e2 --- /dev/null +++ b/src/tests/Recyclarr.Cli.Tests/Processors/Config/TemplateConfigCreatorIntegrationTest.cs @@ -0,0 +1,64 @@ +using System.IO.Abstractions; +using Recyclarr.Cli.Console.Settings; +using Recyclarr.Cli.Processors.Config; +using Recyclarr.Cli.TestLibrary; +using Recyclarr.TrashLib.Repo; + +namespace Recyclarr.Cli.Tests.Processors.Config; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class TemplateConfigCreatorIntegrationTest : CliIntegrationFixture +{ + [Test] + public void Template_id_matching_works() + { + const string templatesJson = + """ + { + "radarr": [ + { + "template": "template-file1.yml", + "id": "template1" + } + ], + "sonarr": [ + { + "template": "template-file2.yml", + "id": "template2" + }, + { + "template": "template-file3.yml", + "id": "template3" + } + ] + } + """; + + var repo = Resolve(); + Fs.AddFile(repo.Path.File("templates.json"), new MockFileData(templatesJson)); + Fs.AddEmptyFile(repo.Path.File("template-file1.yml")); + Fs.AddEmptyFile(repo.Path.File("template-file2.yml")); + // This one shouldn't show up in the result because the user didn't ask for it + Fs.AddEmptyFile(repo.Path.File("template-file3.yml")); + + var settings = Substitute.For(); + settings.Templates.Returns(new[] + { + "template1", + "template2", + // This one shouldn't show up in the results because: + // User specified it, but no template file exists for it. + "template4" + }); + + var sut = Resolve(); + sut.Create(settings); + + Fs.AllFiles.Should().Contain(new[] + { + Paths.ConfigsDirectory.File("template-file1.yml").FullName, + Paths.ConfigsDirectory.File("template-file2.yml").FullName + }); + } +} diff --git a/src/tests/Recyclarr.Cli.Tests/Processors/Config/TemplateConfigCreatorTest.cs b/src/tests/Recyclarr.Cli.Tests/Processors/Config/TemplateConfigCreatorTest.cs index 00d963f5..11ca40a7 100644 --- a/src/tests/Recyclarr.Cli.Tests/Processors/Config/TemplateConfigCreatorTest.cs +++ b/src/tests/Recyclarr.Cli.Tests/Processors/Config/TemplateConfigCreatorTest.cs @@ -32,7 +32,7 @@ public class TemplateConfigCreatorTest : CliIntegrationFixture } [Test, AutoMockData] - public async Task No_replace_when_file_exists_and_not_forced( + public void No_replace_when_file_exists_and_not_forced( [Frozen] IConfigTemplateGuideService templates, [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, [Frozen] IAppPaths paths, @@ -48,13 +48,13 @@ public class TemplateConfigCreatorTest : CliIntegrationFixture settings.Force.Returns(false); settings.Path.Returns(templateFile.FullName); - await sut.Create(settings); + sut.Create(settings); fs.GetFile(destFile).TextContents.Should().Be("b"); } [Test, AutoMockData] - public async Task No_throw_when_file_exists_and_forced( + public void No_throw_when_file_exists_and_forced( [Frozen] IConfigTemplateGuideService templates, [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, [Frozen] IAppPaths paths, @@ -70,6 +70,6 @@ public class TemplateConfigCreatorTest : CliIntegrationFixture var act = () => sut.Create(settings); - await act.Should().NotThrowAsync(); + act.Should().NotThrow(); } } diff --git a/src/tests/Recyclarr.Cli.Tests/Processors/ConfigCreationProcessorIntegrationTest.cs b/src/tests/Recyclarr.Cli.Tests/Processors/ConfigCreationProcessorIntegrationTest.cs index d9ba1f69..d07e1f7a 100644 --- a/src/tests/Recyclarr.Cli.Tests/Processors/ConfigCreationProcessorIntegrationTest.cs +++ b/src/tests/Recyclarr.Cli.Tests/Processors/ConfigCreationProcessorIntegrationTest.cs @@ -13,14 +13,14 @@ namespace Recyclarr.Cli.Tests.Processors; public class ConfigCreationProcessorIntegrationTest : CliIntegrationFixture { [Test] - public async Task Config_file_created_when_using_default_path() + public void Config_file_created_when_using_default_path() { var repo = Resolve(); Fs.AddFile(repo.Path.File("templates.json"), new MockFileData("{}")); var sut = Resolve(); - await sut.Process(new ConfigCreateCommand.CliSettings + sut.Process(new ConfigCreateCommand.CliSettings { Path = null }); @@ -31,7 +31,7 @@ public class ConfigCreationProcessorIntegrationTest : CliIntegrationFixture } [Test] - public async Task Config_file_created_when_using_user_specified_path() + public void Config_file_created_when_using_user_specified_path() { var sut = Resolve(); @@ -44,7 +44,7 @@ public class ConfigCreationProcessorIntegrationTest : CliIntegrationFixture .FullName }; - await sut.Process(settings); + sut.Process(settings); var file = Fs.GetFile(settings.Path); file.Should().NotBeNull(); @@ -52,7 +52,7 @@ public class ConfigCreationProcessorIntegrationTest : CliIntegrationFixture } [Test] - public async Task Should_throw_if_file_already_exists() + public void Should_throw_if_file_already_exists() { var sut = Resolve(); @@ -65,11 +65,11 @@ public class ConfigCreationProcessorIntegrationTest : CliIntegrationFixture var act = () => sut.Process(settings); - await act.Should().ThrowAsync(); + act.Should().Throw(); } [Test] - public async Task Template_id_matching_works() + public void Template_id_matching_works() { const string templatesJson = """ @@ -111,7 +111,7 @@ public class ConfigCreationProcessorIntegrationTest : CliIntegrationFixture }); var sut = Resolve(); - await sut.Process(settings); + sut.Process(settings); Fs.AllFiles.Should().Contain(new[] { diff --git a/src/tests/Recyclarr.Cli.Tests/Processors/ConfigCreationProcessorTest.cs b/src/tests/Recyclarr.Cli.Tests/Processors/ConfigCreationProcessorTest.cs index 5d3f557f..7ab20aa8 100644 --- a/src/tests/Recyclarr.Cli.Tests/Processors/ConfigCreationProcessorTest.cs +++ b/src/tests/Recyclarr.Cli.Tests/Processors/ConfigCreationProcessorTest.cs @@ -22,13 +22,13 @@ public class ConfigCreationProcessorTest } [Test, AutoMockData] - public async Task Throw_when_no_config_creators_can_handle( + public void Throw_when_no_config_creators_can_handle( [CustomizeWith(typeof(EmptyOrderedEnumerable))] ConfigCreationProcessor sut) { var settings = new ConfigCreateCommand.CliSettings(); var act = () => sut.Process(settings); - await act.Should().ThrowAsync(); + act.Should().Throw(); } } diff --git a/src/tests/Recyclarr.Cli.Tests/Processors/ConfigListProcessorTest.cs b/src/tests/Recyclarr.Cli.Tests/Processors/ConfigListProcessorTest.cs index bb070bef..3c450d2e 100644 --- a/src/tests/Recyclarr.Cli.Tests/Processors/ConfigListProcessorTest.cs +++ b/src/tests/Recyclarr.Cli.Tests/Processors/ConfigListProcessorTest.cs @@ -10,7 +10,7 @@ public class ConfigListProcessorTest { [Test] [InlineAutoMockData(ConfigCategory.Templates)] - public async Task List_templates_invokes_correct_lister( + public void List_templates_invokes_correct_lister( ConfigCategory category, [Frozen(Matching.ImplementedInterfaces)] StubAutofacIndex configListers, IConfigLister lister, @@ -18,8 +18,8 @@ public class ConfigListProcessorTest { configListers.Add(category, lister); - await sut.Process(category); + sut.Process(category); - await lister.Received().List(); + lister.Received().List(); } } diff --git a/src/tests/Recyclarr.TrashLib.Tests/Config/Listers/ConfigTemplateListerTest.cs b/src/tests/Recyclarr.TrashLib.Tests/Config/Listers/ConfigTemplateListerTest.cs index b181d7f6..c32ab72b 100644 --- a/src/tests/Recyclarr.TrashLib.Tests/Config/Listers/ConfigTemplateListerTest.cs +++ b/src/tests/Recyclarr.TrashLib.Tests/Config/Listers/ConfigTemplateListerTest.cs @@ -12,7 +12,7 @@ namespace Recyclarr.TrashLib.Tests.Config.Listers; public class ConfigTemplateListerTest : TrashLibIntegrationFixture { [Test, AutoMockData] - public async Task Hidden_templates_are_not_rendered( + public void Hidden_templates_are_not_rendered( IFileInfo stubFile, [Frozen(Matching.ImplementedInterfaces)] TestConsole console, [Frozen] IConfigTemplateGuideService guideService, @@ -26,7 +26,7 @@ public class ConfigTemplateListerTest : TrashLibIntegrationFixture new TemplatePath {Id = "s2", TemplateFile = stubFile, Service = SupportedServices.Sonarr, Hidden = true} }); - await sut.List(); + sut.List(); console.Output.Should().NotContain("s2"); } diff --git a/src/tests/Recyclarr.TrashLib.Tests/Config/Services/ConfigTemplateGuideServiceTest.cs b/src/tests/Recyclarr.TrashLib.Tests/Config/Services/ConfigTemplateGuideServiceTest.cs index 03cd2390..dfd1422e 100644 --- a/src/tests/Recyclarr.TrashLib.Tests/Config/Services/ConfigTemplateGuideServiceTest.cs +++ b/src/tests/Recyclarr.TrashLib.Tests/Config/Services/ConfigTemplateGuideServiceTest.cs @@ -17,11 +17,11 @@ public class ConfigTemplateGuideServiceTest : TrashLibIntegrationFixture { var act = () => _ = sut.LoadTemplateData(); - act.Should().ThrowAsync().WithMessage("Recyclarr*templates*"); + act.Should().Throw().WithMessage("Recyclarr*templates*"); } [Test] - public async Task Normal_behavior() + public void Normal_behavior() { var repo = Resolve(); var templateDir = repo.Path; @@ -43,7 +43,7 @@ public class ConfigTemplateGuideServiceTest : TrashLibIntegrationFixture var sut = Resolve(); - var data = await sut.LoadTemplateData(); + var data = sut.LoadTemplateData(); data.Should().BeEquivalentTo(expectedPaths, o => o.Excluding(x => x.TemplateFile)); data.Select(x => x.TemplateFile.FullName) .Should().BeEquivalentTo(expectedPaths.Select(x => x.TemplateFile.FullName));