From 5c35b15116bc946f8ccf120b6908332dcc917fbd 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 1/5] 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 | 130 +++++++++++++++++ PolyPilot/Components/Pages/Settings.razor | 111 ++++++++++++++- PolyPilot/Models/ConnectionSettings.cs | 9 ++ PolyPilot/Models/RepositoryInfo.cs | 2 + PolyPilot/Services/RepoManager.cs | 154 +++++++++++++++++---- 6 files changed, 398 insertions(+), 26 deletions(-) create mode 100644 PolyPilot.Tests/RepoManagerStorageTests.cs diff --git a/PolyPilot.Tests/ConnectionSettingsTests.cs b/PolyPilot.Tests/ConnectionSettingsTests.cs index ffab769199..ddc1e517f6 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..2c25a8df0a --- /dev/null +++ b/PolyPilot.Tests/RepoManagerStorageTests.cs @@ -0,0 +1,130 @@ +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 async Task RecloneAllRepositoriesToCurrentRootAsync_NullBareClonePath_DoesNotThrow() + { + // Repos with null/empty BareClonePath should not crash EnsureRepoCloneInCurrentRootAsync. + // They'll attempt a fresh clone (which will fail without network), but the error is caught + // and remaining repos continue processing. + var repoWithNullPath = new RepositoryInfo { Id = "owner-repo", BareClonePath = null!, Url = "https://github.com/owner/repo" }; + var state = new RepositoryState { Repositories = new List { repoWithNullPath } }; + var rm = CreateRepoManagerWithState(state); + var progressMessages = new List(); + + // Should not throw ArgumentNullException even with null BareClonePath + await rm.RecloneAllRepositoriesToCurrentRootAsync(msg => progressMessages.Add(msg)); + + // A warning progress message is emitted when the clone fails (network unavailable in tests) + Assert.Contains(progressMessages, m => m.StartsWith("⚠") || m.Contains("owner-repo") || m.Contains("[1/1]")); + } + + [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 730f9f3a98..5ef491fad5 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 @@ -395,6 +396,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 + } +
+ } + } +
+ } +
} - @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 - } -
- } - } -
- } -
} - @{ var cliSettings = SettingsRegistry.ForCategory("Copilot CLI", settingsCtx).ToList(); } - @if (cliSettings.Any()) - { -
-

Copilot CLI

+ @{ var uiSettings = SettingsRegistry.ForCategory("UI", settingsCtx).ToList(); } +
+

UI

- @foreach (var desc in cliSettings) + @foreach (var desc in uiSettings) { @if (SettingMatchesSearch(desc)) { } } - @if (cliSourceChanged) - { -
⟳ Restart the app to apply the change
- }
- } - @{ var uiSettings = SettingsRegistry.ForCategory("UI", settingsCtx).ToList(); } -
-

UI

+ @{ var devSettings = SettingsRegistry.ForCategory("Developer", settingsCtx).ToList(); } +
+

Developer

+ + @if (devSettings.Any()) + {
- @foreach (var desc in uiSettings) + @foreach (var desc in devSettings) { @if (SettingMatchesSearch(desc)) { } } + @if (cliSourceChanged) + { +
⟳ Restart the app to apply the change
+ }
-
- - @if (GitAutoUpdate.IsAvailable) - { -
-

Developer

+ } + @if (GitAutoUpdate.IsAvailable) + {

Auto-Update from Main

Watches origin/main for new commits every 30s. When changes are detected, automatically pulls, rebuilds, and relaunches.

@@ -496,8 +459,42 @@ }
+ } + + @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 + } +
+ } + } +
+ }
- } @if (PlatformHelper.IsDesktop) { @@ -654,9 +651,9 @@ // Also check all section keywords within the group return groupKeyword switch { - "connection" => SectionVisible("transport mode embedded persistent remote server port start stop pid devtunnel share tunnel mobile qr url token connect save reconnect repository repo clone worktree storage root directory"), + "connection" => SectionVisible("transport mode embedded persistent remote server port start stop pid devtunnel share tunnel mobile qr url token connect save reconnect"), "ui" => SectionVisible("chat message layout default reversed both left theme font size text zoom"), - "developer" => SectionVisible("auto update main git watch relaunch rebuild"), + "developer" => SectionVisible("auto update main git watch relaunch rebuild cli source built-in system repository repo clone worktree storage root directory dev drive"), "plugins" => SectionVisible("plugins provider extension dll assembly trust enable disable"), _ => true }; diff --git a/PolyPilot/Services/SettingsRegistry.cs b/PolyPilot/Services/SettingsRegistry.cs index 6c5039d31d..711216e8a1 100644 --- a/PolyPilot/Services/SettingsRegistry.cs +++ b/PolyPilot/Services/SettingsRegistry.cs @@ -135,32 +135,6 @@ private static List Build() SearchKeywords = "save reconnect apply restart", }); - // ── Copilot CLI ───────────────────────────────────────────── - - list.Add(new SettingDescriptor - { - Id = "cli.source", - Label = "CLI Source", - Description = "Use the CLI bundled with the app or one installed on your system.", - Category = "Copilot CLI", - Type = SettingType.CardEnum, - Order = 10, - SearchKeywords = "cli source built-in system version binary copilot", - Options = new[] - { - new SettingOption("BuiltIn", "📦 Built-in"), - new SettingOption("System", "💻 System"), - }, - GetValue = ctx => ctx.Settings.CliSource.ToString(), - SetValue = (ctx, v) => - { - if (v is string s && Enum.TryParse(s, out var src)) - ctx.Settings.CliSource = src; - }, - IsVisible = ctx => ctx.Settings.Mode != ConnectionMode.Remote - && ctx.Settings.Mode != ConnectionMode.Demo - }); - // ── UI ────────────────────────────────────────────────────── list.Add(new SettingDescriptor @@ -373,6 +347,30 @@ private static List Build() // ── Developer ─────────────────────────────────────────────── + list.Add(new SettingDescriptor + { + Id = "cli.source", + Label = "CLI Source", + Description = "Use the CLI bundled with the app or one installed on your system.", + Category = "Developer", + Type = SettingType.CardEnum, + Order = 5, + SearchKeywords = "cli source built-in system version binary copilot", + Options = new[] + { + new SettingOption("BuiltIn", "📦 Built-in"), + new SettingOption("System", "💻 System"), + }, + GetValue = ctx => ctx.Settings.CliSource.ToString(), + SetValue = (ctx, v) => + { + if (v is string s && Enum.TryParse(s, out var src)) + ctx.Settings.CliSource = src; + }, + IsVisible = ctx => ctx.Settings.Mode != ConnectionMode.Remote + && ctx.Settings.Mode != ConnectionMode.Demo + }); + list.Add(new SettingDescriptor { Id = "developer.autoUpdate", From cb4f45e3855d85d5e38605b1e97e733c8d177dde Mon Sep 17 00:00:00 2001 From: "Shane Neuville (HE/HIM)" Date: Sun, 8 Mar 2026 22:43:49 -0500 Subject: [PATCH 3/5] fix: show Developer nav when storage settings available, add GTK FolderPicker guard - Developer nav button now shows when GitAutoUpdate.IsAvailable OR PlatformHelper.IsDesktop - BrowseRepositoryStorageRoot() wrapped in #if MACCATALYST || WINDOWS for GTK compat Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Components/Pages/Settings.razor | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/PolyPilot/Components/Pages/Settings.razor b/PolyPilot/Components/Pages/Settings.razor index 96957aada2..55947589cc 100644 --- a/PolyPilot/Components/Pages/Settings.razor +++ b/PolyPilot/Components/Pages/Settings.razor @@ -55,7 +55,7 @@ UI - @if (GitAutoUpdate.IsAvailable) + @if (GitAutoUpdate.IsAvailable || PlatformHelper.IsDesktop) { - +
+ +
+ +
+ + +
+
@if (RepositoryStorageRootChanged) { @@ -996,7 +998,7 @@ private void UseDefaultRepositoryStorageRoot() { - settings.RepositoryStorageRoot = null; + settings.RepositoryStorageRoot = ""; } private async Task RecloneAllRegisteredRepos() diff --git a/PolyPilot/Components/Pages/Settings.razor.css b/PolyPilot/Components/Pages/Settings.razor.css index 04a6723b53..a1409234a6 100644 --- a/PolyPilot/Components/Pages/Settings.razor.css +++ b/PolyPilot/Components/Pages/Settings.razor.css @@ -345,6 +345,12 @@ border-color: var(--accent-primary); } +.form-input::placeholder { + color: var(--text-secondary, #888); + opacity: 0.55; + font-style: italic; +} + .server-status { display: flex; align-items: center; @@ -814,6 +820,35 @@ white-space: nowrap; } +.settings-field { + display: grid; + grid-template-columns: minmax(120px, max-content) minmax(0, 1fr); + gap: 0.75rem 1rem; + align-items: start; + margin-top: 0.75rem; +} + +.settings-field > label { + color: var(--text-on-surface); + font-size: var(--type-body); + line-height: 1.4; + padding-top: 0.45rem; +} + +.settings-field-body { + display: flex; + flex-direction: column; + gap: 0.75rem; + min-width: 0; +} + +.settings-field-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.25rem; +} + .form-input.wide { flex: 1; max-width: none; @@ -1084,6 +1119,16 @@ font-size: var(--type-callout); } + .settings-field { + grid-template-columns: 1fr; + gap: 0.35rem; + } + + .settings-field > label { + font-size: var(--type-callout); + padding-top: 0; + } + .form-input { font-size: var(--type-callout); padding: 0.35rem 0.5rem; diff --git a/PolyPilot/Services/RepoManager.cs b/PolyPilot/Services/RepoManager.cs index 69f921b08a..c1b55646db 100644 --- a/PolyPilot/Services/RepoManager.cs +++ b/PolyPilot/Services/RepoManager.cs @@ -328,6 +328,11 @@ public static string NormalizeRepoUrl(string input) public string GetEffectiveStorageRoot() => GetStorageRootDir(); + /// + /// Returns the default storage root (ignoring any user-configured override). + /// + public static string GetDefaultStorageRoot() => GetBaseDir(); + private string GetDesiredBareClonePath(string repoId) => Path.Combine(ReposDir, $"{repoId}.git"); private static bool PathsEqual(string left, string right) From 6ae0cf9e78aae2f470a1d295e149d1461662492c Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Mon, 9 Mar 2026 22:41:01 -0500 Subject: [PATCH 5/5] fix: cache storage root, add thread safety for repo state mutations - Cache GetStorageRootDir() result to avoid ConnectionSettings.Load() disk I/O on every ReposDir/WorktreesDir access (was ~10 calls per operation) - Add InvalidateStorageRoot() for settings changes in Settings.razor - Add _stateLock protection around BareClonePath mutations and Save() in EnsureRepoCloneInCurrentRootAsync - Wrap BackfillWorktreeClonePaths iteration with _stateLock - Clear _storageRoot in SetBaseDirForTesting for test isolation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Components/Pages/Settings.razor | 2 ++ PolyPilot/Services/RepoManager.cs | 32 ++++++++++++++++++----- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/PolyPilot/Components/Pages/Settings.razor b/PolyPilot/Components/Pages/Settings.razor index c6831aa75b..af3cc98338 100644 --- a/PolyPilot/Components/Pages/Settings.razor +++ b/PolyPilot/Components/Pages/Settings.razor @@ -1011,6 +1011,7 @@ } settings.Save(); + RepoManager.InvalidateStorageRoot(); recloneBusy = true; recloneProgress = null; StateHasChanged(); @@ -1382,6 +1383,7 @@ settings.RemoteUrl = ConnectionSettings.NormalizeRemoteUrl(settings.RemoteUrl); settings.Save(); + RepoManager.InvalidateStorageRoot(); ShowStatus("Settings saved. Reconnecting...", "", 0); try diff --git a/PolyPilot/Services/RepoManager.cs b/PolyPilot/Services/RepoManager.cs index 069e05f1df..81624839a0 100644 --- a/PolyPilot/Services/RepoManager.cs +++ b/PolyPilot/Services/RepoManager.cs @@ -15,9 +15,10 @@ public class RepoManager private static string? _baseDirOverride; private static readonly object _pathLock = new(); private static string? _stateFile; + private static string? _storageRoot; private static string StateFile { get { lock (_pathLock) return _stateFile ??= GetStateFile(); } } - private string ReposDir => Path.Combine(GetStorageRootDir(), "repos"); - private string WorktreesDir => Path.Combine(GetStorageRootDir(), "worktrees"); + private string ReposDir => Path.Combine(GetCachedStorageRoot(), "repos"); + private string WorktreesDir => Path.Combine(GetCachedStorageRoot(), "worktrees"); /// /// Redirect all RepoManager paths to a test directory. @@ -29,6 +30,7 @@ internal static void SetBaseDirForTesting(string? path) { _baseDirOverride = path; _stateFile = null; + _storageRoot = null; } } @@ -87,6 +89,20 @@ private static string GetStorageRootDir() return GetBaseDir(); } + private static string GetCachedStorageRoot() + { + lock (_pathLock) + return _storageRoot ??= GetStorageRootDir(); + } + + /// + /// Call when settings change (e.g., RepositoryStorageRoot) to pick up the new value. + /// + public static void InvalidateStorageRoot() + { + lock (_pathLock) _storageRoot = null; + } + public void Load() { _loaded = true; @@ -326,7 +342,7 @@ public static string NormalizeRepoUrl(string input) return input; } - public string GetEffectiveStorageRoot() => GetStorageRootDir(); + public string GetEffectiveStorageRoot() => GetCachedStorageRoot(); /// /// Returns the default storage root (ignoring any user-configured override). @@ -358,7 +374,7 @@ private async Task EnsureRepoCloneInCurrentRootAsync(RepositoryInfo repo, Action && Directory.Exists(targetBarePath)) return; - BackfillWorktreeClonePaths(repo); + lock (_stateLock) BackfillWorktreeClonePaths(repo); Directory.CreateDirectory(ReposDir); if (Directory.Exists(targetBarePath)) @@ -381,8 +397,12 @@ private async Task EnsureRepoCloneInCurrentRootAsync(RepositoryInfo repo, Action try { await RunGitAsync(targetBarePath, ct, "config", "core.longpaths", "true"); } catch { } } - repo.BareClonePath = targetBarePath; - Save(); + lock (_stateLock) + { + repo.BareClonePath = targetBarePath; + BackfillWorktreeClonePaths(repo); + Save(); + } OnStateChanged?.Invoke(); }