From ae274ae26620f43add67745ee03ce13b7b1fd751 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Wed, 18 Mar 2026 06:01:58 -0500 Subject: [PATCH 1/8] Guard session history read against empty SessionId Skip dead event-stream disk fallback when SessionId is empty to avoid reading from an invalid path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Services/CopilotService.Organization.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PolyPilot/Services/CopilotService.Organization.cs b/PolyPilot/Services/CopilotService.Organization.cs index 352dcf33..1b383e57 100644 --- a/PolyPilot/Services/CopilotService.Organization.cs +++ b/PolyPilot/Services/CopilotService.Organization.cs @@ -2219,7 +2219,7 @@ private async Task MonitorAndSynthesizeAsync(PendingOrchestration pending, Cance // Dead event stream fallback: in-memory history may be empty if the SDK event // callback stopped firing. Try reading events.jsonl directly from disk. string? diskResponse = null; - if (!session.IsProcessing) + if (!session.IsProcessing && !string.IsNullOrEmpty(session.SessionId)) { try { From 76ac48ec61f9613affa558a06de5eef51886757c Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Wed, 18 Mar 2026 06:05:15 -0500 Subject: [PATCH 2/8] feat: Add existing folder as repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows users to add an already-cloned local folder as a managed repository in PolyPilot, giving it full feature parity with repos cloned through the app (worktrees, branch creation, PR checkout, squad discovery, sessions, etc.). ## RepoManager.AddRepositoryFromLocalAsync (enhanced) - Added Directory.Exists check with clear error message - Added IsGitRepositoryAsync helper (runs git rev-parse --git-dir) - Added origin-remote check with actionable error message - Added Action? onProgress parameter forwarded to the clone step - All three validations throw InvalidOperationException with user-friendly messages ## CopilotService.AddRepoFromLocalFolderAsync (new) - Public surface that delegates to RepoManager then creates the repo group - Desktop (local) mode only β€” throws clearly when connected to a remote server ## SessionSidebar UI (updated) - '+ Repo' form now shows URL / Existing Folder tabs on desktop - Folder tab: path text input + Browse (…) button that opens FolderPickerService - Browse button is platform-gated (#if MACCATALYST || WINDOWS) - CloseAddRepoForm resets both modes; AddRepositoryFromFolder wires progress display - CSS: .add-repo-mode-tabs, .add-repo-tab, .add-repo-folder-row, .btn-repo-browse ## Tests (3 new in RepoManagerTests) - AddRepositoryFromLocal_NonExistentFolder_ThrowsWithClearMessage - AddRepositoryFromLocal_FolderWithNoGit_ThrowsWithClearMessage - AddRepositoryFromLocal_GitRepoWithNoOrigin_ThrowsWithClearMessage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/RepoManagerTests.cs | 167 ++++++++++++++++++ .../Components/Layout/SessionSidebar.razor | 133 ++++++++++++-- .../Layout/SessionSidebar.razor.css | 49 +++++ PolyPilot/Services/CopilotService.Bridge.cs | 32 ++++ PolyPilot/Services/RepoManager.cs | 125 ++++++++++++- 5 files changed, 487 insertions(+), 19 deletions(-) diff --git a/PolyPilot.Tests/RepoManagerTests.cs b/PolyPilot.Tests/RepoManagerTests.cs index f1afc44a..8b2bdceb 100644 --- a/PolyPilot.Tests/RepoManagerTests.cs +++ b/PolyPilot.Tests/RepoManagerTests.cs @@ -369,4 +369,171 @@ public void Load_WithCorruptedState_HealsFromDisk() } #endregion + + #region AddRepositoryFromLocalAsync Validation Tests + + [Fact] + public async Task AddRepositoryFromLocal_NonExistentFolder_ThrowsWithClearMessage() + { + var rm = new RepoManager(); + var ex = await Assert.ThrowsAsync( + () => rm.AddRepositoryFromLocalAsync("/this/path/does/not/exist")); + Assert.Contains("not found", ex.Message); + } + + [Fact] + public async Task AddRepositoryFromLocal_FolderWithNoGit_ThrowsWithClearMessage() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"not-a-repo-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + try + { + var rm = new RepoManager(); + var ex = await Assert.ThrowsAsync( + () => rm.AddRepositoryFromLocalAsync(tempDir)); + Assert.Contains("not a git repository", ex.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task AddRepositoryFromLocal_GitRepoWithNoOrigin_ThrowsWithClearMessage() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"no-origin-repo-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + try + { + // Initialize a real git repo with no remotes + await RunProcess("git", "init", tempDir); + var rm = new RepoManager(); + var ex = await Assert.ThrowsAsync( + () => rm.AddRepositoryFromLocalAsync(tempDir)); + Assert.Contains("origin", ex.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task RegisterExternalWorktree_AddsWorktreeToState() + { + // STRUCTURAL: Verifies that RegisterExternalWorktreeAsync stores a WorktreeInfo + // and fires OnStateChanged, so the sidebar updates after adding a local folder. + var tempDir = Path.Combine(Path.GetTempPath(), $"ext-wt-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + try + { + await RunProcess("git", "init", tempDir); + await RunProcess("git", "-C", tempDir, "commit", "--allow-empty", "-m", "init"); + + var rm = new RepoManager(); + RepoManager.SetBaseDirForTesting(Path.Combine(Path.GetTempPath(), $"rmtest-{Guid.NewGuid():N}")); + try + { + // Seed a fake repo entry (skip network) + var repoId = "test-owner-testrepo"; + var fakeRepo = new RepositoryInfo { Id = repoId, Name = "testrepo", Url = "https://github.com/test-owner/testrepo.git" }; + var stateChangedFired = false; + rm.OnStateChanged += () => stateChangedFired = true; + + // Directly inject state (bypass load) + var stateField = typeof(RepoManager).GetField("_state", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + var loadedField = typeof(RepoManager).GetField("_loaded", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + var successField = typeof(RepoManager).GetField("_loadedSuccessfully", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + var state = new RepositoryState { Repositories = [fakeRepo] }; + stateField.SetValue(rm, state); + loadedField.SetValue(rm, true); + successField.SetValue(rm, true); + + await rm.RegisterExternalWorktreeAsync(fakeRepo, tempDir, default); + + Assert.True(stateChangedFired, "OnStateChanged must fire so the sidebar refreshes"); + Assert.Single(rm.Worktrees, w => w.RepoId == repoId && PathsEqual(w.Path, tempDir)); + } + finally + { + RepoManager.SetBaseDirForTesting(TestSetup.TestBaseDir); + } + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task RegisterExternalWorktree_Idempotent() + { + // Adding the same path twice should not create duplicate worktree entries. + var tempDir = Path.Combine(Path.GetTempPath(), $"ext-wt-idem-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + try + { + await RunProcess("git", "init", tempDir); + await RunProcess("git", "-C", tempDir, "commit", "--allow-empty", "-m", "init"); + + var rm = new RepoManager(); + RepoManager.SetBaseDirForTesting(Path.Combine(Path.GetTempPath(), $"rmtest2-{Guid.NewGuid():N}")); + try + { + var fakeRepo = new RepositoryInfo { Id = "owner-repo", Name = "repo", Url = "https://github.com/owner/repo.git" }; + var stateField = typeof(RepoManager).GetField("_state", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + var loadedField = typeof(RepoManager).GetField("_loaded", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + var successField = typeof(RepoManager).GetField("_loadedSuccessfully", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + var state = new RepositoryState { Repositories = [fakeRepo] }; + stateField.SetValue(rm, state); + loadedField.SetValue(rm, true); + successField.SetValue(rm, true); + + await rm.RegisterExternalWorktreeAsync(fakeRepo, tempDir, default); + await rm.RegisterExternalWorktreeAsync(fakeRepo, tempDir, default); // second call + + Assert.Single(rm.Worktrees); // exactly one entry + } + finally + { + RepoManager.SetBaseDirForTesting(TestSetup.TestBaseDir); + } + } + finally + { + Directory.Delete(tempDir, true); + } + } + + private static bool PathsEqual(string left, string right) + { + var l = Path.GetFullPath(left).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var r = Path.GetFullPath(right).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return string.Equals(l, r, StringComparison.OrdinalIgnoreCase); + } + + private static Task RunProcess(string exe, params string[] args) + { + var tcs = new TaskCompletionSource(); + var psi = new System.Diagnostics.ProcessStartInfo(exe) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + foreach (var a in args) psi.ArgumentList.Add(a); + // Set EnableRaisingEvents and subscribe Exited BEFORE Start() to avoid the race + // where a fast process exits between Start() and EnableRaisingEvents = true. + var p = new System.Diagnostics.Process { StartInfo = psi, EnableRaisingEvents = true }; + p.Exited += (_, _) => + { + if (p.ExitCode == 0) tcs.TrySetResult(); + else tcs.TrySetException(new Exception($"{exe} exited with {p.ExitCode}")); + }; + p.Start(); + return tcs.Task; + } + + #endregion } diff --git a/PolyPilot/Components/Layout/SessionSidebar.razor b/PolyPilot/Components/Layout/SessionSidebar.razor index 9078e988..0dd11ec6 100644 --- a/PolyPilot/Components/Layout/SessionSidebar.razor +++ b/PolyPilot/Components/Layout/SessionSidebar.razor @@ -338,7 +338,7 @@ else
- +
} @@ -350,26 +350,63 @@ else @if (showAddRepo) {
- - @if (confirmRepoReplace) + @* Mode tabs: URL vs Existing Folder *@ + @if (PlatformHelper.IsDesktop && !confirmRepoReplace) { -
- @confirmRepoName already exists. Re-fetch? -
- - -
+
+ +
} - else + + @if (addRepoFolderMode && PlatformHelper.IsDesktop) { + @* Folder picker mode *@ +
+ + +
- - +
} + else + { + + @if (confirmRepoReplace) + { +
+ @confirmRepoName already exists. Re-fetch? +
+ + +
+
+ } + else + { +
+ + +
+ } + } @if (isAddingRepo && !string.IsNullOrEmpty(addRepoProgress)) {
@addRepoProgress
@@ -1246,6 +1283,9 @@ else private bool confirmRepoReplace = false; private string? confirmRepoName = null; private string? confirmRemoveRepoId = null; + // true when the user chose "Browse folder" mode instead of typing a URL + private bool addRepoFolderMode = false; + private string addRepoFolderPath = ""; // Quick-create inline branch input private string? quickBranchRepoId = null; @@ -2406,6 +2446,73 @@ else } } + private void OpenAddRepoForm() + { + showAddRepo = true; + addRepoFolderMode = false; + addRepoFolderPath = ""; + addRepoError = null; + confirmRepoReplace = false; + confirmRepoName = null; + newRepoUrl = ""; + } + + private void CloseAddRepoForm() + { + showAddRepo = false; + addRepoError = null; + addRepoFolderMode = false; + addRepoFolderPath = ""; + newRepoUrl = ""; + } + + private async Task BrowseExistingRepo() + { +#if MACCATALYST || WINDOWS + try + { + var dir = await FolderPickerService.PickFolderAsync(); + if (!string.IsNullOrEmpty(dir)) + { + addRepoFolderPath = dir; + StateHasChanged(); + } + } + catch (Exception ex) + { + addRepoError = $"Could not open folder picker: {ex.Message}"; + } +#else + await Task.CompletedTask; +#endif + } + + private async Task AddRepositoryFromFolder() + { + if (isAddingRepo || string.IsNullOrWhiteSpace(addRepoFolderPath)) return; + addRepoError = null; + isAddingRepo = true; + addRepoProgress = null; + try + { + await CopilotService.AddRepoFromLocalFolderAsync(addRepoFolderPath.Trim(), progress => + { + addRepoProgress = progress; + InvokeAsync(StateHasChanged); + }); + CloseAddRepoForm(); + } + catch (Exception ex) + { + addRepoError = ex.Message; + } + finally + { + isAddingRepo = false; + addRepoProgress = null; + } + } + private void OpenDirectoryPicker() { showDirectoryPicker = true; diff --git a/PolyPilot/Components/Layout/SessionSidebar.razor.css b/PolyPilot/Components/Layout/SessionSidebar.razor.css index a7487ebc..65b22431 100644 --- a/PolyPilot/Components/Layout/SessionSidebar.razor.css +++ b/PolyPilot/Components/Layout/SessionSidebar.razor.css @@ -681,6 +681,55 @@ border-bottom: 1px solid var(--control-border); } +.add-repo-mode-tabs { + display: flex; + gap: 0.25rem; + margin-bottom: 0.35rem; +} + +.add-repo-tab { + all: unset; + padding: 0.2rem 0.5rem; + border-radius: 4px; + font-size: var(--type-body); + cursor: pointer; + color: var(--text-secondary); + border: 1px solid transparent; +} + +.add-repo-tab:hover { color: var(--text-primary); } + +.add-repo-tab.active { + background: var(--bg-primary); + border-color: var(--control-border); + color: var(--text-primary); +} + +.add-repo-folder-row { + display: flex; + gap: 0.25rem; + align-items: center; +} + +.add-repo-folder-row .repo-url-input { + flex: 1; + width: auto; +} + +.btn-repo-browse { + all: unset; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: var(--type-body); + cursor: pointer; + border: 1px solid var(--control-border); + color: var(--text-secondary); + background: var(--bg-primary); + white-space: nowrap; +} + +.btn-repo-browse:hover { color: var(--text-primary); } + .repo-url-input { width: 100%; padding: 0.4rem; diff --git a/PolyPilot/Services/CopilotService.Bridge.cs b/PolyPilot/Services/CopilotService.Bridge.cs index 4ac16404..f3271496 100644 --- a/PolyPilot/Services/CopilotService.Bridge.cs +++ b/PolyPilot/Services/CopilotService.Bridge.cs @@ -577,6 +577,38 @@ public async Task LoadFullRemoteHistoryAsync(string sessionName) return (result.RepoId, result.RepoName); } + /// + /// Add an already-cloned local folder as a managed repository. The folder's 'origin' + /// remote URL is used to create a bare clone in the PolyPilot repos directory, giving + /// the repo all the same features as if it were cloned through the app. + /// Desktop (local) mode only β€” not supported when connected to a remote server. + /// + public async Task<(string RepoId, string RepoName)> AddRepoFromLocalFolderAsync( + string localPath, + Action? onProgress = null, + CancellationToken ct = default) + { + if (IsRemoteMode) + throw new InvalidOperationException( + "Adding an existing folder is only supported in local mode. " + + "In remote mode the server cannot access local paths on this device."); + + var repo = await _repoManager.AddRepositoryFromLocalAsync(localPath, onProgress, ct); + var group = GetOrCreateRepoGroup(repo.Id, repo.Name, explicitly: true); + + // Un-collapse the group so it's visibly expanded after the user adds a folder. + // If the group was already in the sidebar but collapsed, nothing would appear to + // change otherwise, leaving the user confused about whether the add succeeded. + if (group != null && group.IsCollapsed) + { + group.IsCollapsed = false; + SaveOrganization(); + OnStateChanged?.Invoke(); + } + + return (repo.Id, repo.Name); + } + public async Task RemoveRepoRemoteAsync(string repoId, string groupId, bool deleteFromDisk, CancellationToken ct = default) { if (!IsRemoteMode) diff --git a/PolyPilot/Services/RepoManager.cs b/PolyPilot/Services/RepoManager.cs index 8a5292a4..92d4a3d4 100644 --- a/PolyPilot/Services/RepoManager.cs +++ b/PolyPilot/Services/RepoManager.cs @@ -528,16 +528,129 @@ await RunGitAsync(barePath, ct, "config", "remote.origin.fetch", } /// - /// Add a repository from an existing local path (non-bare). Creates a bare clone. + /// Add a repository from an existing local path (non-bare). Validates the folder is a + /// git repository with an 'origin' remote, then registers and bare-clones it the same + /// way as . + /// The local folder is also registered as an external worktree so it appears in the + /// "πŸ“‚ Existing" list when creating sessions. /// - public async Task AddRepositoryFromLocalAsync(string localPath, CancellationToken ct = default) + /// Path to an existing non-bare git working directory. + /// Optional progress callback forwarded to the clone/fetch step. + /// Cancellation token. + public async Task AddRepositoryFromLocalAsync( + string localPath, + Action? onProgress = null, + CancellationToken ct = default) { - // Get remote URL - var remoteUrl = (await RunGitAsync(localPath, ct, "remote", "get-url", "origin")).Trim(); + // Expand ~ so users can type ~/Projects/myrepo without hitting Directory.Exists failures. + if (localPath.StartsWith("~", StringComparison.Ordinal)) + localPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + localPath.TrimStart('~').TrimStart('/', '\\')); + localPath = Path.GetFullPath(localPath); + + if (!Directory.Exists(localPath)) + throw new InvalidOperationException($"Folder not found: '{localPath}'"); + + // Confirm the folder is a git repository. + if (!await IsGitRepositoryAsync(localPath, ct)) + throw new InvalidOperationException( + $"'{localPath}' is not a git repository. " + + "Make sure the folder contains a cloned repository."); + + // Extract the remote URL from the 'origin' remote. + string remoteUrl; + try + { + remoteUrl = (await RunGitAsync(localPath, ct, "remote", "get-url", "origin")).Trim(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + remoteUrl = ""; + } + if (string.IsNullOrEmpty(remoteUrl)) - throw new InvalidOperationException($"No 'origin' remote found in {localPath}"); + throw new InvalidOperationException( + $"No 'origin' remote found in '{localPath}'. " + + "The folder must have a remote named 'origin' (e.g. a GitHub clone)."); + + var repo = await AddRepositoryAsync(remoteUrl, onProgress, ct); + + // Register the local folder as an external worktree so it appears in the + // "Existing" list when creating sessions. This is the visible entry the user + // expects after adding their local clone. + await RegisterExternalWorktreeAsync(repo, localPath, ct); + + return repo; + } + + /// + /// Register an existing local folder as a worktree for the given repo. + /// Idempotent β€” does nothing if the path is already registered. + /// + internal async Task RegisterExternalWorktreeAsync(RepositoryInfo repo, string localPath, CancellationToken ct) + { + EnsureLoaded(); + var normalizedPath = Path.GetFullPath(localPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + // Already registered for this repo? + lock (_stateLock) + { + if (_state.Worktrees.Any(w => w.RepoId == repo.Id + && !string.IsNullOrWhiteSpace(w.Path) + && PathsEqual(w.Path, normalizedPath))) + return; + } + + // Read the current branch of the local clone. + string branch; + try + { + branch = (await RunGitAsync(normalizedPath, ct, "branch", "--show-current")).Trim(); + if (string.IsNullOrWhiteSpace(branch)) + { + // Detached HEAD β€” use short commit hash as label + branch = (await RunGitAsync(normalizedPath, ct, "rev-parse", "--short", "HEAD")).Trim(); + } + } + catch (OperationCanceledException) { throw; } + catch { branch = Path.GetFileName(normalizedPath); } - return await AddRepositoryAsync(remoteUrl, ct); + var wt = new WorktreeInfo + { + RepoId = repo.Id, + Branch = branch, + Path = normalizedPath, + BareClonePath = repo.BareClonePath, + CreatedAt = DateTime.UtcNow + }; + + lock (_stateLock) + { + _state.Worktrees.Add(wt); + Save(); + } + OnStateChanged?.Invoke(); + } + + /// + /// Returns true if is a git working directory or bare repository. + /// + private async Task IsGitRepositoryAsync(string path, CancellationToken ct) + { + try + { + var result = await RunGitAsync(path, ct, "rev-parse", "--git-dir"); + return !string.IsNullOrWhiteSpace(result); + } + catch (OperationCanceledException) + { + throw; + } + catch + { + return false; + } } /// From 917cc9cde4851339876030b8e762726bfde52726 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Wed, 18 Mar 2026 12:30:24 -0500 Subject: [PATCH 3/8] fix: local folder creates its own distinct sidebar group (not merged into existing repo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of ongoing confusion: when the user added ~/Projects/maui3, the code extracted its origin (https://github.com/dotnet/maui) and merged it into the existing 'maui' group. The user saw no visible change. The user's expectation: adding a local folder should create a NEW, DISTINCT entry in the sidebar -- like 'πŸ“ maui3' -- that they can see and use. Fix: complete redesign of the local-folder flow: 1. SessionGroup.LocalPath (new field): marks a group as a 'pinned local folder'. IsLocalFolder computed property. Not linked to a PolyPilot bare clone. 2. GetOrCreateLocalFolderGroup() (new): creates a distinct group named after the folder (e.g. 'maui3'), idempotent by path. Un-collapses if already exists. 3. AddRepoFromLocalFolderAsync: now does path normalization (including ~ expansion) itself, validates the folder, ALSO registers the bare clone (for worktree support), then creates a LOCAL FOLDER GROUP -- not the existing repo group. 4. Sidebar rendering: local-folder groups show a πŸ“ icon, have their own '...' menu with 'New Session Here' (creates CWD=localPath session) and 'Remove Folder'. 5. QuickCreateSessionForFolder: creates a timestamped session in the local folder group, with working directory set to the folder path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Components/Layout/SessionSidebar.razor | 49 ++++++++++++++++++- PolyPilot/Models/SessionOrganization.cs | 11 +++++ PolyPilot/Services/CopilotService.Bridge.cs | 28 ++++++----- .../Services/CopilotService.Organization.cs | 41 ++++++++++++++++ PolyPilot/Services/RepoManager.cs | 5 +- 5 files changed, 117 insertions(+), 17 deletions(-) diff --git a/PolyPilot/Components/Layout/SessionSidebar.razor b/PolyPilot/Components/Layout/SessionSidebar.razor index 0dd11ec6..cbbc04b5 100644 --- a/PolyPilot/Components/Layout/SessionSidebar.razor +++ b/PolyPilot/Components/Layout/SessionSidebar.razor @@ -446,14 +446,16 @@ else @if (showGroupHeaders) { var isRepoGroup = !string.IsNullOrEmpty(group.RepoId); + var isLocalFolderGroup = group.IsLocalFolder; var repoForGroup = isRepoGroup ? RepoManager.Repositories.FirstOrDefault(r => r.Id == group.RepoId) : null; var groupTitle = repoForGroup != null ? $"{repoForGroup.Url}\n{repoForGroup.BareClonePath}" : ""; + if (isLocalFolderGroup) groupTitle = group.LocalPath!; var repoDiskSize = repoForGroup != null ? RepoManager.GetRepoDiskSize(repoForGroup.Id) : null; if (repoDiskSize.HasValue) groupTitle += $"\nDisk: {RepoManager.FormatSize(repoDiskSize.Value)}"; if (groupProvider != null) groupTitle = groupProvider.GroupDescription; -
@@ -462,6 +464,10 @@ else { @groupProvider.Icon } + else if (isLocalFolderGroup) + { + πŸ“ + } else if (isRepoGroup) { βŒ₯ @@ -524,7 +530,21 @@ else {
- @if (isRepoGroup) + @if (isLocalFolderGroup) + { + var localFolderPath = group.LocalPath!; + + +
+ + } + else @if (isRepoGroup) { var rId = group.RepoId!; +
+ } + else + {
+ } } else { @@ -536,6 +548,17 @@ else + @if (!string.IsNullOrEmpty(group.RepoId)) + { + @* πŸ“ group backed by a bare clone β€” offer full branch/worktree features *@ + var lfRepoId = group.RepoId!; + + + } @@ -1306,6 +1329,8 @@ else // true when the user chose "Browse folder" mode instead of typing a URL private bool addRepoFolderMode = false; private string addRepoFolderPath = ""; + // When a local folder is merged into an existing repo group instead of getting its own πŸ“ group + private string? addFolderMergedIntoGroupName = null; // Quick-create inline branch input private string? quickBranchRepoId = null; @@ -2508,6 +2533,7 @@ else addRepoError = null; addRepoFolderMode = false; addRepoFolderPath = ""; + addFolderMergedIntoGroupName = null; newRepoUrl = ""; } @@ -2536,16 +2562,25 @@ else { if (isAddingRepo || string.IsNullOrWhiteSpace(addRepoFolderPath)) return; addRepoError = null; + addFolderMergedIntoGroupName = null; isAddingRepo = true; addRepoProgress = null; try { - await CopilotService.AddRepoFromLocalFolderAsync(addRepoFolderPath.Trim(), progress => + var result = await CopilotService.AddRepoFromLocalFolderAsync(addRepoFolderPath.Trim(), progress => { addRepoProgress = progress; InvokeAsync(StateHasChanged); }); - CloseAddRepoForm(); + if (result.ExistingGroupId != null) + { + // Merged into an existing repo group β€” show guidance instead of closing + addFolderMergedIntoGroupName = result.ExistingGroupName; + } + else + { + CloseAddRepoForm(); + } } catch (Exception ex) { diff --git a/PolyPilot/Services/CopilotService.Bridge.cs b/PolyPilot/Services/CopilotService.Bridge.cs index 48293540..2d022b19 100644 --- a/PolyPilot/Services/CopilotService.Bridge.cs +++ b/PolyPilot/Services/CopilotService.Bridge.cs @@ -583,7 +583,20 @@ public async Task LoadFullRemoteHistoryAsync(string sessionName) /// the repo all the same features as if it were cloned through the app. /// Desktop (local) mode only β€” not supported when connected to a remote server. ///
- public async Task<(string RepoId, string RepoName)> AddRepoFromLocalFolderAsync( + /// + /// Result of adding a local folder as a repository. + /// + public record AddLocalFolderResult( + string RepoId, + string RepoName, + /// + /// When non-null, the folder was registered as an external worktree under an existing + /// repo group rather than creating a new πŸ“ group. Points to the existing group. + /// + string? ExistingGroupId, + string? ExistingGroupName); + + public async Task AddRepoFromLocalFolderAsync( string localPath, Action? onProgress = null, CancellationToken ct = default) @@ -603,14 +616,30 @@ public async Task LoadFullRemoteHistoryAsync(string sessionName) if (!Directory.Exists(localPath)) throw new InvalidOperationException($"Folder not found: '{localPath}'"); - // Register/update the bare clone for the repo (needed for worktree creation later) - await _repoManager.AddRepositoryFromLocalAsync(localPath, onProgress, ct); + // Register/update the bare clone and external worktree for this local path + var repo = await _repoManager.AddRepositoryFromLocalAsync(localPath, onProgress, ct); - // Create a DISTINCT sidebar group for this local folder so the user sees it explicitly. - // This is different from GetOrCreateRepoGroup which merges into the existing repo group. - GetOrCreateLocalFolderGroup(localPath); + // Check if an existing repo group already covers this repo (added via URL previously). + // If so, un-collapse it and return it rather than creating a redundant πŸ“ group. + // The local folder is already registered as a WorktreeInfo and appears in the + // "πŸ“‚ Existing" tab of that group's New Session dialog. + var existingRepoGroup = Organization.Groups.FirstOrDefault( + g => g.RepoId == repo.Id && !g.IsMultiAgent && !g.IsLocalFolder); + if (existingRepoGroup != null) + { + if (existingRepoGroup.IsCollapsed) + { + existingRepoGroup.IsCollapsed = false; + SaveOrganization(); + } + OnStateChanged?.Invoke(); + return new AddLocalFolderResult(repo.Id, repo.Name, existingRepoGroup.Id, existingRepoGroup.Name); + } - return (Path.GetFileName(localPath), Path.GetFileName(localPath)); + // No existing repo group β€” create a distinct πŸ“ group for this local folder. + // Store the RepoId on the group so it can offer full worktree features. + var localGroup = GetOrCreateLocalFolderGroup(localPath, repo.Id); + return new AddLocalFolderResult(repo.Id, repo.Name, null, null); } public async Task RemoveRepoRemoteAsync(string repoId, string groupId, bool deleteFromDisk, CancellationToken ct = default) diff --git a/PolyPilot/Services/CopilotService.Organization.cs b/PolyPilot/Services/CopilotService.Organization.cs index fb5f3e58..0ae252d5 100644 --- a/PolyPilot/Services/CopilotService.Organization.cs +++ b/PolyPilot/Services/CopilotService.Organization.cs @@ -823,8 +823,10 @@ internal bool IsWorkerInMultiAgentGroup(string sessionName) /// /// Create (or return existing) a sidebar group for a pinned local folder. /// The group is distinct from any repo-based group β€” sessions in it use the local path as CWD. + /// When is provided, the group records which repo backs it so the + /// full worktree/branch menu can be offered. /// - public SessionGroup GetOrCreateLocalFolderGroup(string localPath) + public SessionGroup GetOrCreateLocalFolderGroup(string localPath, string? repoId = null) { var normalized = Path.GetFullPath(localPath) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); @@ -838,12 +840,11 @@ public SessionGroup GetOrCreateLocalFolderGroup(string localPath) StringComparison.OrdinalIgnoreCase)); if (existing != null) { - if (existing.IsCollapsed) - { - existing.IsCollapsed = false; - SaveOrganization(); - OnStateChanged?.Invoke(); - } + bool changed = false; + if (existing.IsCollapsed) { existing.IsCollapsed = false; changed = true; } + // Back-fill RepoId if we now know it + if (repoId != null && existing.RepoId == null) { existing.RepoId = repoId; changed = true; } + if (changed) { SaveOrganization(); OnStateChanged?.Invoke(); } return existing; } @@ -853,6 +854,7 @@ public SessionGroup GetOrCreateLocalFolderGroup(string localPath) Id = Guid.NewGuid().ToString(), Name = folderName, LocalPath = normalized, + RepoId = repoId, SortOrder = Organization.Groups.Any() ? Organization.Groups.Max(g => g.SortOrder) + 1 : 0 }; AddGroup(group); From 185cedef948552267673d1017bf1c9e616ec0846 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Wed, 18 Mar 2026 15:19:30 -0500 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20full=20worktree=20support=20for=20?= =?UTF-8?q?=F0=9F=93=81=20local=20folder=20groups=20+=20auto-migrate=20sta?= =?UTF-8?q?le=20groups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes: 1. CreateSessionWithWorktreeAsync now accepts targetGroupId -- when 'Quick Branch' or 'Named Branch' is clicked from a πŸ“ group, the new session lands IN that local folder group (not the URL-based repo group). 2. ReconcileOrganization migration: groups created by old code (before LocalPath field existed) are auto-detected by matching their name against registered external worktrees. LocalPath and RepoId are back-filled on load, so πŸ“ groups created in earlier sessions now get the correct menu. 3. RepoManager.GetWorktreesDir() exposes the managed worktrees directory path so ReconcileOrganization can distinguish external worktrees (user-owned) from PolyPilot-managed ones. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Components/Layout/SessionSidebar.razor | 19 +++++++++------ .../Services/CopilotService.Organization.cs | 24 +++++++++++++++++++ PolyPilot/Services/CopilotService.cs | 14 ++++++++--- PolyPilot/Services/RepoManager.cs | 3 +++ 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/PolyPilot/Components/Layout/SessionSidebar.razor b/PolyPilot/Components/Layout/SessionSidebar.razor index 16183383..e036a84d 100644 --- a/PolyPilot/Components/Layout/SessionSidebar.razor +++ b/PolyPilot/Components/Layout/SessionSidebar.razor @@ -552,10 +552,10 @@ else { @* πŸ“ group backed by a bare clone β€” offer full branch/worktree features *@ var lfRepoId = group.RepoId!; - - } @@ -631,7 +631,7 @@ else }
- @if (quickBranchRepoId == group.RepoId && isRepoGroup) + @if (quickBranchRepoId == group.RepoId && (isRepoGroup || isLocalFolderGroup)) { var qbRepoId = group.RepoId!;
@@ -1334,6 +1334,7 @@ else // Quick-create inline branch input private string? quickBranchRepoId = null; + private string? quickBranchGroupId = null; private string quickBranchInput = ""; private bool quickBranchIsCreating = false; private string? quickBranchError = null; @@ -1871,7 +1872,7 @@ else } } - private async Task QuickCreateSessionForRepo(string repoId) + private async Task QuickCreateSessionForRepo(string repoId, string? targetGroupId = null) { if (isCreating) return; isCreating = true; @@ -1882,7 +1883,8 @@ else { var sessionInfo = await CopilotService.CreateSessionWithWorktreeAsync( repoId: repoId, - model: selectedModel); + model: selectedModel, + targetGroupId: targetGroupId); CopilotService.SaveUiState(currentPage, selectedModel: selectedModel); await OnSessionSelected.InvokeAsync(); } @@ -1899,9 +1901,10 @@ else } } - private void StartQuickBranch(string repoId) + private void StartQuickBranch(string repoId, string? targetGroupId = null) { quickBranchRepoId = repoId; + quickBranchGroupId = targetGroupId; quickBranchInput = ""; quickBranchError = null; } @@ -1939,9 +1942,11 @@ else repoId: repoId, branchName: branchName, prNumber: prNumber, - model: selectedModel); + model: selectedModel, + targetGroupId: quickBranchGroupId); quickBranchRepoId = null; + quickBranchGroupId = null; quickBranchInput = ""; CopilotService.SaveUiState(currentPage, selectedModel: selectedModel); await OnSessionSelected.InvokeAsync(); diff --git a/PolyPilot/Services/CopilotService.Organization.cs b/PolyPilot/Services/CopilotService.Organization.cs index 0ae252d5..ef4f6733 100644 --- a/PolyPilot/Services/CopilotService.Organization.cs +++ b/PolyPilot/Services/CopilotService.Organization.cs @@ -381,6 +381,30 @@ internal void ReconcileOrganization(bool allowPruning = true) } } + // Migration: back-fill LocalPath/RepoId on groups that were created by an older version + // of the code before the LocalPath field existed. Detect them by matching their name against + // registered external worktrees (paths NOT under the managed worktrees directory). + var managedWorktreesDir = _repoManager.GetWorktreesDir(); + foreach (var group in Organization.Groups) + { + if (group.IsLocalFolder || !string.IsNullOrEmpty(group.RepoId) || group.IsMultiAgent + || group.Id == SessionGroup.DefaultId || group.IsCodespace) + continue; + + // Look for an external worktree whose folder name matches this group name + var match = _repoManager.Worktrees.FirstOrDefault(wt => + !wt.Path.StartsWith(managedWorktreesDir, StringComparison.OrdinalIgnoreCase) && + string.Equals(Path.GetFileName(wt.Path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)), + group.Name, StringComparison.OrdinalIgnoreCase)); + if (match != null) + { + group.LocalPath = match.Path; + group.RepoId = match.RepoId; + changed = true; + Debug($"ReconcileOrganization: back-filled LocalPath='{match.Path}' RepoId='{match.RepoId}' on group '{group.Name}'"); + } + } + // Build the full set of known session names: active sessions + aliases (persisted names) var knownNames = new HashSet(activeNames); try diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 90e67ccd..e4281290 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -2540,6 +2540,7 @@ public async Task CreateSessionWithWorktreeAsync( string? sessionName = null, string? model = null, string? initialPrompt = null, + string? targetGroupId = null, CancellationToken ct = default) { // Remote mode: send the entire operation to the server as a single atomic command. @@ -2640,9 +2641,16 @@ await _bridgeClient.CreateSessionWithWorktreeAsync(new CreateSessionWithWorktree var repo = _repoManager.Repositories.FirstOrDefault(r => r.Id == wt.RepoId); if (repo != null) { - var group = GetOrCreateRepoGroup(repo.Id, repo.Name, explicitly: true); - if (group != null) - MoveSession(sessionInfo.Name, group.Id); + // If the caller specified a target group (e.g. a πŸ“ local folder group), use it; + // otherwise fall back to the standard repo group. + string? resolvedGroupId = targetGroupId; + if (resolvedGroupId == null) + { + var group = GetOrCreateRepoGroup(repo.Id, repo.Name, explicitly: true); + resolvedGroupId = group?.Id; + } + if (resolvedGroupId != null) + MoveSession(sessionInfo.Name, resolvedGroupId); var meta = GetSessionMeta(sessionInfo.Name); if (meta != null) meta.WorktreeId = wt.Id; } diff --git a/PolyPilot/Services/RepoManager.cs b/PolyPilot/Services/RepoManager.cs index 0b11d699..da3727e5 100644 --- a/PolyPilot/Services/RepoManager.cs +++ b/PolyPilot/Services/RepoManager.cs @@ -20,6 +20,9 @@ public class RepoManager private string ReposDir => Path.Combine(GetCachedStorageRoot(), "repos"); private string WorktreesDir => Path.Combine(GetCachedStorageRoot(), "worktrees"); + /// Returns the directory where PolyPilot-managed worktrees live. + public string GetWorktreesDir() => WorktreesDir; + /// /// Redirect all RepoManager paths to a test directory. /// Clears cached paths so they re-resolve from the new base. From fd19d2e5c3528ed038145831f40a65068e3e4cb4 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Wed, 18 Mar 2026 15:50:21 -0500 Subject: [PATCH 7/8] feat: nested worktrees for local folder repos (.polypilot/worktrees/ inside repo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user adds an existing folder via 'Add Existing Folder' and creates a new branch (⚑ Quick Branch or β‘‚ Named Branch) from the πŸ“ group menu, worktrees are now created nested inside their repo at: {localPath}/.polypilot/worktrees/{branchName}/ instead of the centralized ~/.polypilot/worktrees/{repoId}-{guid8}/. Changes: - RepoManager.CreateWorktreeAsync: new optional localPath param; when provided, uses nested strategy and adds .polypilot/ to .gitignore - EnsureGitIgnoreEntry: new private helper β€” idempotent, handles existing entries with/without trailing slash, creates .gitignore if missing - RemoveWorktreeAsync: safety guard now also accepts nested worktrees (paths containing /.polypilot/worktrees/) in addition to centralized ones - CopilotService.CreateSessionWithWorktreeAsync: threads localPath through to CreateWorktreeAsync - SessionSidebar: QuickCreateSessionForRepo, StartQuickBranch, CommitQuickBranch all pass group.LocalPath for πŸ“ local folder groups - Tests: 4 new unit tests for EnsureGitIgnoreEntry edge cases; updated FakeRepoManager/FailingRepoManager to match new signature Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/RepoManagerTests.cs | 82 +++++++++++++++++++ PolyPilot.Tests/WorktreeStrategyTests.cs | 4 +- .../Components/Layout/SessionSidebar.razor | 20 +++-- PolyPilot/Services/CopilotService.cs | 3 +- PolyPilot/Services/RepoManager.cs | 65 +++++++++++++-- 5 files changed, 158 insertions(+), 16 deletions(-) diff --git a/PolyPilot.Tests/RepoManagerTests.cs b/PolyPilot.Tests/RepoManagerTests.cs index 8b2bdceb..f1721d35 100644 --- a/PolyPilot.Tests/RepoManagerTests.cs +++ b/PolyPilot.Tests/RepoManagerTests.cs @@ -513,6 +513,88 @@ private static bool PathsEqual(string left, string right) return string.Equals(l, r, StringComparison.OrdinalIgnoreCase); } + #region EnsureGitIgnoreEntry Tests + + [Fact] + public void EnsureGitIgnoreEntry_CreatesGitIgnoreIfMissing() + { + var tmpDir = Directory.CreateTempSubdirectory("polypilot-test-").FullName; + try + { + var method = typeof(RepoManager).GetMethod("EnsureGitIgnoreEntry", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!; + method.Invoke(null, [tmpDir, ".polypilot/"]); + + var gitignorePath = Path.Combine(tmpDir, ".gitignore"); + Assert.True(File.Exists(gitignorePath)); + var content = File.ReadAllText(gitignorePath); + Assert.Contains(".polypilot/", content); + } + finally { Directory.Delete(tmpDir, true); } + } + + [Fact] + public void EnsureGitIgnoreEntry_AppendsIfNotPresent() + { + var tmpDir = Directory.CreateTempSubdirectory("polypilot-test-").FullName; + try + { + var gitignorePath = Path.Combine(tmpDir, ".gitignore"); + File.WriteAllText(gitignorePath, "*.user\nbin/\n"); + + var method = typeof(RepoManager).GetMethod("EnsureGitIgnoreEntry", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!; + method.Invoke(null, [tmpDir, ".polypilot/"]); + + var content = File.ReadAllText(gitignorePath); + Assert.Contains(".polypilot/", content); + Assert.Contains("*.user", content); // existing content preserved + } + finally { Directory.Delete(tmpDir, true); } + } + + [Fact] + public void EnsureGitIgnoreEntry_IdempotentIfAlreadyPresent() + { + var tmpDir = Directory.CreateTempSubdirectory("polypilot-test-").FullName; + try + { + var gitignorePath = Path.Combine(tmpDir, ".gitignore"); + File.WriteAllText(gitignorePath, ".polypilot/\n"); + + var method = typeof(RepoManager).GetMethod("EnsureGitIgnoreEntry", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!; + method.Invoke(null, [tmpDir, ".polypilot/"]); + method.Invoke(null, [tmpDir, ".polypilot/"]); // call twice + + var lines = File.ReadAllLines(gitignorePath); + Assert.Equal(1, lines.Count(l => l.Trim() == ".polypilot/")); // only one entry + } + finally { Directory.Delete(tmpDir, true); } + } + + [Fact] + public void EnsureGitIgnoreEntry_MatchesWithoutTrailingSlash() + { + var tmpDir = Directory.CreateTempSubdirectory("polypilot-test-").FullName; + try + { + var gitignorePath = Path.Combine(tmpDir, ".gitignore"); + File.WriteAllText(gitignorePath, ".polypilot\n"); // no trailing slash variant + + var method = typeof(RepoManager).GetMethod("EnsureGitIgnoreEntry", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!; + method.Invoke(null, [tmpDir, ".polypilot/"]); + + var content = File.ReadAllText(gitignorePath); + // Should NOT add a duplicate (already covered by ".polypilot" line) + Assert.DoesNotContain(".polypilot/", content); + } + finally { Directory.Delete(tmpDir, true); } + } + + #endregion + private static Task RunProcess(string exe, params string[] args) { var tcs = new TaskCompletionSource(); diff --git a/PolyPilot.Tests/WorktreeStrategyTests.cs b/PolyPilot.Tests/WorktreeStrategyTests.cs index 6b38cfa9..bbd6ba29 100644 --- a/PolyPilot.Tests/WorktreeStrategyTests.cs +++ b/PolyPilot.Tests/WorktreeStrategyTests.cs @@ -47,7 +47,7 @@ public FakeRepoManager(List repos) } public override Task CreateWorktreeAsync(string repoId, string branchName, - string? baseBranch = null, bool skipFetch = false, CancellationToken ct = default) + string? baseBranch = null, bool skipFetch = false, string? localPath = null, CancellationToken ct = default) { CreateCalls.Add((repoId, branchName, skipFetch)); var id = $"wt-{Interlocked.Increment(ref _worktreeCounter)}"; @@ -482,7 +482,7 @@ public FailingRepoManager(List repos) } public override Task CreateWorktreeAsync(string repoId, string branchName, - string? baseBranch = null, bool skipFetch = false, CancellationToken ct = default) + string? baseBranch = null, bool skipFetch = false, string? localPath = null, CancellationToken ct = default) { throw new InvalidOperationException("Simulated git failure"); } diff --git a/PolyPilot/Components/Layout/SessionSidebar.razor b/PolyPilot/Components/Layout/SessionSidebar.razor index e036a84d..811b5fc5 100644 --- a/PolyPilot/Components/Layout/SessionSidebar.razor +++ b/PolyPilot/Components/Layout/SessionSidebar.razor @@ -552,10 +552,11 @@ else { @* πŸ“ group backed by a bare clone β€” offer full branch/worktree features *@ var lfRepoId = group.RepoId!; - - } @@ -1335,6 +1336,7 @@ else // Quick-create inline branch input private string? quickBranchRepoId = null; private string? quickBranchGroupId = null; + private string? quickBranchLocalPath = null; private string quickBranchInput = ""; private bool quickBranchIsCreating = false; private string? quickBranchError = null; @@ -1872,7 +1874,7 @@ else } } - private async Task QuickCreateSessionForRepo(string repoId, string? targetGroupId = null) + private async Task QuickCreateSessionForRepo(string repoId, string? targetGroupId = null, string? localPath = null) { if (isCreating) return; isCreating = true; @@ -1884,7 +1886,8 @@ else var sessionInfo = await CopilotService.CreateSessionWithWorktreeAsync( repoId: repoId, model: selectedModel, - targetGroupId: targetGroupId); + targetGroupId: targetGroupId, + localPath: localPath); CopilotService.SaveUiState(currentPage, selectedModel: selectedModel); await OnSessionSelected.InvokeAsync(); } @@ -1901,10 +1904,11 @@ else } } - private void StartQuickBranch(string repoId, string? targetGroupId = null) + private void StartQuickBranch(string repoId, string? targetGroupId = null, string? localPath = null) { quickBranchRepoId = repoId; quickBranchGroupId = targetGroupId; + quickBranchLocalPath = localPath; quickBranchInput = ""; quickBranchError = null; } @@ -1912,7 +1916,7 @@ else private async Task HandleQuickBranchKeyDown(KeyboardEventArgs e, string repoId) { if (e.Key == "Enter") await CommitQuickBranch(repoId); - else if (e.Key == "Escape") quickBranchRepoId = null; + else if (e.Key == "Escape") { quickBranchRepoId = null; quickBranchLocalPath = null; } } private async Task CommitQuickBranch(string repoId) @@ -1943,10 +1947,12 @@ else branchName: branchName, prNumber: prNumber, model: selectedModel, - targetGroupId: quickBranchGroupId); + targetGroupId: quickBranchGroupId, + localPath: quickBranchLocalPath); quickBranchRepoId = null; quickBranchGroupId = null; + quickBranchLocalPath = null; quickBranchInput = ""; CopilotService.SaveUiState(currentPage, selectedModel: selectedModel); await OnSessionSelected.InvokeAsync(); diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index e4281290..5b97f12f 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -2541,6 +2541,7 @@ public async Task CreateSessionWithWorktreeAsync( string? model = null, string? initialPrompt = null, string? targetGroupId = null, + string? localPath = null, CancellationToken ct = default) { // Remote mode: send the entire operation to the server as a single atomic command. @@ -2604,7 +2605,7 @@ await _bridgeClient.CreateSessionWithWorktreeAsync(new CreateSessionWithWorktree else { var branch = branchName ?? $"session-{DateTime.Now:yyyyMMdd-HHmmss}"; - wt = await _repoManager.CreateWorktreeAsync(repoId, branch, null, ct: ct); + wt = await _repoManager.CreateWorktreeAsync(repoId, branch, null, localPath: localPath, ct: ct); } var name = sessionName ?? wt.Branch; diff --git a/PolyPilot/Services/RepoManager.cs b/PolyPilot/Services/RepoManager.cs index da3727e5..bd625ba4 100644 --- a/PolyPilot/Services/RepoManager.cs +++ b/PolyPilot/Services/RepoManager.cs @@ -658,7 +658,12 @@ private async Task IsGitRepositoryAsync(string path, CancellationToken ct) /// /// Create a new worktree for a repository on a new branch from origin/main. /// - public virtual async Task CreateWorktreeAsync(string repoId, string branchName, string? baseBranch = null, bool skipFetch = false, CancellationToken ct = default) + /// + /// Optional path to the user's existing local repo clone (added via "Add Existing Folder"). + /// When provided, the worktree is created at {localPath}/.polypilot/worktrees/{branchName}/ + /// (nested inside the user's repo) rather than the centralized ~/.polypilot/worktrees/. + /// + public virtual async Task CreateWorktreeAsync(string repoId, string branchName, string? baseBranch = null, bool skipFetch = false, string? localPath = null, CancellationToken ct = default) { EnsureLoaded(); var repo = _state.Repositories.FirstOrDefault(r => r.Id == repoId) @@ -677,9 +682,23 @@ public virtual async Task CreateWorktreeAsync(string repoId, strin var baseRef = baseBranch ?? await GetDefaultBranch(repo.BareClonePath, ct); Console.WriteLine($"[RepoManager] Creating worktree from base ref: {baseRef}"); - Directory.CreateDirectory(WorktreesDir); + string worktreePath; var worktreeId = Guid.NewGuid().ToString()[..8]; - var worktreePath = Path.Combine(WorktreesDir, $"{repoId}-{worktreeId}"); + + if (!string.IsNullOrWhiteSpace(localPath)) + { + // Nested strategy: place worktree inside the user's repo at .polypilot/worktrees/{branch}/ + var repoWorktreesDir = Path.Combine(Path.GetFullPath(localPath), ".polypilot", "worktrees"); + Directory.CreateDirectory(repoWorktreesDir); + EnsureGitIgnoreEntry(localPath, ".polypilot/"); + worktreePath = Path.Combine(repoWorktreesDir, branchName); + } + else + { + // Centralized strategy: place worktree in ~/.polypilot/worktrees/{repoId}-{guid8}/ + Directory.CreateDirectory(WorktreesDir); + worktreePath = Path.Combine(WorktreesDir, $"{repoId}-{worktreeId}"); + } try { @@ -712,6 +731,37 @@ public virtual async Task CreateWorktreeAsync(string repoId, strin return wt; } + /// + /// Ensures that (e.g. .polypilot/) is present in the + /// .gitignore file inside . Creates .gitignore + /// if it does not exist. No-op if the entry is already present. + /// + private static void EnsureGitIgnoreEntry(string repoPath, string entry) + { + try + { + var gitignorePath = Path.Combine(repoPath, ".gitignore"); + var lines = File.Exists(gitignorePath) + ? File.ReadAllLines(gitignorePath) + : []; + + // Check if any existing line matches (exact or without trailing slash variant) + var entryTrimmed = entry.TrimEnd('/'); + if (lines.Any(l => l.Trim() == entry || l.Trim() == entryTrimmed || l.Trim() == $"/{entry}" || l.Trim() == $"/{entryTrimmed}")) + return; + + // Append with a leading newline if file doesn't end with one + using var sw = new StreamWriter(gitignorePath, append: true); + if (lines.Length > 0 && !string.IsNullOrEmpty(lines[^1])) + sw.WriteLine(); + sw.WriteLine(entry); + } + catch (Exception ex) + { + Console.WriteLine($"[RepoManager] Failed to update .gitignore: {ex.Message}"); + } + } + /// /// Create a worktree by checking out a GitHub PR's branch. /// Fetches the PR ref, discovers the actual branch name via gh CLI, @@ -850,10 +900,13 @@ public async Task RemoveWorktreeAsync(string worktreeId, bool deleteBranch = fal } else if (Directory.Exists(wt.Path)) { - // No repo found β€” only delete if path is within our managed worktrees directory - // to prevent accidental deletion of arbitrary directories from corrupted state. + // No repo found β€” only delete if path is within a managed location to prevent + // accidental deletion of arbitrary directories from corrupted state. + // Managed locations: ~/.polypilot/worktrees/ OR {anyRepo}/.polypilot/worktrees/ var fullPath = Path.GetFullPath(wt.Path); - if (fullPath.StartsWith(Path.GetFullPath(WorktreesDir), StringComparison.OrdinalIgnoreCase)) + var isCentralized = fullPath.StartsWith(Path.GetFullPath(WorktreesDir), StringComparison.OrdinalIgnoreCase); + var isNested = fullPath.Contains(Path.DirectorySeparatorChar + ".polypilot" + Path.DirectorySeparatorChar + "worktrees" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + if (isCentralized || isNested) try { Directory.Delete(wt.Path, recursive: true); } catch { } } From d5f62ad00ff018dd2cc6efeae982f0f53058bf60 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Wed, 18 Mar 2026 15:56:41 -0500 Subject: [PATCH 8/8] fix: path traversal guard + nested worktree delete for SessionListItem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 (path traversal): After computing the nested worktree path, validate that Path.GetFullPath(worktreePath) stays inside the managed .polypilot/worktrees/ directory. A branch name like '../../evil' would otherwise escape and create a worktree outside the user's repo. Bug 2 (delete dialog): SessionListItem.ShowCloseConfirm isManaged check now also accepts nested worktrees (paths containing /.polypilot/worktrees/) in addition to centralized ~/.polypilot/worktrees/ paths β€” consistent with RemoveWorktreeAsync. Tests: 6 new path traversal tests (3 malicious, 3 safe branch names) validating the guard logic works correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/RepoManagerTests.cs | 42 +++++++++++++++++++ .../Components/Layout/SessionListItem.razor | 19 +++++++-- PolyPilot/Services/RepoManager.cs | 8 ++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/PolyPilot.Tests/RepoManagerTests.cs b/PolyPilot.Tests/RepoManagerTests.cs index f1721d35..f56455cb 100644 --- a/PolyPilot.Tests/RepoManagerTests.cs +++ b/PolyPilot.Tests/RepoManagerTests.cs @@ -595,6 +595,48 @@ public void EnsureGitIgnoreEntry_MatchesWithoutTrailingSlash() #endregion + #region Nested Worktree Path Traversal Tests + + [Theory] + [InlineData("../../evil")] + [InlineData("../sibling")] + [InlineData("foo/../../escape")] + public void CreateWorktree_PathTraversal_InBranchName_IsRejected(string maliciousBranch) + { + // Simulate what CreateWorktreeAsync does: combine repoWorktreesDir + branchName then GetFullPath + var fakeRepoDir = Path.Combine(Path.GetTempPath(), "fake-repo"); + var repoWorktreesDir = Path.Combine(fakeRepoDir, ".polypilot", "worktrees"); + var worktreePath = Path.Combine(repoWorktreesDir, maliciousBranch); + var resolved = Path.GetFullPath(worktreePath); + var managedBase = Path.GetFullPath(repoWorktreesDir) + Path.DirectorySeparatorChar; + + // The guard condition: resolved must start with managedBase + var wouldEscape = !resolved.StartsWith(managedBase, StringComparison.OrdinalIgnoreCase) + && !resolved.Equals(Path.GetFullPath(repoWorktreesDir), StringComparison.OrdinalIgnoreCase); + + Assert.True(wouldEscape, $"Branch '{maliciousBranch}' should escape the managed dir but guard says it doesn't. Resolved: {resolved}"); + } + + [Theory] + [InlineData("my-feature")] + [InlineData("feature/login")] + [InlineData("fix.typo")] + public void CreateWorktree_ValidBranchName_StaysInsideDir(string safeBranch) + { + var fakeRepoDir = Path.Combine(Path.GetTempPath(), "fake-repo"); + var repoWorktreesDir = Path.Combine(fakeRepoDir, ".polypilot", "worktrees"); + var worktreePath = Path.Combine(repoWorktreesDir, safeBranch); + var resolved = Path.GetFullPath(worktreePath); + var managedBase = Path.GetFullPath(repoWorktreesDir) + Path.DirectorySeparatorChar; + + var wouldEscape = !resolved.StartsWith(managedBase, StringComparison.OrdinalIgnoreCase) + && !resolved.Equals(Path.GetFullPath(repoWorktreesDir), StringComparison.OrdinalIgnoreCase); + + Assert.False(wouldEscape, $"Branch '{safeBranch}' should NOT escape the managed dir. Resolved: {resolved}"); + } + + #endregion + private static Task RunProcess(string exe, params string[] args) { var tcs = new TaskCompletionSource(); diff --git a/PolyPilot/Components/Layout/SessionListItem.razor b/PolyPilot/Components/Layout/SessionListItem.razor index 038d8107..69f5fcd1 100644 --- a/PolyPilot/Components/Layout/SessionListItem.razor +++ b/PolyPilot/Components/Layout/SessionListItem.razor @@ -283,12 +283,25 @@ await OnCloseMenu.InvokeAsync(); try { - bool hasWorktree = !string.IsNullOrEmpty(Meta?.WorktreeId); + bool hasWorktree = false; string branchName = ""; - if (hasWorktree) + if (!string.IsNullOrEmpty(Meta?.WorktreeId)) { var wt = RepoManager.Worktrees.FirstOrDefault(w => w.Id == Meta!.WorktreeId); - branchName = wt?.Branch ?? ""; + // Only offer worktree/branch deletion for PolyPilot-managed worktrees. + // External worktrees (added via "Add Existing Folder") are user-owned β€” we must not delete them. + // Managed locations: ~/.polypilot/worktrees/ (centralized) OR {repo}/.polypilot/worktrees/ (nested) + var managedDir = RepoManager.GetWorktreesDir(); + var isNested = wt != null && wt.Path.Contains( + Path.DirectorySeparatorChar + ".polypilot" + Path.DirectorySeparatorChar + "worktrees" + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase); + bool isManaged = wt != null && + (wt.Path.StartsWith(managedDir, StringComparison.OrdinalIgnoreCase) || isNested); + if (isManaged) + { + hasWorktree = true; + branchName = wt!.Branch ?? ""; + } } var result = await JS.InvokeAsync( diff --git a/PolyPilot/Services/RepoManager.cs b/PolyPilot/Services/RepoManager.cs index bd625ba4..dd9032f7 100644 --- a/PolyPilot/Services/RepoManager.cs +++ b/PolyPilot/Services/RepoManager.cs @@ -692,6 +692,14 @@ public virtual async Task CreateWorktreeAsync(string repoId, strin Directory.CreateDirectory(repoWorktreesDir); EnsureGitIgnoreEntry(localPath, ".polypilot/"); worktreePath = Path.Combine(repoWorktreesDir, branchName); + + // Guard against path traversal: branch names with ".." or leading "/" could escape the directory. + var resolved = Path.GetFullPath(worktreePath); + if (!resolved.StartsWith(Path.GetFullPath(repoWorktreesDir) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + && !resolved.Equals(Path.GetFullPath(repoWorktreesDir), StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"Branch name '{branchName}' would create worktree outside the managed directory. " + + "Use a branch name without '..' or leading path separators."); } else {