From 879d9c1c3fd367efbb3f80fab4e8f261bdac0c68 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 02:22:03 +0000 Subject: [PATCH 1/2] Initial plan From bbbef9b02101acacc77b1fc13060abf2afbd6650 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 02:30:34 +0000 Subject: [PATCH 2/2] feat: allow changing location of cloned repositories - Add `RepoStorageDir` property to `ConnectionSettings` - Add `SetCustomStorageDir()` and `GetEffectiveStorageDir()` to `RepoManager` - Apply custom storage dir at app startup in `MauiProgram.cs` - Add 'Repositories' settings group in Settings.razor with path input - Add `.repo-storage-row` CSS style - Add 6 new tests for the new behavior Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- PolyPilot.Tests/ConnectionSettingsTests.cs | 25 ++++++++ PolyPilot.Tests/RepoManagerTests.cs | 60 +++++++++++++++++++ PolyPilot/Components/Pages/Settings.razor | 46 ++++++++++++++ PolyPilot/Components/Pages/Settings.razor.css | 7 +++ PolyPilot/MauiProgram.cs | 8 ++- PolyPilot/Models/ConnectionSettings.cs | 5 ++ PolyPilot/Services/RepoManager.cs | 55 ++++++++++++++++- 7 files changed, 203 insertions(+), 3 deletions(-) diff --git a/PolyPilot.Tests/ConnectionSettingsTests.cs b/PolyPilot.Tests/ConnectionSettingsTests.cs index 9df2ed20c4..fe4d933dcc 100644 --- a/PolyPilot.Tests/ConnectionSettingsTests.cs +++ b/PolyPilot.Tests/ConnectionSettingsTests.cs @@ -391,6 +391,31 @@ public void NormalizeRemoteUrl_DoesNotDoubleScheme() Assert.Equal("http://http://example.com", result); } + [Fact] + public void RepoStorageDir_DefaultsToNull() + { + var settings = new ConnectionSettings(); + Assert.Null(settings.RepoStorageDir); + } + + [Fact] + public void RepoStorageDir_RoundTripsViaSerialization() + { + var original = new ConnectionSettings { RepoStorageDir = @"D:\code\polypilot" }; + var json = System.Text.Json.JsonSerializer.Serialize(original); + var loaded = System.Text.Json.JsonSerializer.Deserialize(json)!; + Assert.Equal(@"D:\code\polypilot", loaded.RepoStorageDir); + } + + [Fact] + public void RepoStorageDir_NullRoundTripsViaSerialization() + { + var original = new ConnectionSettings { RepoStorageDir = null }; + var json = System.Text.Json.JsonSerializer.Serialize(original); + var loaded = System.Text.Json.JsonSerializer.Deserialize(json)!; + Assert.Null(loaded.RepoStorageDir); + } + private void Dispose() { try { Directory.Delete(_testDir, true); } catch { } diff --git a/PolyPilot.Tests/RepoManagerTests.cs b/PolyPilot.Tests/RepoManagerTests.cs index 22816ebdae..8da53ffc4d 100644 --- a/PolyPilot.Tests/RepoManagerTests.cs +++ b/PolyPilot.Tests/RepoManagerTests.cs @@ -366,4 +366,64 @@ public void Load_WithCorruptedState_HealsFromDisk() } #endregion + + #region Custom Storage Dir Tests + + [Fact] + public void SetCustomStorageDir_ChangesReposDirAndWorktreesDir() + { + var customDir = Path.Combine(Path.GetTempPath(), $"custom-storage-{Guid.NewGuid():N}"); + Directory.CreateDirectory(customDir); + try + { + RepoManager.SetCustomStorageDir(customDir); + try + { + var effectiveDir = RepoManager.GetEffectiveStorageDir(); + Assert.Equal(customDir, effectiveDir); + } + finally + { + // Restore test isolation + RepoManager.SetBaseDirForTesting(TestSetup.TestBaseDir); + } + } + finally + { + Directory.Delete(customDir, true); + } + } + + [Fact] + public void SetCustomStorageDir_Null_FallsBackToDefault() + { + RepoManager.SetCustomStorageDir(null); + try + { + var effectiveDir = RepoManager.GetEffectiveStorageDir(); + // Should be the test base dir (set by SetBaseDirForTesting in TestSetup) + Assert.Equal(TestSetup.TestBaseDir, effectiveDir); + } + finally + { + RepoManager.SetBaseDirForTesting(TestSetup.TestBaseDir); + } + } + + [Fact] + public void SetCustomStorageDir_WhitespaceString_TreatedAsNull() + { + RepoManager.SetCustomStorageDir(" "); + try + { + var effectiveDir = RepoManager.GetEffectiveStorageDir(); + Assert.Equal(TestSetup.TestBaseDir, effectiveDir); + } + finally + { + RepoManager.SetBaseDirForTesting(TestSetup.TestBaseDir); + } + } + + #endregion } diff --git a/PolyPilot/Components/Pages/Settings.razor b/PolyPilot/Components/Pages/Settings.razor index 95d061a0e2..7bfad87550 100644 --- a/PolyPilot/Components/Pages/Settings.razor +++ b/PolyPilot/Components/Pages/Settings.razor @@ -10,6 +10,7 @@ @inject GitAutoUpdateService GitAutoUpdate @inject FiestaService FiestaService @inject UsageStatsService UsageStats +@inject RepoManager RepoManager @inject NavigationManager Nav @inject IJSRuntime JS @inject IServiceProvider ServiceProvider @@ -413,6 +414,30 @@ } + @if (PlatformHelper.IsDesktop) + { +
+

Repositories

+ +
+

Storage Location

+

Directory where bare repo clones and worktrees are stored. Leave blank to use the default ~/.polypilot location. Newly cloned repositories will use this location; existing repos keep their current paths.

+
+ + +
+ @if (!string.IsNullOrEmpty(settings.RepoStorageDir)) + { +
📁 @settings.RepoStorageDir
+ } +
+
+ } +

UI

@@ -654,6 +679,7 @@ 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"), + "repositories" => SectionVisible("repo storage location clone directory path devdrive"), "ui" => SectionVisible("chat message layout default reversed both left theme font size text zoom"), "statistics" => SectionVisible("statistics usage stats sessions time code"), "developer" => SectionVisible("auto update main git watch relaunch rebuild"), @@ -1293,4 +1319,24 @@ return $"{seconds / 3600}h {(seconds % 3600) / 60}m"; return $"{seconds / 86400}d {(seconds % 86400) / 3600}h"; } + + private async Task SaveRepoStorageDir() + { + var raw = await JS.InvokeAsync("eval", "document.getElementById('repo-storage-dir')?.value ?? ''"); + var path = string.IsNullOrWhiteSpace(raw) ? null : raw.Trim(); + if (path != null && !Directory.Exists(path)) + { + try { Directory.CreateDirectory(path); } + catch (Exception ex) + { + ShowStatus($"Cannot create directory: {path} ({ex.Message})", "error", 5000); + return; + } + } + settings.RepoStorageDir = path; + settings.Save(); + RepoManager.SetCustomStorageDir(path); + ShowStatus("Repository storage location saved.", "success"); + StateHasChanged(); + } } diff --git a/PolyPilot/Components/Pages/Settings.razor.css b/PolyPilot/Components/Pages/Settings.razor.css index c8999e90ce..2f4156c371 100644 --- a/PolyPilot/Components/Pages/Settings.razor.css +++ b/PolyPilot/Components/Pages/Settings.razor.css @@ -815,6 +815,13 @@ border-top: 1px solid var(--control-border); } +.repo-storage-row { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + .toggle-label { display: flex; align-items: center; diff --git a/PolyPilot/MauiProgram.cs b/PolyPilot/MauiProgram.cs index 2274eb1aae..f14401aa54 100644 --- a/PolyPilot/MauiProgram.cs +++ b/PolyPilot/MauiProgram.cs @@ -1,4 +1,5 @@ using PolyPilot.Services; +using PolyPilot.Models; using Microsoft.Extensions.Logging; using ZXing.Net.Maui.Controls; using MauiDevFlow.Agent; @@ -113,7 +114,12 @@ public static MauiApp CreateMauiApp() builder.AddMauiBlazorDevFlowTools(); #endif - return builder.Build(); + var app = builder.Build(); + // Apply custom repo storage directory from settings before any RepoManager access. + // Failure is intentionally swallowed — a bad path won't prevent the app from starting; + // RepoManager will fall back to the default ~/.polypilot location. + try { RepoManager.SetCustomStorageDir(ConnectionSettings.Load().RepoStorageDir); } catch { } + return app; } private static void LogException(string source, Exception? ex) diff --git a/PolyPilot/Models/ConnectionSettings.cs b/PolyPilot/Models/ConnectionSettings.cs index 4dfda1cb3a..d14e0326bc 100644 --- a/PolyPilot/Models/ConnectionSettings.cs +++ b/PolyPilot/Models/ConnectionSettings.cs @@ -61,6 +61,11 @@ public class ConnectionSettings public List DisabledMcpServers { get; set; } = new(); public List DisabledPlugins { get; set; } = new(); public bool EnableSessionNotifications { get; set; } = false; + /// + /// Custom directory for storing bare repo clones and worktrees. + /// When null, uses the default ~/.polypilot location. + /// + public string? RepoStorageDir { get; set; } /// /// Normalizes a remote URL by ensuring it has an http(s):// scheme. diff --git a/PolyPilot/Services/RepoManager.cs b/PolyPilot/Services/RepoManager.cs index 6126c94d31..ea8276f3b9 100644 --- a/PolyPilot/Services/RepoManager.cs +++ b/PolyPilot/Services/RepoManager.cs @@ -11,6 +11,7 @@ namespace PolyPilot.Services; public class RepoManager { private static string? _baseDirOverride; + private static string? _customStorageDir; private static readonly object _pathLock = new(); private static string? _reposDir; private static string ReposDir { get { lock (_pathLock) return _reposDir ??= GetReposDir(); } } @@ -28,12 +29,51 @@ internal static void SetBaseDirForTesting(string? path) lock (_pathLock) { _baseDirOverride = path; + _customStorageDir = null; _reposDir = null; _worktreesDir = null; _stateFile = null; } } + /// + /// Set a custom directory for storing bare repo clones and worktrees. + /// When non-null, repos go to <customDir>/repos/ and worktrees to <customDir>/worktrees/. + /// The state file (repos.json) remains in the default ~/.polypilot location. + /// + public static void SetCustomStorageDir(string? path) + { + lock (_pathLock) + { + _customStorageDir = string.IsNullOrWhiteSpace(path) ? null : path.Trim(); + _reposDir = null; + _worktreesDir = null; + } + } + + /// + /// Returns the effective directory used for repos and worktrees. + /// + public static string GetEffectiveStorageDir() + { + lock (_pathLock) + { + if (_customStorageDir != null) return _customStorageDir; + if (_baseDirOverride != null) return _baseDirOverride; + try + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(home)) + home = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(home, ".polypilot"); + } + catch + { + return Path.Combine(Path.GetTempPath(), ".polypilot"); + } + } + } + private RepositoryState _state = new(); private bool _loaded; private bool _loadedSuccessfully; @@ -66,8 +106,19 @@ private static string GetBaseDir() } } - private static string GetReposDir() => Path.Combine(GetBaseDir(), "repos"); - private static string GetWorktreesDir() => Path.Combine(GetBaseDir(), "worktrees"); + private static string GetReposDir() + { + // _customStorageDir overrides where repos are stored (but not the state file) + if (_customStorageDir != null) return Path.Combine(_customStorageDir, "repos"); + return Path.Combine(GetBaseDir(), "repos"); + } + + private static string GetWorktreesDir() + { + if (_customStorageDir != null) return Path.Combine(_customStorageDir, "worktrees"); + return Path.Combine(GetBaseDir(), "worktrees"); + } + private static string GetStateFile() => Path.Combine(GetBaseDir(), "repos.json"); public void Load()