From b3cadefa892f5bf65c138b09771987178febcd93 Mon Sep 17 00:00:00 2001 From: Coen van den Munckhof Date: Wed, 2 Sep 2026 14:54:35 +0200 Subject: [PATCH 1/4] persistant cache --- .../Persistence/IRepositorySnapshotStore.cs | 17 ++ .../Persistence/RepositorySnapshotStore.cs | 244 ++++++++++++++++++ .../RepositorySnapshotStoreSettings.cs | 15 ++ .../RepositorySnapshotStoreTests.cs | 130 ++++++++++ 4 files changed, 406 insertions(+) create mode 100644 src/RepoM.Core.Repositories/Persistence/IRepositorySnapshotStore.cs create mode 100644 src/RepoM.Core.Repositories/Persistence/RepositorySnapshotStore.cs create mode 100644 src/RepoM.Core.Repositories/Persistence/RepositorySnapshotStoreSettings.cs create mode 100644 tests/RepoM.Core.Repositories.Tests/Persistence/RepositorySnapshotStoreTests.cs diff --git a/src/RepoM.Core.Repositories/Persistence/IRepositorySnapshotStore.cs b/src/RepoM.Core.Repositories/Persistence/IRepositorySnapshotStore.cs new file mode 100644 index 00000000..8e451234 --- /dev/null +++ b/src/RepoM.Core.Repositories/Persistence/IRepositorySnapshotStore.cs @@ -0,0 +1,17 @@ +namespace RepoM.Core.Repositories.Persistence; + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using RepoM.Core.Repositories.Model; + +/// +/// Persists the set of discovered repositories so the list can be shown immediately at startup, +/// before the (slower) filesystem scan and git status reads complete. +/// +public interface IRepositorySnapshotStore +{ + Task> LoadAsync(CancellationToken ct = default); + + Task SaveAsync(IEnumerable repositories, CancellationToken ct = default); +} diff --git a/src/RepoM.Core.Repositories/Persistence/RepositorySnapshotStore.cs b/src/RepoM.Core.Repositories/Persistence/RepositorySnapshotStore.cs new file mode 100644 index 00000000..28ee1f58 --- /dev/null +++ b/src/RepoM.Core.Repositories/Persistence/RepositorySnapshotStore.cs @@ -0,0 +1,244 @@ +namespace RepoM.Core.Repositories.Persistence; + +using System; +using System.Collections.Generic; +using System.IO.Abstractions; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using RepoM.Core.Plugin.Repository; +using RepoM.Core.Repositories.Model; + +public sealed class RepositorySnapshotStore : IRepositorySnapshotStore +{ + private const int CURRENT_VERSION = 1; + + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = false, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly IFileSystem _fileSystem; + private readonly ILogger _logger; + private readonly string _filePath; + + public RepositorySnapshotStore(IFileSystem fileSystem, RepositorySnapshotStoreSettings settings, ILogger logger) + { + _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + ArgumentNullException.ThrowIfNull(settings); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _filePath = settings.FilePath; + } + + public async Task> LoadAsync(CancellationToken ct = default) + { + if (!_fileSystem.File.Exists(_filePath)) + { + return []; + } + + try + { + var json = await _fileSystem.File.ReadAllTextAsync(_filePath, ct).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + SnapshotDocument? document = JsonSerializer.Deserialize(json, _jsonOptions); + if (document?.Repositories is null || document.Version != CURRENT_VERSION) + { + return []; + } + + return [.. document.Repositories.Select(Map),]; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not load repository snapshot from '{File}'", _filePath); + return []; + } + } + + public async Task SaveAsync(IEnumerable repositories, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(repositories); + + try + { + var directory = _fileSystem.Path.GetDirectoryName(_filePath); + if (!string.IsNullOrWhiteSpace(directory) && !_fileSystem.Directory.Exists(directory)) + { + _fileSystem.Directory.CreateDirectory(directory); + } + + var document = new SnapshotDocument + { + Version = CURRENT_VERSION, + Repositories = [.. repositories.Select(Map),], + }; + + var json = JsonSerializer.Serialize(document, _jsonOptions); + + // Write to a temporary file first, then replace, to avoid a corrupted snapshot on crash. + var tempFile = _filePath + ".tmp"; + await _fileSystem.File.WriteAllTextAsync(tempFile, json, ct).ConfigureAwait(false); + _fileSystem.File.Move(tempFile, _filePath, overwrite: true); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not save repository snapshot to '{File}'", _filePath); + } + } + + private static RepositorySnapshotDto Map(RepositoryInfo info) + { + return new RepositorySnapshotDto + { + Path = info.Path, + SafePath = info.SafePath, + Name = info.Name, + WindowsPath = info.WindowsPath, + LinuxPath = info.LinuxPath, + Location = info.Location, + IsBare = info.IsBare, + CurrentBranch = info.CurrentBranch, + CurrentBranchHasUpstream = info.CurrentBranchHasUpstream, + CurrentBranchIsDetached = info.CurrentBranchIsDetached, + CurrentBranchIsOnTag = info.CurrentBranchIsOnTag, + Branches = info.Branches, + LocalBranches = info.LocalBranches, + Tags = info.Tags, + AheadBy = info.AheadBy, + BehindBy = info.BehindBy, + LocalUntracked = info.LocalUntracked, + LocalModified = info.LocalModified, + LocalMissing = info.LocalMissing, + LocalAdded = info.LocalAdded, + LocalStaged = info.LocalStaged, + LocalRemoved = info.LocalRemoved, + StashCount = info.StashCount, + LastSeen = info.LastSeen, + LastUpdated = info.LastUpdated, + Remotes = + [ + .. info.Remotes.Select(r => new RemoteDto + { + Key = r.Key, + Url = r.Url, + }), + ], + }; + } + + private static RepositoryInfo Map(RepositorySnapshotDto dto) + { + var info = new RepositoryInfo + { + Path = dto.Path, + SafePath = dto.SafePath, + Name = dto.Name, + WindowsPath = dto.WindowsPath, + LinuxPath = dto.LinuxPath, + Location = dto.Location, + IsBare = dto.IsBare, + CurrentBranch = dto.CurrentBranch, + CurrentBranchHasUpstream = dto.CurrentBranchHasUpstream, + CurrentBranchIsDetached = dto.CurrentBranchIsDetached, + CurrentBranchIsOnTag = dto.CurrentBranchIsOnTag, + Branches = dto.Branches, + LocalBranches = dto.LocalBranches, + Tags = dto.Tags, + AheadBy = dto.AheadBy, + BehindBy = dto.BehindBy, + LocalUntracked = dto.LocalUntracked, + LocalModified = dto.LocalModified, + LocalMissing = dto.LocalMissing, + LocalAdded = dto.LocalAdded, + LocalStaged = dto.LocalStaged, + LocalRemoved = dto.LocalRemoved, + StashCount = dto.StashCount, + LastSeen = dto.LastSeen, + LastUpdated = dto.LastUpdated, + }; + + foreach (RemoteDto remote in dto.Remotes) + { + info.Remotes.Add(new Remote(remote.Key, remote.Url)); + } + + return info; + } + + private sealed class SnapshotDocument + { + public int Version { get; set; } + + public List Repositories { get; set; } = []; + } + + private sealed class RepositorySnapshotDto + { + public string Path { get; set; } = string.Empty; + + public string SafePath { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string WindowsPath { get; set; } = string.Empty; + + public string LinuxPath { get; set; } = string.Empty; + + public string Location { get; set; } = string.Empty; + + public bool IsBare { get; set; } + + public string CurrentBranch { get; set; } = string.Empty; + + public bool CurrentBranchHasUpstream { get; set; } + + public bool CurrentBranchIsDetached { get; set; } + + public bool CurrentBranchIsOnTag { get; set; } + + public string[] Branches { get; set; } = []; + + public string[] LocalBranches { get; set; } = []; + + public string[] Tags { get; set; } = []; + + public int? AheadBy { get; set; } + + public int? BehindBy { get; set; } + + public int? LocalUntracked { get; set; } + + public int? LocalModified { get; set; } + + public int? LocalMissing { get; set; } + + public int? LocalAdded { get; set; } + + public int? LocalStaged { get; set; } + + public int? LocalRemoved { get; set; } + + public int? StashCount { get; set; } + + public DateTimeOffset LastSeen { get; set; } + + public DateTimeOffset LastUpdated { get; set; } + + public List Remotes { get; set; } = []; + } + + private sealed class RemoteDto + { + public string Key { get; set; } = string.Empty; + + public string Url { get; set; } = string.Empty; + } +} diff --git a/src/RepoM.Core.Repositories/Persistence/RepositorySnapshotStoreSettings.cs b/src/RepoM.Core.Repositories/Persistence/RepositorySnapshotStoreSettings.cs new file mode 100644 index 00000000..2e7d5d52 --- /dev/null +++ b/src/RepoM.Core.Repositories/Persistence/RepositorySnapshotStoreSettings.cs @@ -0,0 +1,15 @@ +namespace RepoM.Core.Repositories.Persistence; + +using System; + +public sealed record RepositorySnapshotStoreSettings +{ + public RepositorySnapshotStoreSettings(string filePath) + { + FilePath = string.IsNullOrWhiteSpace(filePath) + ? throw new ArgumentException("File path must not be empty.", nameof(filePath)) + : filePath; + } + + public string FilePath { get; } +} diff --git a/tests/RepoM.Core.Repositories.Tests/Persistence/RepositorySnapshotStoreTests.cs b/tests/RepoM.Core.Repositories.Tests/Persistence/RepositorySnapshotStoreTests.cs new file mode 100644 index 00000000..d0a89556 --- /dev/null +++ b/tests/RepoM.Core.Repositories.Tests/Persistence/RepositorySnapshotStoreTests.cs @@ -0,0 +1,130 @@ +namespace RepoM.Core.Repositories.Tests.Persistence; + +using System.Collections.Generic; +using System.IO.Abstractions.TestingHelpers; +using System.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using RepoM.Core.Plugin.Repository; +using RepoM.Core.Repositories.Model; +using RepoM.Core.Repositories.Persistence; +using Xunit; + +public class RepositorySnapshotStoreTests +{ + private const string FILE_PATH = @"c:\appdata\repositories.snapshot.json"; + + private readonly MockFileSystem _fileSystem = new(); + private readonly RepositorySnapshotStore _sut; + + public RepositorySnapshotStoreTests() + { + _sut = new RepositorySnapshotStore( + _fileSystem, + new RepositorySnapshotStoreSettings(FILE_PATH), + NullLogger.Instance); + } + + [Fact] + public async Task LoadAsync_ShouldReturnEmpty_WhenFileDoesNotExist() + { + IReadOnlyList result = await _sut.LoadAsync(); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task SaveAsync_ThenLoadAsync_ShouldRoundTripRepository() + { + // Arrange + var repo = new RepositoryInfo + { + Path = @"c:\repos\myrepo", + SafePath = "c:/repos/myrepo", + Name = "myrepo", + WindowsPath = @"c:\repos\myrepo", + LinuxPath = "c:/repos/myrepo", + Location = @"c:\repos", + CurrentBranch = "main", + CurrentBranchHasUpstream = true, + Branches = ["main", "dev",], + LocalBranches = ["main",], + Tags = ["work", "personal",], + AheadBy = 2, + BehindBy = 1, + LocalModified = 3, + StashCount = 1, + }; + repo.Remotes.Add(new Remote("origin", "https://example.com/repo.git")); + + // Act + await _sut.SaveAsync([repo,]); + IReadOnlyList loaded = await _sut.LoadAsync(); + + // Assert + loaded.Should().ContainSingle(); + RepositoryInfo actual = loaded[0]; + actual.SafePath.Should().Be("c:/repos/myrepo"); + actual.Name.Should().Be("myrepo"); + actual.CurrentBranch.Should().Be("main"); + actual.CurrentBranchHasUpstream.Should().BeTrue(); + actual.Branches.Should().BeEquivalentTo("main", "dev"); + actual.LocalBranches.Should().BeEquivalentTo("main"); + actual.Tags.Should().BeEquivalentTo("work", "personal"); + actual.AheadBy.Should().Be(2); + actual.BehindBy.Should().Be(1); + actual.LocalModified.Should().Be(3); + actual.StashCount.Should().Be(1); + actual.Remotes.Should().ContainSingle(); + actual.Remotes[0].Key.Should().Be("origin"); + actual.Remotes[0].Url.Should().Be("https://example.com/repo.git"); + } + + [Fact] + public async Task SaveAsync_ShouldCreateDirectory_WhenItDoesNotExist() + { + // Arrange + var repo = new RepositoryInfo + { + Path = @"c:\repos\myrepo", + SafePath = "c:/repos/myrepo", + Name = "myrepo", + }; + + // Act + await _sut.SaveAsync([repo,]); + + // Assert + _fileSystem.File.Exists(FILE_PATH).Should().BeTrue(); + } + + [Fact] + public async Task LoadAsync_ShouldReturnEmpty_WhenFileIsCorrupt() + { + // Arrange + _fileSystem.AddFile(FILE_PATH, new MockFileData("this is not valid json")); + + // Act + IReadOnlyList result = await _sut.LoadAsync(); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public async Task SaveAsync_ThenLoadAsync_ShouldRoundTripMultipleRepositories() + { + // Arrange + var repo1 = new RepositoryInfo { Path = @"c:\r1", SafePath = "c:/r1", Name = "r1", }; + var repo2 = new RepositoryInfo { Path = @"c:\r2", SafePath = "c:/r2", Name = "r2", }; + + // Act + await _sut.SaveAsync([repo1, repo2,]); + IReadOnlyList loaded = await _sut.LoadAsync(); + + // Assert + loaded.Should().HaveCount(2); + loaded.Select(r => r.SafePath).Should().BeEquivalentTo("c:/r1", "c:/r2"); + } +} From 783eaff036a3b274461f5abf44feeda8bdc01931 Mon Sep 17 00:00:00 2001 From: Coen van den Munckhof Date: Fri, 4 Sep 2026 11:25:30 +0200 Subject: [PATCH 2/4] Add RepositorySnapshotStore and corresponding tests for persistence functionality --- .../Persistence/RepositorySnapshotStore.cs | 34 +++++++++++-------- .../RepositorySnapshotStoreTests.cs | 31 ++++++++++++++--- 2 files changed, 47 insertions(+), 18 deletions(-) rename src/{RepoM.Core.Repositories => RepoM.Api/Repositories}/Persistence/RepositorySnapshotStore.cs (87%) rename tests/{RepoM.Core.Repositories.Tests => RepoM.Api.Tests/Repositories}/Persistence/RepositorySnapshotStoreTests.cs (85%) diff --git a/src/RepoM.Core.Repositories/Persistence/RepositorySnapshotStore.cs b/src/RepoM.Api/Repositories/Persistence/RepositorySnapshotStore.cs similarity index 87% rename from src/RepoM.Core.Repositories/Persistence/RepositorySnapshotStore.cs rename to src/RepoM.Api/Repositories/Persistence/RepositorySnapshotStore.cs index 28ee1f58..e216256f 100644 --- a/src/RepoM.Core.Repositories/Persistence/RepositorySnapshotStore.cs +++ b/src/RepoM.Api/Repositories/Persistence/RepositorySnapshotStore.cs @@ -1,25 +1,31 @@ -namespace RepoM.Core.Repositories.Persistence; +namespace RepoM.Api.Repositories.Persistence; using System; using System.Collections.Generic; using System.IO.Abstractions; using System.Linq; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using RepoM.Core.Plugin.Repository; using RepoM.Core.Repositories.Model; +using RepoM.Core.Repositories.Persistence; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; public sealed class RepositorySnapshotStore : IRepositorySnapshotStore { private const int CURRENT_VERSION = 1; - private static readonly JsonSerializerOptions _jsonOptions = new() - { - WriteIndented = false, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, - }; + private static readonly ISerializer _serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull) + .Build(); + + private static readonly IDeserializer _deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); private readonly IFileSystem _fileSystem; private readonly ILogger _logger; @@ -42,14 +48,14 @@ public async Task> LoadAsync(CancellationToken ct try { - var json = await _fileSystem.File.ReadAllTextAsync(_filePath, ct).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(json)) + var yaml = await _fileSystem.File.ReadAllTextAsync(_filePath, ct).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(yaml)) { return []; } - SnapshotDocument? document = JsonSerializer.Deserialize(json, _jsonOptions); - if (document?.Repositories is null || document.Version != CURRENT_VERSION) + SnapshotDocument document = _deserializer.Deserialize(yaml); + if (document.Version != CURRENT_VERSION) { return []; } @@ -81,11 +87,11 @@ public async Task SaveAsync(IEnumerable repositories, Cancellati Repositories = [.. repositories.Select(Map),], }; - var json = JsonSerializer.Serialize(document, _jsonOptions); + var yaml = _serializer.Serialize(document); // Write to a temporary file first, then replace, to avoid a corrupted snapshot on crash. var tempFile = _filePath + ".tmp"; - await _fileSystem.File.WriteAllTextAsync(tempFile, json, ct).ConfigureAwait(false); + await _fileSystem.File.WriteAllTextAsync(tempFile, yaml, ct).ConfigureAwait(false); _fileSystem.File.Move(tempFile, _filePath, overwrite: true); } catch (Exception ex) @@ -241,4 +247,4 @@ private sealed class RemoteDto public string Url { get; set; } = string.Empty; } -} +} \ No newline at end of file diff --git a/tests/RepoM.Core.Repositories.Tests/Persistence/RepositorySnapshotStoreTests.cs b/tests/RepoM.Api.Tests/Repositories/Persistence/RepositorySnapshotStoreTests.cs similarity index 85% rename from tests/RepoM.Core.Repositories.Tests/Persistence/RepositorySnapshotStoreTests.cs rename to tests/RepoM.Api.Tests/Repositories/Persistence/RepositorySnapshotStoreTests.cs index d0a89556..e5a4a27f 100644 --- a/tests/RepoM.Core.Repositories.Tests/Persistence/RepositorySnapshotStoreTests.cs +++ b/tests/RepoM.Api.Tests/Repositories/Persistence/RepositorySnapshotStoreTests.cs @@ -1,4 +1,4 @@ -namespace RepoM.Core.Repositories.Tests.Persistence; +namespace RepoM.Api.Tests.Repositories.Persistence; using System.Collections.Generic; using System.IO.Abstractions.TestingHelpers; @@ -6,6 +6,7 @@ namespace RepoM.Core.Repositories.Tests.Persistence; using System.Threading.Tasks; using AwesomeAssertions; using Microsoft.Extensions.Logging.Abstractions; +using RepoM.Api.Repositories.Persistence; using RepoM.Core.Plugin.Repository; using RepoM.Core.Repositories.Model; using RepoM.Core.Repositories.Persistence; @@ -13,7 +14,7 @@ namespace RepoM.Core.Repositories.Tests.Persistence; public class RepositorySnapshotStoreTests { - private const string FILE_PATH = @"c:\appdata\repositories.snapshot.json"; + private const string FILE_PATH = @"c:\appdata\repositories.snapshot.yaml"; private readonly MockFileSystem _fileSystem = new(); private readonly RepositorySnapshotStore _sut; @@ -99,11 +100,33 @@ public async Task SaveAsync_ShouldCreateDirectory_WhenItDoesNotExist() _fileSystem.File.Exists(FILE_PATH).Should().BeTrue(); } + [Fact] + public async Task SaveAsync_ShouldWriteYaml() + { + // Arrange + var repo = new RepositoryInfo + { + Path = @"c:\repos\myrepo", + SafePath = "c:/repos/myrepo", + Name = "myrepo", + }; + + // Act + await _sut.SaveAsync([repo,]); + + // Assert + string yaml = _fileSystem.File.ReadAllText(FILE_PATH); + yaml.Should().StartWith("version: 1"); + yaml.Should().Contain("repositories:"); + yaml.Should().Contain("path:"); + yaml.Should().NotContain("{"); + } + [Fact] public async Task LoadAsync_ShouldReturnEmpty_WhenFileIsCorrupt() { // Arrange - _fileSystem.AddFile(FILE_PATH, new MockFileData("this is not valid json")); + _fileSystem.AddFile(FILE_PATH, new MockFileData("this is not valid yaml")); // Act IReadOnlyList result = await _sut.LoadAsync(); @@ -127,4 +150,4 @@ public async Task SaveAsync_ThenLoadAsync_ShouldRoundTripMultipleRepositories() loaded.Should().HaveCount(2); loaded.Select(r => r.SafePath).Should().BeEquivalentTo("c:/r1", "c:/r2"); } -} +} \ No newline at end of file From f523c9f168d2fea0d1d1c30a19107f9c0cd3c0fe Mon Sep 17 00:00:00 2001 From: Coen van den Munckhof Date: Mon, 7 Sep 2026 14:23:09 +0200 Subject: [PATCH 3/4] Integrate IRepositorySnapshotStore for repository state persistence in RepositoryMonitorService --- src/RepoM.App/Bootstrapper.cs | 8 ++ .../RepositoryMonitorService.cs | 19 +++- .../RepositoryListViewModelTests.cs | 5 + .../RepositoryMonitorServiceTests.cs | 96 ++++++++++++++++--- 4 files changed, 114 insertions(+), 14 deletions(-) diff --git a/src/RepoM.App/Bootstrapper.cs b/src/RepoM.App/Bootstrapper.cs index e6f9aa4e..b3d724dd 100644 --- a/src/RepoM.App/Bootstrapper.cs +++ b/src/RepoM.App/Bootstrapper.cs @@ -12,6 +12,7 @@ namespace RepoM.App; using RepoM.Api.Ordering.Label; using RepoM.Api.Ordering.Score; using RepoM.Api.Ordering.Sum; +using RepoM.Api.Repositories.Persistence; using RepoM.Api.RepositoryActions.Decorators; using RepoM.App.i18n; using RepoM.App.RepositoryActions; @@ -27,6 +28,7 @@ namespace RepoM.App; using RepoM.Core.Plugin.RepositoryOrdering; using RepoM.Core.Repositories.Monitoring; using RepoM.Core.Repositories.Favorite; +using RepoM.Core.Repositories.Persistence; using RepoM.Core.Repositories.Reading; using RepoM.Core.Repositories.Scanning; using RepoM.Core.Repositories.Store; @@ -61,6 +63,12 @@ public static void RegisterServices(IFileSystem fileSystem, IAppDataPathProvider Container.Register(Lifestyle.Singleton); Container.Register(Lifestyle.Singleton); Container.Register(Lifestyle.Singleton); + Container.Register( + () => new RepositorySnapshotStore( + fileSystem, + new RepositorySnapshotStoreSettings(fileSystem.Path.Combine(appDataProvider.AppDataPath, "repositories.snapshot.yaml")), + Container.GetInstance>()), + Lifestyle.Singleton); Container.Register(() => Container.GetInstance(), Lifestyle.Singleton); Container.Register(() => new FavoriteService(Container.GetInstance()), Lifestyle.Singleton); Container.Register(Lifestyle.Singleton); diff --git a/src/RepoM.Core.Repositories/RepositoryMonitorService.cs b/src/RepoM.Core.Repositories/RepositoryMonitorService.cs index b6774f8a..37af20e7 100644 --- a/src/RepoM.Core.Repositories/RepositoryMonitorService.cs +++ b/src/RepoM.Core.Repositories/RepositoryMonitorService.cs @@ -14,6 +14,7 @@ namespace RepoM.Core.Repositories; using RepoM.Core.Plugin; using RepoM.Core.Repositories.Model; using RepoM.Core.Repositories.Monitoring; +using RepoM.Core.Repositories.Persistence; using RepoM.Core.Repositories.Reading; using RepoM.Core.Repositories.Scanning; using RepoM.Core.Repositories.Store; @@ -30,6 +31,7 @@ public sealed class RepositoryMonitorService : IModule, IDisposable private readonly Func> _pathProvider; private readonly IRepositoryMonitoringService _monitoringState; private readonly IRepositoryMonitoringEvents _monitoringEvents; + private readonly IRepositorySnapshotStore _snapshotStore; private readonly TimeSpan _scanInterval; private readonly Lock _scanLock = new(); private CompositeDisposable? _subscriptions; @@ -48,6 +50,7 @@ public RepositoryMonitorService( Func> pathProvider, IRepositoryMonitoringService monitoringState, IRepositoryMonitoringEvents monitoringEvents, + IRepositorySnapshotStore snapshotStore, ILogger logger) { _scanner = scanner ?? throw new ArgumentNullException(nameof(scanner)); @@ -58,6 +61,7 @@ public RepositoryMonitorService( _pathProvider = pathProvider ?? throw new ArgumentNullException(nameof(pathProvider)); _monitoringState = monitoringState ?? throw new ArgumentNullException(nameof(monitoringState)); _monitoringEvents = monitoringEvents ?? throw new ArgumentNullException(nameof(monitoringEvents)); + _snapshotStore = snapshotStore ?? throw new ArgumentNullException(nameof(snapshotStore)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _scanInterval = TimeSpan.FromMinutes(30); } @@ -66,12 +70,24 @@ public RepositoryMonitorService( public IObservable IsScanning => _scanner.IsScanning; - public Task StartAsync() + public async Task StartAsync() { _logger.LogInformation("RepositoryMonitorService starting"); _subscriptions = new CompositeDisposable(); + IReadOnlyList snapshot = await _snapshotStore.LoadAsync().ConfigureAwait(false); + _store.AddOrUpdateRange(snapshot); + + var saveSnapshotSubscription = _store + .Connect() + .Throttle(TimeSpan.FromSeconds(1)) + .SelectMany(_ => Observable.FromAsync(token => _snapshotStore.SaveAsync(_store.Items, token))) + .Subscribe( + _ => { }, + ex => _logger.LogError(ex, "Could not save repository snapshot")); + _subscriptions.Add(saveSnapshotSubscription); + // Root-level watching is used for detecting newly created / changed repositories. // It also keeps our behavior consistent with the previous contract where the // watcher receives the configured search roots. @@ -148,7 +164,6 @@ public Task StartAsync() _subscriptions.Add(stalenessSubscription); _logger.LogInformation("RepositoryMonitorService started"); - return Task.CompletedTask; } /// diff --git a/tests/RepoM.App.Tests/ViewModels/RepositoryListViewModelTests.cs b/tests/RepoM.App.Tests/ViewModels/RepositoryListViewModelTests.cs index a4b06cc4..e7383069 100644 --- a/tests/RepoM.App.Tests/ViewModels/RepositoryListViewModelTests.cs +++ b/tests/RepoM.App.Tests/ViewModels/RepositoryListViewModelTests.cs @@ -22,6 +22,7 @@ namespace RepoM.App.Tests.ViewModels; using RepoM.Core.Repositories.Favorite; using RepoM.Core.Repositories.Model; using RepoM.Core.Repositories.Monitoring; +using RepoM.Core.Repositories.Persistence; using RepoM.Core.Repositories.Reading; using RepoM.Core.Repositories.Scanning; using RepoM.Core.Repositories.Store; @@ -340,6 +341,9 @@ private static RepositoryMonitorService CreateMonitorService(RepositoryInfo? upd var fileSystem = new MockFileSystem(); var monitoringState = A.Fake(); var monitoringEvents = A.Fake(); + var snapshotStore = A.Fake(); + A.CallTo(() => snapshotStore.LoadAsync(default)).Returns([]); + A.CallTo(() => snapshotStore.SaveAsync(A>._, default)).Returns(Task.CompletedTask); if (updatedInfo != null) { @@ -355,6 +359,7 @@ private static RepositoryMonitorService CreateMonitorService(RepositoryInfo? upd () => [], monitoringState, monitoringEvents, + snapshotStore, NullLogger.Instance); } diff --git a/tests/RepoM.Core.Repositories.Tests/RepositoryMonitorServiceTests.cs b/tests/RepoM.Core.Repositories.Tests/RepositoryMonitorServiceTests.cs index 078cb2df..5e8d4311 100644 --- a/tests/RepoM.Core.Repositories.Tests/RepositoryMonitorServiceTests.cs +++ b/tests/RepoM.Core.Repositories.Tests/RepositoryMonitorServiceTests.cs @@ -12,6 +12,7 @@ namespace RepoM.Core.Repositories.Tests; using Microsoft.Extensions.Logging.Abstractions; using RepoM.Core.Repositories.Model; using RepoM.Core.Repositories.Monitoring; +using RepoM.Core.Repositories.Persistence; using RepoM.Core.Repositories.Reading; using RepoM.Core.Repositories.Scanning; using RepoM.Core.Repositories.Store; @@ -26,6 +27,7 @@ public class RepositoryMonitorServiceTests : IDisposable private readonly IFileSystem _fileSystem; private readonly RepositoryStore _store; private readonly RepositoryMonitoringStateService _monitoringState; + private readonly IRepositorySnapshotStore _snapshotStore; private readonly RepositoryMonitorService _sut; public RepositoryMonitorServiceTests() @@ -36,10 +38,13 @@ public RepositoryMonitorServiceTests() _fileSystem = A.Fake(); _store = new RepositoryStore(); _monitoringState = new RepositoryMonitoringStateService(); + _snapshotStore = A.Fake(); A.CallTo(() => _scanner.IsScanning).Returns(Observable.Return(false)); A.CallTo(() => _watcher.Watch(A>._)).Returns(Observable.Empty()); A.CallTo(() => _scanner.Scan(A>._, A._)).Returns(Observable.Empty()); + A.CallTo(() => _snapshotStore.LoadAsync(A._)).Returns([]); + A.CallTo(() => _snapshotStore.SaveAsync(A>._, A._)).Returns(Task.CompletedTask); _sut = new RepositoryMonitorService( _scanner, @@ -50,6 +55,7 @@ public RepositoryMonitorServiceTests() () => ["/repos",], _monitoringState, _monitoringState, + _snapshotStore, NullLogger.Instance); } @@ -79,6 +85,64 @@ public async Task StartAsync_ShouldTriggerInitialScan() A.CallTo(() => _scanner.Scan(A>._, A._)).MustHaveHappened(); } + [Fact] + public async Task StartAsync_ShouldLoadRepositoriesFromSnapshot() + { + // Arrange + var snapshotStore = A.Fake(); + var repository = new RepositoryInfo { Path = @"c:\repos\cached", SafePath = "c:/repos/cached", Name = "cached", }; + A.CallTo(() => snapshotStore.LoadAsync(A._)).Returns([repository,]); + using var sut = new RepositoryMonitorService( + _scanner, + _watcher, + _reader, + _store, + _fileSystem, + () => ["/repos",], + _monitoringState, + _monitoringState, + snapshotStore, + NullLogger.Instance); + + // Act + await sut.StartAsync(); + + // Assert + _store.Lookup("c:/repos/cached").HasValue.Should().BeTrue(); + A.CallTo(() => snapshotStore.LoadAsync(A._)).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task StartAsync_ShouldSaveSnapshot_WhenRepositoryStoreChanges() + { + // Arrange + var snapshotStore = A.Fake(); + var saveCompleted = new TaskCompletionSource(); + A.CallTo(() => snapshotStore.LoadAsync(A._)).Returns([]); + A.CallTo(() => snapshotStore.SaveAsync(A>._, A._)) + .Invokes(() => saveCompleted.TrySetResult()) + .Returns(Task.CompletedTask); + using var sut = new RepositoryMonitorService( + _scanner, + _watcher, + _reader, + _store, + _fileSystem, + () => ["/repos",], + _monitoringState, + _monitoringState, + snapshotStore, + NullLogger.Instance); + await sut.StartAsync(); + + // Act + _store.AddOrUpdate(new RepositoryInfo { Path = @"c:\repos\saved", SafePath = "c:/repos/saved", Name = "saved", }); + await saveCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + // Assert + A.CallTo(() => snapshotStore.SaveAsync(A>._, A._)).MustHaveHappenedOnceExactly(); + } + [Fact] public async Task StopAsync_ShouldDisposeSubscriptions() { @@ -124,7 +188,7 @@ public async Task ScanAsync_ShouldAddFoundRepositoriesToStore() public void Constructor_ShouldThrow_WhenScannerIsNull() { var act = () => new RepositoryMonitorService( - null!, _watcher, _reader, _store, _fileSystem, () => [], _monitoringState, _monitoringState, NullLogger.Instance); + null!, _watcher, _reader, _store, _fileSystem, () => [], _monitoringState, _monitoringState, _snapshotStore, NullLogger.Instance); act.Should().Throw(); } @@ -132,7 +196,7 @@ public void Constructor_ShouldThrow_WhenScannerIsNull() public void Constructor_ShouldThrow_WhenWatcherIsNull() { var act = () => new RepositoryMonitorService( - _scanner, null!, _reader, _store, _fileSystem, () => [], _monitoringState, _monitoringState, NullLogger.Instance); + _scanner, null!, _reader, _store, _fileSystem, () => [], _monitoringState, _monitoringState, _snapshotStore, NullLogger.Instance); act.Should().Throw(); } @@ -140,7 +204,7 @@ public void Constructor_ShouldThrow_WhenWatcherIsNull() public void Constructor_ShouldThrow_WhenReaderIsNull() { var act = () => new RepositoryMonitorService( - _scanner, _watcher, null!, _store, _fileSystem, () => [], _monitoringState, _monitoringState, NullLogger.Instance); + _scanner, _watcher, null!, _store, _fileSystem, () => [], _monitoringState, _monitoringState, _snapshotStore, NullLogger.Instance); act.Should().Throw(); } @@ -148,7 +212,7 @@ public void Constructor_ShouldThrow_WhenReaderIsNull() public void Constructor_ShouldThrow_WhenStoreIsNull() { var act = () => new RepositoryMonitorService( - _scanner, _watcher, _reader, null!, _fileSystem, () => [], _monitoringState, _monitoringState, NullLogger.Instance); + _scanner, _watcher, _reader, null!, _fileSystem, () => [], _monitoringState, _monitoringState, _snapshotStore, NullLogger.Instance); act.Should().Throw(); } @@ -156,7 +220,7 @@ public void Constructor_ShouldThrow_WhenStoreIsNull() public void Constructor_ShouldThrow_WhenFileSystemIsNull() { var act = () => new RepositoryMonitorService( - _scanner, _watcher, _reader, _store, null!, () => [], _monitoringState, _monitoringState, NullLogger.Instance); + _scanner, _watcher, _reader, _store, null!, () => [], _monitoringState, _monitoringState, _snapshotStore, NullLogger.Instance); act.Should().Throw(); } @@ -164,7 +228,7 @@ public void Constructor_ShouldThrow_WhenFileSystemIsNull() public void Constructor_ShouldThrow_WhenPathProviderIsNull() { var act = () => new RepositoryMonitorService( - _scanner, _watcher, _reader, _store, _fileSystem, null!, _monitoringState, _monitoringState, NullLogger.Instance); + _scanner, _watcher, _reader, _store, _fileSystem, null!, _monitoringState, _monitoringState, _snapshotStore, NullLogger.Instance); act.Should().Throw(); } @@ -172,7 +236,7 @@ public void Constructor_ShouldThrow_WhenPathProviderIsNull() public void Constructor_ShouldThrow_WhenLoggerIsNull() { var act = () => new RepositoryMonitorService( - _scanner, _watcher, _reader, _store, _fileSystem, () => [], _monitoringState, _monitoringState, null!); + _scanner, _watcher, _reader, _store, _fileSystem, () => [], _monitoringState, _monitoringState, _snapshotStore, null!); act.Should().Throw(); } @@ -291,7 +355,7 @@ public async Task RemoveStaleRepositories_ShouldSkipConcurrentCall() }); using var sut = new RepositoryMonitorService( - _scanner, _watcher, _reader, fakeStore, _fileSystem, () => ["/repos",], _monitoringState, _monitoringState, NullLogger.Instance); + _scanner, _watcher, _reader, fakeStore, _fileSystem, () => ["/repos",], _monitoringState, _monitoringState, _snapshotStore, NullLogger.Instance); // act // Start the first (blocking) call on a background thread @@ -328,7 +392,7 @@ public async Task IsScanning_ShouldExposeScannersObservable() A.CallTo(() => _scanner.IsScanning).Returns(subject.AsObservable()); var sut = new RepositoryMonitorService( - _scanner, _watcher, _reader, _store, _fileSystem, () => ["/repos",], _monitoringState, _monitoringState, NullLogger.Instance); + _scanner, _watcher, _reader, _store, _fileSystem, () => ["/repos",], _monitoringState, _monitoringState, _snapshotStore, NullLogger.Instance); // Act var isScanning = await sut.IsScanning.FirstAsync(); @@ -690,7 +754,7 @@ public async Task StopAsync_ThenStartAsync_ShouldWork() public void Constructor_ShouldThrow_WhenMonitoringStateIsNull() { var act = () => new RepositoryMonitorService( - _scanner, _watcher, _reader, _store, _fileSystem, () => [], null!, _monitoringState, NullLogger.Instance); + _scanner, _watcher, _reader, _store, _fileSystem, () => [], null!, _monitoringState, _snapshotStore, NullLogger.Instance); act.Should().Throw(); } @@ -698,7 +762,15 @@ public void Constructor_ShouldThrow_WhenMonitoringStateIsNull() public void Constructor_ShouldThrow_WhenMonitoringEventsIsNull() { var act = () => new RepositoryMonitorService( - _scanner, _watcher, _reader, _store, _fileSystem, () => [], _monitoringState, null!, NullLogger.Instance); + _scanner, _watcher, _reader, _store, _fileSystem, () => [], _monitoringState, null!, _snapshotStore, NullLogger.Instance); + act.Should().Throw(); + } + + [Fact] + public void Constructor_ShouldThrow_WhenSnapshotStoreIsNull() + { + var act = () => new RepositoryMonitorService( + _scanner, _watcher, _reader, _store, _fileSystem, () => [], _monitoringState, _monitoringState, null!, NullLogger.Instance); act.Should().Throw(); } @@ -1096,7 +1168,7 @@ public async Task RefreshAllAsync_ShouldSkipSecondCall_WhenAlreadyRunning() }); using var sut = new RepositoryMonitorService( - _scanner, _watcher, _reader, fakeStore, _fileSystem, () => ["/repos"], _monitoringState, _monitoringState, NullLogger.Instance); + _scanner, _watcher, _reader, fakeStore, _fileSystem, () => ["/repos"], _monitoringState, _monitoringState, _snapshotStore, NullLogger.Instance); // Act var firstCall = Task.Run(() => sut.RefreshAllAsync(CancellationToken.None)); From 480c32bbe69883e13677fc02c3504d6d26e073c9 Mon Sep 17 00:00:00 2001 From: Coen van den Munckhof Date: Mon, 7 Sep 2026 14:51:35 +0200 Subject: [PATCH 4/4] Add cancellation token to LoadAsync in RepositoryMonitorService --- src/RepoM.Core.Repositories/RepositoryMonitorService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/RepoM.Core.Repositories/RepositoryMonitorService.cs b/src/RepoM.Core.Repositories/RepositoryMonitorService.cs index 37af20e7..42763015 100644 --- a/src/RepoM.Core.Repositories/RepositoryMonitorService.cs +++ b/src/RepoM.Core.Repositories/RepositoryMonitorService.cs @@ -72,11 +72,12 @@ public RepositoryMonitorService( public async Task StartAsync() { + var ctNone = CancellationToken.None; // this method does not support cancellation _logger.LogInformation("RepositoryMonitorService starting"); _subscriptions = new CompositeDisposable(); - IReadOnlyList snapshot = await _snapshotStore.LoadAsync().ConfigureAwait(false); + IReadOnlyList snapshot = await _snapshotStore.LoadAsync(ctNone).ConfigureAwait(false); _store.AddOrUpdateRange(snapshot); var saveSnapshotSubscription = _store