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..af3cc98338 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 @@ -54,7 +55,7 @@ UI - @if (GitAutoUpdate.IsAvailable) + @if (GitAutoUpdate.IsAvailable || PlatformHelper.IsDesktop) { + + + + + @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) { @@ -556,6 +611,8 @@ private bool showToken; private bool showQrCode; private bool showDirectQrCode; + private bool recloneBusy; + private string? recloneProgress; private string? qrCodeDataUri; private string? directQrCodeDataUri; private List localIps = new(); @@ -566,6 +623,7 @@ private CliSourceMode _initialCliSource; private ConnectionMode _initialMode; private bool cliSourceChanged; + private string _initialRepositoryStorageRoot = ""; private int fontSize = 20; private string fiestaLinkName = ""; private string fiestaLinkUrl = ""; @@ -618,7 +676,7 @@ { "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 }; @@ -712,8 +770,10 @@ protected override void OnInitialized() { settings = ConnectionSettings.Load(); + settings.RepositoryStorageRoot = ConnectionSettings.NormalizeRepositoryStorageRoot(settings.RepositoryStorageRoot); _initialCliSource = settings.CliSource; _initialMode = settings.Mode; + _initialRepositoryStorageRoot = NormalizePathForCompare(settings.RepositoryStorageRoot); DevTunnelService.OnStateChanged += OnTunnelStateChanged; GitAutoUpdate.OnStateChanged += OnAutoUpdateStateChanged; FiestaService.OnStateChanged += OnFiestaStateChanged; @@ -911,6 +971,72 @@ settings.Save(); } + private static string NormalizePathForCompare(string? path) + { + var normalized = ConnectionSettings.NormalizeRepositoryStorageRoot(path); + if (string.IsNullOrWhiteSpace(normalized)) + return ""; + return normalized.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + + private bool RepositoryStorageRootChanged => + !string.Equals(NormalizePathForCompare(settings.RepositoryStorageRoot), _initialRepositoryStorageRoot, StringComparison.OrdinalIgnoreCase); + + private static bool IsStorageRootValid(string? path) => + string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path.Trim()); + + private async Task BrowseRepositoryStorageRoot() + { +#if MACCATALYST || WINDOWS + var dir = await FolderPickerService.PickFolderAsync(); + if (!string.IsNullOrWhiteSpace(dir)) + settings.RepositoryStorageRoot = dir; +#else + await Task.CompletedTask; +#endif + } + + private void UseDefaultRepositoryStorageRoot() + { + settings.RepositoryStorageRoot = ""; + } + + private async Task RecloneAllRegisteredRepos() + { + settings.RepositoryStorageRoot = ConnectionSettings.NormalizeRepositoryStorageRoot(settings.RepositoryStorageRoot); + if (!IsStorageRootValid(settings.RepositoryStorageRoot)) + { + ShowStatus("Repository storage root must be an absolute path", "error", 5000); + return; + } + + settings.Save(); + RepoManager.InvalidateStorageRoot(); + recloneBusy = true; + recloneProgress = null; + StateHasChanged(); + + try + { + await RepoManager.RecloneAllRepositoriesToCurrentRootAsync(progress => + { + recloneProgress = progress; + _ = InvokeAsync(StateHasChanged); + }); + _initialRepositoryStorageRoot = NormalizePathForCompare(settings.RepositoryStorageRoot); + ShowStatus("Repositories re-cloned to new root", "success", 3500); + } + catch (Exception ex) + { + ShowStatus($"Re-clone failed: {Models.ErrorMessageHelper.Humanize(ex)}", "error", 8000); + } + finally + { + recloneBusy = false; + StateHasChanged(); + } + } + private static string ShortenPath(string path) { // Show just the last 2-3 segments for readability @@ -1233,6 +1359,13 @@ private async Task SaveAndApply() { + settings.RepositoryStorageRoot = ConnectionSettings.NormalizeRepositoryStorageRoot(settings.RepositoryStorageRoot); + if (!IsStorageRootValid(settings.RepositoryStorageRoot)) + { + ShowStatus("Repository storage root must be an absolute path", "error", 5000); + return; + } + if (settings.Mode == ConnectionMode.Persistent && !serverAlive) { ShowStatus("Start the persistent server first", "error", 5000); @@ -1250,6 +1383,7 @@ settings.RemoteUrl = ConnectionSettings.NormalizeRemoteUrl(settings.RemoteUrl); settings.Save(); + RepoManager.InvalidateStorageRoot(); ShowStatus("Settings saved. Reconnecting...", "", 0); try @@ -1257,6 +1391,7 @@ await CopilotService.ReconnectAsync(settings); _initialMode = settings.Mode; _initialCliSource = settings.CliSource; + _initialRepositoryStorageRoot = NormalizePathForCompare(settings.RepositoryStorageRoot); cliSourceChanged = false; ShowStatus("Connected!", "success"); Nav.NavigateTo("/"); 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/Models/ConnectionSettings.cs b/PolyPilot/Models/ConnectionSettings.cs index cd6d89d9ae..e9a5e995f2 100644 --- a/PolyPilot/Models/ConnectionSettings.cs +++ b/PolyPilot/Models/ConnectionSettings.cs @@ -82,6 +82,7 @@ public class ConnectionSettings public bool AutoUpdateFromMain { get; set; } = false; public CliSourceMode CliSource { get; set; } = CliSourceMode.BuiltIn; public VsCodeVariant Editor { get; set; } = VsCodeVariant.Stable; + public string? RepositoryStorageRoot { get; set; } public List DisabledMcpServers { get; set; } = new(); public List DisabledPlugins { get; set; } = new(); public PluginSettings Plugins { get; set; } = new(); @@ -169,6 +170,7 @@ public static ConnectionSettings Load() // Ensure loaded mode is valid for this platform if (!PlatformHelper.AvailableModes.Contains(settings.Mode)) settings.Mode = PlatformHelper.DefaultMode; + settings.RepositoryStorageRoot = NormalizeRepositoryStorageRoot(settings.RepositoryStorageRoot); NormalizeEnumFields(settings); @@ -179,6 +181,13 @@ public static ConnectionSettings Load() return settings; } + public static string? NormalizeRepositoryStorageRoot(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + return null; + return path.Trim(); + } + /// Normalize invalid enum values to safe defaults. Testable separately from Load(). internal static void NormalizeEnumFields(ConnectionSettings settings) { diff --git a/PolyPilot/Models/RepositoryInfo.cs b/PolyPilot/Models/RepositoryInfo.cs index 4ec18cb8fc..14bc0e4aeb 100644 --- a/PolyPilot/Models/RepositoryInfo.cs +++ b/PolyPilot/Models/RepositoryInfo.cs @@ -24,6 +24,8 @@ public class WorktreeInfo public string RepoId { get; set; } = ""; public string Branch { get; set; } = ""; public string Path { get; set; } = ""; + /// Path to the bare clone backing this worktree. + public string? BareClonePath { get; set; } /// Session name using this worktree as CWD, if any. public string? SessionName { get; set; } /// GitHub PR number if this worktree was created from a PR. diff --git a/PolyPilot/Services/RepoManager.cs b/PolyPilot/Services/RepoManager.cs index fa8c143984..81624839a0 100644 --- a/PolyPilot/Services/RepoManager.cs +++ b/PolyPilot/Services/RepoManager.cs @@ -7,18 +7,18 @@ namespace PolyPilot.Services; /// /// Manages bare git clones and worktrees for repository-centric sessions. -/// Repos live at ~/.polypilot/repos/.git, worktrees at ~/.polypilot/worktrees//. +/// Clone and worktree roots are configurable via ConnectionSettings.RepositoryStorageRoot. +/// Metadata state remains in ~/.polypilot/repos.json. /// public class RepoManager { private static string? _baseDirOverride; private static readonly object _pathLock = new(); - private static string? _reposDir; - private static string ReposDir { get { lock (_pathLock) return _reposDir ??= GetReposDir(); } } - private static string? _worktreesDir; - private static string WorktreesDir { get { lock (_pathLock) return _worktreesDir ??= GetWorktreesDir(); } } private static string? _stateFile; + private static string? _storageRoot; private static string StateFile { get { lock (_pathLock) return _stateFile ??= GetStateFile(); } } + private string ReposDir => Path.Combine(GetCachedStorageRoot(), "repos"); + private string WorktreesDir => Path.Combine(GetCachedStorageRoot(), "worktrees"); /// /// Redirect all RepoManager paths to a test directory. @@ -29,9 +29,8 @@ internal static void SetBaseDirForTesting(string? path) lock (_pathLock) { _baseDirOverride = path; - _reposDir = null; - _worktreesDir = null; _stateFile = null; + _storageRoot = null; } } @@ -72,10 +71,38 @@ private static string GetBaseDir() } } - private static string GetReposDir() => Path.Combine(GetBaseDir(), "repos"); - private static string GetWorktreesDir() => Path.Combine(GetBaseDir(), "worktrees"); private static string GetStateFile() => Path.Combine(GetBaseDir(), "repos.json"); + private static string GetStorageRootDir() + { + // Respect test isolation: if a base dir override is active, skip settings. + if (_baseDirOverride != null) + return _baseDirOverride; + try + { + var settings = ConnectionSettings.Load(); + var configuredRoot = ConnectionSettings.NormalizeRepositoryStorageRoot(settings.RepositoryStorageRoot); + if (!string.IsNullOrEmpty(configuredRoot) && Path.IsPathRooted(configuredRoot)) + return configuredRoot; + } + catch { } + 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; @@ -88,6 +115,19 @@ public void Load() var json = File.ReadAllText(stateFile); _state = JsonSerializer.Deserialize(json) ?? new RepositoryState(); } + var repoPathsById = _state.Repositories + .Where(r => !string.IsNullOrWhiteSpace(r.BareClonePath)) + .ToDictionary(r => r.Id, r => r.BareClonePath, StringComparer.OrdinalIgnoreCase); + var changed = false; + foreach (var wt in _state.Worktrees.Where(w => string.IsNullOrWhiteSpace(w.BareClonePath))) + { + if (repoPathsById.TryGetValue(wt.RepoId, out var bareClonePath)) + { + wt.BareClonePath = bareClonePath; + changed = true; + } + } + if (changed) Save(); _loadedSuccessfully = true; } catch (Exception ex) @@ -302,6 +342,94 @@ public static string NormalizeRepoUrl(string input) return input; } + public string GetEffectiveStorageRoot() => GetCachedStorageRoot(); + + /// + /// 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) + { + var normalizedLeft = Path.GetFullPath(left).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var normalizedRight = Path.GetFullPath(right).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return string.Equals(normalizedLeft, normalizedRight, StringComparison.OrdinalIgnoreCase); + } + + private void BackfillWorktreeClonePaths(RepositoryInfo repo) + { + if (string.IsNullOrWhiteSpace(repo.BareClonePath)) + return; + foreach (var wt in _state.Worktrees.Where(w => w.RepoId == repo.Id && string.IsNullOrWhiteSpace(w.BareClonePath))) + wt.BareClonePath = repo.BareClonePath; + } + + private async Task EnsureRepoCloneInCurrentRootAsync(RepositoryInfo repo, Action? onProgress, CancellationToken ct) + { + var targetBarePath = GetDesiredBareClonePath(repo.Id); + if (!string.IsNullOrWhiteSpace(repo.BareClonePath) + && PathsEqual(repo.BareClonePath, targetBarePath) + && Directory.Exists(targetBarePath)) + return; + + lock (_stateLock) BackfillWorktreeClonePaths(repo); + Directory.CreateDirectory(ReposDir); + + if (Directory.Exists(targetBarePath)) + { + onProgress?.Invoke($"Fetching {repo.Id}…"); + try { await RunGitAsync(targetBarePath, ct, "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"); } catch { } + await RunGitWithProgressAsync(targetBarePath, onProgress, ct, "fetch", "--progress", "origin"); + } + else + { + onProgress?.Invoke($"Cloning {repo.Url}…"); + await RunGitWithProgressAsync(null, onProgress, ct, "clone", "--bare", "--progress", repo.Url, targetBarePath); + await RunGitAsync(targetBarePath, ct, "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"); + onProgress?.Invoke($"Fetching refs…"); + await RunGitWithProgressAsync(targetBarePath, onProgress, ct, "fetch", "--progress", "origin"); + } + + if (OperatingSystem.IsWindows()) + { + try { await RunGitAsync(targetBarePath, ct, "config", "core.longpaths", "true"); } catch { } + } + + lock (_stateLock) + { + repo.BareClonePath = targetBarePath; + BackfillWorktreeClonePaths(repo); + Save(); + } + OnStateChanged?.Invoke(); + } + + public async Task RecloneAllRepositoriesToCurrentRootAsync(Action? onProgress = null, CancellationToken ct = default) + { + EnsureLoaded(); + var total = _state.Repositories.Count; + for (var index = 0; index < total; index++) + { + var repo = _state.Repositories[index]; + onProgress?.Invoke($"[{index + 1}/{total}] {repo.Name}"); + try + { + await EnsureRepoCloneInCurrentRootAsync(repo, onProgress, ct); + } + catch (OperationCanceledException) + { + throw; // propagate cancellation + } + catch (Exception ex) + { + onProgress?.Invoke($"⚠ {repo.Name}: {ex.Message}"); + Console.WriteLine($"[RepoManager] RecloneAll: failed for {repo.Id}: {ex.Message}"); + } + } + } + /// /// Clone a repository as bare. Returns the RepositoryInfo. /// If already tracked, returns existing entry. @@ -317,20 +445,12 @@ public async Task AddRepositoryAsync(string url, Action? var existing = _state.Repositories.FirstOrDefault(r => r.Id == id); if (existing != null) { - onProgress?.Invoke($"Fetching {id}…"); - try { await RunGitAsync(existing.BareClonePath, ct, "config", "remote.origin.fetch", - "+refs/heads/*:refs/remotes/origin/*"); } catch { } - await RunGitWithProgressAsync(existing.BareClonePath, onProgress, ct, "fetch", "--progress", "origin"); - // Ensure long paths are enabled for existing repos on Windows - if (OperatingSystem.IsWindows()) - { - try { await RunGitAsync(existing.BareClonePath, ct, "config", "core.longpaths", "true"); } catch { } - } + await EnsureRepoCloneInCurrentRootAsync(existing, onProgress, ct); return existing; } Directory.CreateDirectory(ReposDir); - var barePath = Path.Combine(ReposDir, $"{id}.git"); + var barePath = GetDesiredBareClonePath(id); if (Directory.Exists(barePath)) { @@ -397,6 +517,7 @@ public virtual async Task CreateWorktreeAsync(string repoId, strin EnsureLoaded(); var repo = _state.Repositories.FirstOrDefault(r => r.Id == repoId) ?? throw new InvalidOperationException($"Repository '{repoId}' not found."); + await EnsureRepoCloneInCurrentRootAsync(repo, null, ct); // Fetch latest from origin (prune to clean up deleted remote branches). // Best-effort: continue offline so worktree creation still works from cached refs. @@ -432,6 +553,7 @@ public virtual async Task CreateWorktreeAsync(string repoId, strin RepoId = repoId, Branch = branchName, Path = worktreePath, + BareClonePath = repo.BareClonePath, CreatedAt = DateTime.UtcNow }; lock (_stateLock) @@ -453,6 +575,7 @@ public async Task CreateWorktreeFromPrAsync(string repoId, int prN EnsureLoaded(); var repo = _state.Repositories.FirstOrDefault(r => r.Id == repoId) ?? throw new InvalidOperationException($"Repository '{repoId}' not found."); + await EnsureRepoCloneInCurrentRootAsync(repo, null, ct); // Try to discover the PR's actual head branch name via gh CLI string? headBranch = null; @@ -531,6 +654,7 @@ public async Task CreateWorktreeFromPrAsync(string repoId, int prN RepoId = repoId, Branch = branchName, Path = worktreePath, + BareClonePath = repo.BareClonePath, PrNumber = prNumber, Remote = remoteName, CreatedAt = DateTime.UtcNow @@ -553,23 +677,28 @@ public async Task RemoveWorktreeAsync(string worktreeId, bool deleteBranch = fal var wt = _state.Worktrees.FirstOrDefault(w => w.Id == worktreeId); if (wt == null) return; - var repo = _state.Repositories.FirstOrDefault(r => r.Id == wt.RepoId); - if (repo != null) + var bareClonePath = wt.BareClonePath; + if (string.IsNullOrWhiteSpace(bareClonePath)) + { + var repo = _state.Repositories.FirstOrDefault(r => r.Id == wt.RepoId); + bareClonePath = repo?.BareClonePath; + } + if (!string.IsNullOrWhiteSpace(bareClonePath)) { try { - await RunGitAsync(repo.BareClonePath, ct, "worktree", "remove", wt.Path, "--force"); + await RunGitAsync(bareClonePath, ct, "worktree", "remove", wt.Path, "--force"); } catch { // Force cleanup if git worktree remove fails if (Directory.Exists(wt.Path)) try { Directory.Delete(wt.Path, recursive: true); } catch { } - try { await RunGitAsync(repo.BareClonePath, ct, "worktree", "prune"); } catch { } + try { await RunGitAsync(bareClonePath, ct, "worktree", "prune"); } catch { } } // Optionally clean up the branch too if (deleteBranch && !string.IsNullOrEmpty(wt.Branch)) - try { await RunGitAsync(repo.BareClonePath, ct, "branch", "-D", "--", wt.Branch); } catch { } + try { await RunGitAsync(bareClonePath, ct, "branch", "-D", "--", wt.Branch); } catch { } } else if (Directory.Exists(wt.Path)) { @@ -711,6 +840,7 @@ public virtual async Task FetchAsync(string repoId, CancellationToken ct = defau EnsureLoaded(); var repo = _state.Repositories.FirstOrDefault(r => r.Id == repoId) ?? throw new InvalidOperationException($"Repository '{repoId}' not found."); + await EnsureRepoCloneInCurrentRootAsync(repo, null, ct); await RunGitAsync(repo.BareClonePath, ct, "fetch", "--prune", "origin"); } @@ -722,6 +852,7 @@ public async Task> GetBranchesAsync(string repoId, CancellationToke EnsureLoaded(); var repo = _state.Repositories.FirstOrDefault(r => r.Id == repoId) ?? throw new InvalidOperationException($"Repository '{repoId}' not found."); + await EnsureRepoCloneInCurrentRootAsync(repo, null, ct); var output = await RunGitAsync(repo.BareClonePath, ct, "branch", "--list"); return output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(b => b.TrimStart('*').Trim()) 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",