From e6e2193856fbec32d2e01287017d7c24876fa483 Mon Sep 17 00:00:00 2001 From: vitek-karas <10670590+vitek-karas@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:19:49 +0100 Subject: [PATCH] Add configurable repo storage root and reclone workflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/ConnectionSettingsTests.cs | 18 ++- PolyPilot.Tests/RepoManagerStorageTests.cs | 112 +++++++++++++++++ PolyPilot/Components/Pages/Settings.razor | 111 ++++++++++++++++- PolyPilot/Models/ConnectionSettings.cs | 9 ++ PolyPilot/Models/RepositoryInfo.cs | 2 + PolyPilot/Services/RepoManager.cs | 137 +++++++++++++++++---- 6 files changed, 366 insertions(+), 23 deletions(-) create mode 100644 PolyPilot.Tests/RepoManagerStorageTests.cs diff --git a/PolyPilot.Tests/ConnectionSettingsTests.cs b/PolyPilot.Tests/ConnectionSettingsTests.cs index 0077eed025..32db20b9d4 100644 --- a/PolyPilot.Tests/ConnectionSettingsTests.cs +++ b/PolyPilot.Tests/ConnectionSettingsTests.cs @@ -142,6 +142,7 @@ public void DefaultValues_NewFields_AreCorrect() Assert.Null(settings.ServerPassword); Assert.False(settings.DirectSharingEnabled); Assert.Equal(CliSourceMode.BuiltIn, settings.CliSource); + Assert.Null(settings.RepositoryStorageRoot); } [Fact] @@ -154,7 +155,8 @@ public void Save_Load_RoundTrip_WithNewFields() Port = 4321, ServerPassword = "mypass", DirectSharingEnabled = true, - CliSource = CliSourceMode.System + CliSource = CliSourceMode.System, + RepositoryStorageRoot = "D:\\DevDrive\\PolyPilot" }; var json = JsonSerializer.Serialize(original); @@ -164,6 +166,7 @@ public void Save_Load_RoundTrip_WithNewFields() Assert.Equal("mypass", loaded!.ServerPassword); Assert.True(loaded.DirectSharingEnabled); Assert.Equal(CliSourceMode.System, loaded.CliSource); + Assert.Equal("D:\\DevDrive\\PolyPilot", loaded.RepositoryStorageRoot); } [Fact] @@ -178,6 +181,19 @@ public void BackwardCompatibility_OldJsonWithoutNewFields() Assert.Null(loaded.ServerPassword); Assert.False(loaded.DirectSharingEnabled); Assert.Equal(CliSourceMode.BuiltIn, loaded.CliSource); + Assert.Null(loaded.RepositoryStorageRoot); + } + + [Fact] + public void NormalizeRepositoryStorageRoot_Whitespace_ReturnsNull() + { + Assert.Null(ConnectionSettings.NormalizeRepositoryStorageRoot(" ")); + } + + [Fact] + public void NormalizeRepositoryStorageRoot_TrimmedPath_ReturnsTrimmed() + { + Assert.Equal("C:\\Dev", ConnectionSettings.NormalizeRepositoryStorageRoot(" C:\\Dev ")); } [Fact] diff --git a/PolyPilot.Tests/RepoManagerStorageTests.cs b/PolyPilot.Tests/RepoManagerStorageTests.cs new file mode 100644 index 0000000000..94236668ba --- /dev/null +++ b/PolyPilot.Tests/RepoManagerStorageTests.cs @@ -0,0 +1,112 @@ +using System.Reflection; +using System.Text.Json; +using PolyPilot.Models; +using PolyPilot.Services; + +namespace PolyPilot.Tests; + +public class RepoManagerStorageTests +{ + private static RepoManager CreateRepoManagerWithState(RepositoryState state) + { + var rm = new RepoManager(); + var stateField = typeof(RepoManager).GetField("_state", BindingFlags.NonPublic | BindingFlags.Instance)!; + var loadedField = typeof(RepoManager).GetField("_loaded", BindingFlags.NonPublic | BindingFlags.Instance)!; + stateField.SetValue(rm, state); + loadedField.SetValue(rm, true); + return rm; + } + + private static void InvokeBackfillWorktreeClonePaths(RepoManager rm, RepositoryInfo repo) + { + var method = typeof(RepoManager).GetMethod("BackfillWorktreeClonePaths", BindingFlags.NonPublic | BindingFlags.Instance)!; + method.Invoke(rm, new object[] { repo }); + } + + [Fact] + public void BackfillWorktreeClonePaths_FillsMissingPaths() + { + var repo = new RepositoryInfo { Id = "owner-repo", BareClonePath = "/old/repos/owner-repo.git" }; + var state = new RepositoryState + { + Repositories = new List { repo }, + Worktrees = new List + { + new() { Id = "wt1", RepoId = "owner-repo", Path = "/old/worktrees/owner-repo-a1b2c3d4" }, + new() { Id = "wt2", RepoId = "other-repo", Path = "/old/worktrees/other-repo-e5f6g7h8" } + } + }; + var rm = CreateRepoManagerWithState(state); + + InvokeBackfillWorktreeClonePaths(rm, repo); + + Assert.Equal("/old/repos/owner-repo.git", state.Worktrees[0].BareClonePath); + Assert.Null(state.Worktrees[1].BareClonePath); + } + + [Fact] + public void BackfillWorktreeClonePaths_DoesNotOverwriteExistingPath() + { + var repo = new RepositoryInfo { Id = "owner-repo", BareClonePath = "/new/repos/owner-repo.git" }; + var state = new RepositoryState + { + Repositories = new List { repo }, + Worktrees = new List + { + new() + { + Id = "wt1", + RepoId = "owner-repo", + Path = "/old/worktrees/owner-repo-a1b2c3d4", + BareClonePath = "/old/repos/owner-repo.git" + } + } + }; + var rm = CreateRepoManagerWithState(state); + + InvokeBackfillWorktreeClonePaths(rm, repo); + + Assert.Equal("/old/repos/owner-repo.git", state.Worktrees[0].BareClonePath); + } + + [Fact] + public void RepositoryState_WorktreeBareClonePath_RoundTripsJson() + { + var original = new RepositoryState + { + Repositories = new List + { + new() { Id = "owner-repo", BareClonePath = "/repos/owner-repo.git", Url = "https://github.com/owner/repo" } + }, + Worktrees = new List + { + new() { Id = "wt1", RepoId = "owner-repo", Path = "/worktrees/owner-repo-1234", BareClonePath = "/repos/owner-repo.git" } + } + }; + + var json = JsonSerializer.Serialize(original); + var roundTrip = JsonSerializer.Deserialize(json); + + Assert.NotNull(roundTrip); + Assert.Single(roundTrip!.Worktrees); + Assert.Equal("/repos/owner-repo.git", roundTrip.Worktrees[0].BareClonePath); + } + + [Fact] + public async Task RecloneAllRepositoriesToCurrentRootAsync_NoRepos_Completes() + { + var rm = CreateRepoManagerWithState(new RepositoryState()); + await rm.RecloneAllRepositoriesToCurrentRootAsync(); + Assert.Empty(rm.Repositories); + } + + [Fact] + public void PathsEqual_IgnoresTrailingSeparator() + { + var method = typeof(RepoManager).GetMethod("PathsEqual", BindingFlags.NonPublic | BindingFlags.Static)!; + var root = Path.Combine(Path.GetTempPath(), "polypilot-repo-root"); + var withTrailing = root + Path.DirectorySeparatorChar; + var equal = (bool)method.Invoke(null, new object[] { withTrailing, root })!; + Assert.True(equal); + } +} diff --git a/PolyPilot/Components/Pages/Settings.razor b/PolyPilot/Components/Pages/Settings.razor index e0f5416cc4..cb0594c3c4 100644 --- a/PolyPilot/Components/Pages/Settings.razor +++ b/PolyPilot/Components/Pages/Settings.razor @@ -9,6 +9,7 @@ @inject QrScannerService QrScanner @inject GitAutoUpdateService GitAutoUpdate @inject FiestaService FiestaService +@inject RepoManager RepoManager @inject NavigationManager Nav @inject IJSRuntime JS @inject IServiceProvider ServiceProvider @@ -354,6 +355,40 @@ } + @if (PlatformHelper.IsDesktop) + { +
+

Repository Storage

+

Root folder used for managed bare clones and worktrees.

+
+ + +
+
+ + +
+ @if (RepositoryStorageRootChanged) + { +
+ Existing sessions/worktrees stay where they are. New worktrees use the new root after save. +
+ @if (RepoManager.Repositories.Count > 0) + { +
+ + @if (!string.IsNullOrWhiteSpace(recloneProgress)) + { + @recloneProgress + } +
+ } + } +
+ } +