refactor: Centralize repo updating

Repo updating is also a little more robust now.
json-serializing-nullable-fields-issue
Robert Dailey 8 months ago
parent b5c49d81c5
commit ef8ae7dd48

@ -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

@ -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)]

@ -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<ConfigCreateCommand.CliSettings>
{
private readonly IConfigCreationProcessor _processor;
private readonly IMultiRepoUpdater _repoUpdater;
private readonly ILogger _log;
[UsedImplicitly]
@ -40,9 +42,10 @@ public class ConfigCreateCommand : AsyncCommand<ConfigCreateCommand.CliSettings>
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<ConfigCreateCommand.CliSettings>
{
try
{
await _processor.Process(settings);
await _repoUpdater.UpdateAllRepositories(settings.CancellationToken);
_processor.Process(settings);
}
catch (FileExistsException e)
{

@ -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<ConfigListCommand.CliSettings>
{
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<ConfigListCommand.CliSettings>
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<int> ExecuteAsync(CommandContext context, CliSettings settings)
{
try
{
await _processor.Process(settings.ListCategory);
await _repoUpdater.UpdateAllRepositories(settings.CancellationToken);
_processor.Process(settings.ListCategory);
}
catch (FileExistsException e)
{

@ -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<ListCustomFormatsCommand.CliSettings>
public class ListCustomFormatsCommand : AsyncCommand<ListCustomFormatsCommand.CliSettings>
{
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<ListCustomFormatsCommand.
public bool Raw { get; init; } = false;
}
public ListCustomFormatsCommand(CustomFormatDataLister lister, ITrashGuidesRepo repo)
public ListCustomFormatsCommand(CustomFormatDataLister lister, IMultiRepoUpdater repoUpdater)
{
_lister = lister;
_repo = repo;
_repoUpdater = repoUpdater;
}
public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings)
{
await _repo.Update();
await _repoUpdater.UpdateAllRepositories(settings.CancellationToken);
_lister.List(settings);
return 0;
}

@ -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<ListQualitiesCommand.CliSettings>
public class ListQualitiesCommand : AsyncCommand<ListQualitiesCommand.CliSettings>
{
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<ListQualitiesCommand.CliSetti
public SupportedServices Service { get; init; }
}
public ListQualitiesCommand(QualitySizeDataLister lister, ITrashGuidesRepo repo)
public ListQualitiesCommand(QualitySizeDataLister lister, IMultiRepoUpdater repoUpdater)
{
_lister = lister;
_repoUpdater = repo;
_repoUpdater = repoUpdater;
}
public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings)
{
await _repoUpdater.Update();
await _repoUpdater.UpdateAllRepositories(settings.CancellationToken);
_lister.ListQualities(settings.Service);
return 0;
}

@ -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<ListReleaseProfilesCommand.CliSettings>
public class ListReleaseProfilesCommand : AsyncCommand<ListReleaseProfilesCommand.CliSettings>
{
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<ListReleaseProfilesComm
public string? ListTerms { get; init; }
}
public ListReleaseProfilesCommand(ILogger log, ReleaseProfileDataLister lister, ITrashGuidesRepo repo)
public ListReleaseProfilesCommand(ILogger log, ReleaseProfileDataLister lister, IMultiRepoUpdater repoUpdater)
{
_log = log;
_lister = lister;
_repoUpdater = repo;
_repoUpdater = repoUpdater;
}
public override async Task<int> 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.

@ -16,7 +16,7 @@ namespace Recyclarr.Cli.Console.Commands;
public class SyncCommand : AsyncCommand<SyncCommand.CliSettings>
{
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<SyncCommand.CliSettings>
public IReadOnlyCollection<string> 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<SyncCommand.CliSettings>
// 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);
}

@ -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();
}
}

@ -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<Unit> _interceptedSubject = new();
private readonly ConsoleAppCancellationTokenSource _ct = new();
public IObservable<Unit> 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,

@ -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);
}
}

@ -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();
}
}

@ -4,5 +4,5 @@ namespace Recyclarr.Cli.Processors.Config;
public interface IConfigCreationProcessor
{
Task Process(ICreateConfigSettings settings);
void Process(ICreateConfigSettings settings);
}

@ -5,5 +5,5 @@ namespace Recyclarr.Cli.Processors.Config;
public interface IConfigCreator
{
bool CanHandle(ICreateConfigSettings settings);
Task Create(ICreateConfigSettings settings);
void Create(ICreateConfigSettings settings);
}

@ -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);
}

@ -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);

@ -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();
}
}

@ -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);
}
}

@ -1,6 +0,0 @@
namespace Recyclarr.Common;
public interface IFileUtilities
{
void DeleteReadOnlyDirectory(string directory);
}

@ -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)

@ -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("");

@ -2,5 +2,5 @@ namespace Recyclarr.TrashLib.Config.Listers;
public interface IConfigLister
{
Task List();
void List();
}

@ -31,10 +31,8 @@ public class ConfigTemplateGuideService : IConfigTemplateGuideService
_repo = repo;
}
public async Task<IReadOnlyCollection<TemplatePath>> LoadTemplateData()
public IReadOnlyCollection<TemplatePath> LoadTemplateData()
{
await _repo.Update();
var templatesPath = _repo.Path.File("templates.json");
if (!templatesPath.Exists)
{

@ -2,5 +2,5 @@ namespace Recyclarr.TrashLib.Config.Services;
public interface IConfigTemplateGuideService
{
Task<IReadOnlyCollection<TemplatePath>> LoadTemplateData();
IReadOnlyCollection<TemplatePath> LoadTemplateData();
}

@ -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);
}
}

@ -0,0 +1,29 @@
using Spectre.Console;
namespace Recyclarr.TrashLib.Repo;
public class ConsoleMultiRepoUpdater : IMultiRepoUpdater
{
private readonly IAnsiConsole _console;
private readonly IReadOnlyCollection<IUpdateableRepo> _repos;
public ConsoleMultiRepoUpdater(IAnsiConsole console, IReadOnlyCollection<IUpdateableRepo> 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));
});
}
}

@ -5,5 +5,4 @@ namespace Recyclarr.TrashLib.Repo;
public interface IConfigTemplatesRepo
{
IDirectoryInfo Path { get; }
Task Update();
}

@ -0,0 +1,6 @@
namespace Recyclarr.TrashLib.Repo;
public interface IMultiRepoUpdater
{
Task UpdateAllRepositories(CancellationToken token);
}

@ -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);
}

@ -5,5 +5,4 @@ namespace Recyclarr.TrashLib.Repo;
public interface ITrashGuidesRepo
{
IDirectoryInfo Path { get; }
Task Update();
}

@ -0,0 +1,6 @@
namespace Recyclarr.TrashLib.Repo;
public interface IUpdateableRepo
{
Task Update(CancellationToken token);
}

@ -8,9 +8,12 @@ public class RepoAutofacModule : Module
{
base.Load(builder);
builder.RegisterType<ConfigTemplatesRepo>().As<IConfigTemplatesRepo>();
builder.RegisterType<TrashGuidesRepo>().As<ITrashGuidesRepo>();
// Unique Repo Registrations
builder.RegisterType<ConfigTemplatesRepo>().As<IConfigTemplatesRepo>().As<IUpdateableRepo>();
builder.RegisterType<TrashGuidesRepo>().As<ITrashGuidesRepo>().As<IUpdateableRepo>();
builder.RegisterType<RepoUpdater>().As<IRepoUpdater>();
builder.RegisterType<ConsoleMultiRepoUpdater>().As<IMultiRepoUpdater>();
builder.RegisterType<RepoMetadataBuilder>().As<IRepoMetadataBuilder>().InstancePerLifetimeScope();
}
}

@ -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}");
}
}

@ -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);
}
}

@ -14,3 +14,11 @@ public class GitCmdException : Exception
ExitCode = exitCode;
}
}
public class InvalidGitRepoException : Exception
{
public InvalidGitRepoException(string? message)
: base(message)
{
}
}

@ -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<string>) args);
return RunGitCmd(token, (ICollection<string>) args);
}
private async Task RunGitCmd(ICollection<string> args)
private async Task RunGitCmd(CancellationToken token, ICollection<string> 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<string> {"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);
}
}

@ -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<IGitRepository> CreateAndCloneIfNeeded(Uri repoUrl, IDirectoryInfo repoPath, string branch)
public async Task<IGitRepository> 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;

@ -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);
}

@ -4,5 +4,9 @@ namespace Recyclarr.TrashLib.Repo.VersionControl;
public interface IGitRepositoryFactory
{
Task<IGitRepository> CreateAndCloneIfNeeded(Uri repoUrl, IDirectoryInfo repoPath, string branch);
Task<IGitRepository> CreateAndCloneIfNeeded(
Uri repoUrl,
IDirectoryInfo repoPath,
string branch,
CancellationToken token);
}

@ -50,7 +50,6 @@ public class TrashLibAutofacModule : Module
private static void CommonRegistrations(ContainerBuilder builder)
{
builder.RegisterType<DefaultEnvironment>().As<IEnvironment>();
builder.RegisterType<FileUtilities>().As<IFileUtilities>();
builder.RegisterType<RuntimeValidationService>().As<IRuntimeValidationService>();
}

@ -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<IConfigTemplatesRepo>(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<IConfigTemplatesRepo>();
// 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<ConfigListCommand>();
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<IConfigTemplatesRepo>();
// 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<ConfigCreateCommand>();
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);
}
}

@ -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);
}
}

@ -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<IConfigTemplatesRepo>();
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<ICreateConfigSettings>();
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<TemplateConfigCreator>();
sut.Create(settings);
Fs.AllFiles.Should().Contain(new[]
{
Paths.ConfigsDirectory.File("template-file1.yml").FullName,
Paths.ConfigsDirectory.File("template-file2.yml").FullName
});
}
}

@ -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();
}
}

@ -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<IConfigTemplatesRepo>();
Fs.AddFile(repo.Path.File("templates.json"), new MockFileData("{}"));
var sut = Resolve<ConfigCreationProcessor>();
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<ConfigCreationProcessor>();
@ -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<ConfigCreationProcessor>();
@ -65,11 +65,11 @@ public class ConfigCreationProcessorIntegrationTest : CliIntegrationFixture
var act = () => sut.Process(settings);
await act.Should().ThrowAsync<FileExistsException>();
act.Should().Throw<FileExistsException>();
}
[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<ConfigCreationProcessor>();
await sut.Process(settings);
sut.Process(settings);
Fs.AllFiles.Should().Contain(new[]
{

@ -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<FatalException>();
act.Should().Throw<FatalException>();
}
}

@ -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<ConfigCategory, IConfigLister> 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();
}
}

@ -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");
}

@ -17,11 +17,11 @@ public class ConfigTemplateGuideServiceTest : TrashLibIntegrationFixture
{
var act = () => _ = sut.LoadTemplateData();
act.Should().ThrowAsync<InvalidDataException>().WithMessage("Recyclarr*templates*");
act.Should().Throw<InvalidDataException>().WithMessage("Recyclarr*templates*");
}
[Test]
public async Task Normal_behavior()
public void Normal_behavior()
{
var repo = Resolve<IConfigTemplatesRepo>();
var templateDir = repo.Path;
@ -43,7 +43,7 @@ public class ConfigTemplateGuideServiceTest : TrashLibIntegrationFixture
var sut = Resolve<ConfigTemplateGuideService>();
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));

Loading…
Cancel
Save