Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
250 changes: 250 additions & 0 deletions src/RepoM.Api/Repositories/Persistence/RepositorySnapshotStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
namespace RepoM.Api.Repositories.Persistence;

using System;
using System.Collections.Generic;
using System.IO.Abstractions;
using System.Linq;
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 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;
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<IReadOnlyList<RepositoryInfo>> LoadAsync(CancellationToken ct = default)
{
if (!_fileSystem.File.Exists(_filePath))
{
return [];
}

try
{
var yaml = await _fileSystem.File.ReadAllTextAsync(_filePath, ct).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(yaml))
{
return [];
}

SnapshotDocument document = _deserializer.Deserialize<SnapshotDocument>(yaml);
if (document.Version != CURRENT_VERSION)
{
return [];
}

return [.. document.Repositories.Select(Map),];
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Could not load repository snapshot from '{File}'", _filePath);

Check warning on line 67 in src/RepoM.Api/Repositories/Persistence/RepositorySnapshotStore.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Evaluation of this argument may be expensive and unnecessary if logging is disabled

See more on https://sonarcloud.io/project/issues?id=RepoM&issues=AaBrnFmkECBKsn7UGnwv&open=AaBrnFmkECBKsn7UGnwv&pullRequest=230
return [];
}
}

public async Task SaveAsync(IEnumerable<RepositoryInfo> 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 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, yaml, ct).ConfigureAwait(false);
_fileSystem.File.Move(tempFile, _filePath, overwrite: true);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Could not save repository snapshot to '{File}'", _filePath);

Check warning on line 99 in src/RepoM.Api/Repositories/Persistence/RepositorySnapshotStore.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Evaluation of this argument may be expensive and unnecessary if logging is disabled

See more on https://sonarcloud.io/project/issues?id=RepoM&issues=AaBrnFmkECBKsn7UGnww&open=AaBrnFmkECBKsn7UGnww&pullRequest=230
}
}

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<RepositorySnapshotDto> 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<RemoteDto> Remotes { get; set; } = [];
}

private sealed class RemoteDto
{
public string Key { get; set; } = string.Empty;

public string Url { get; set; } = string.Empty;
}
}
8 changes: 8 additions & 0 deletions src/RepoM.App/Bootstrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -61,6 +63,12 @@ public static void RegisterServices(IFileSystem fileSystem, IAppDataPathProvider
Container.Register<IRepositoryScanner, GitRepositoryScanner>(Lifestyle.Singleton);
Container.Register<IRepositoryWatcher, FileSystemRepositoryWatcher>(Lifestyle.Singleton);
Container.Register<IRepositoryInfoReader, LibGit2SharpRepositoryInfoReader>(Lifestyle.Singleton);
Container.Register<IRepositorySnapshotStore>(
() => new RepositorySnapshotStore(
fileSystem,
new RepositorySnapshotStoreSettings(fileSystem.Path.Combine(appDataProvider.AppDataPath, "repositories.snapshot.yaml")),
Container.GetInstance<ILogger<RepositorySnapshotStore>>()),
Lifestyle.Singleton);
Container.Register<IFavoriteService>(() => Container.GetInstance<FavoriteService>(), Lifestyle.Singleton);
Container.Register<FavoriteService>(() => new FavoriteService(Container.GetInstance<IFavoriteStore>()), Lifestyle.Singleton);
Container.Register<IFavoriteStore, RepoM.Api.Favorite.FavoriteYamlStore>(Lifestyle.Singleton);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public interface IRepositorySnapshotStore
{
Task<IReadOnlyList<RepositoryInfo>> LoadAsync(CancellationToken ct = default);

Task SaveAsync(IEnumerable<RepositoryInfo> repositories, CancellationToken ct = default);
}
Original file line number Diff line number Diff line change
@@ -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; }
}
20 changes: 18 additions & 2 deletions src/RepoM.Core.Repositories/RepositoryMonitorService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,6 +31,7 @@ public sealed class RepositoryMonitorService : IModule, IDisposable
private readonly Func<IEnumerable<string>> _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;
Expand All @@ -48,6 +50,7 @@ public RepositoryMonitorService(
Func<IEnumerable<string>> pathProvider,
IRepositoryMonitoringService monitoringState,
IRepositoryMonitoringEvents monitoringEvents,
IRepositorySnapshotStore snapshotStore,
ILogger logger)
{
_scanner = scanner ?? throw new ArgumentNullException(nameof(scanner));
Expand All @@ -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);
}
Expand All @@ -66,12 +70,25 @@ public RepositoryMonitorService(

public IObservable<bool> IsScanning => _scanner.IsScanning;

public Task StartAsync()
public async Task StartAsync()
{
var ctNone = CancellationToken.None; // this method does not support cancellation
_logger.LogInformation("RepositoryMonitorService starting");

_subscriptions = new CompositeDisposable();

IReadOnlyList<RepositoryInfo> snapshot = await _snapshotStore.LoadAsync(ctNone).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.
Expand Down Expand Up @@ -148,7 +165,6 @@ public Task StartAsync()
_subscriptions.Add(stalenessSubscription);

_logger.LogInformation("RepositoryMonitorService started");
return Task.CompletedTask;
}

/// <summary>
Expand Down
Loading
Loading