diff --git a/PolyPilot.Tests/RepoManagerTests.cs b/PolyPilot.Tests/RepoManagerTests.cs index f1afc44a..f56455cb 100644 --- a/PolyPilot.Tests/RepoManagerTests.cs +++ b/PolyPilot.Tests/RepoManagerTests.cs @@ -369,4 +369,295 @@ 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); + } + + #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 + + #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(); + 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.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/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/Components/Layout/SessionSidebar.razor b/PolyPilot/Components/Layout/SessionSidebar.razor index 9078e988..811b5fc5 100644 --- a/PolyPilot/Components/Layout/SessionSidebar.razor +++ b/PolyPilot/Components/Layout/SessionSidebar.razor @@ -338,7 +338,7 @@ else
- +
} @@ -350,25 +350,74 @@ 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 *@ + @if (addFolderMergedIntoGroupName != null) + { +
+ βœ… Registered as an existing worktree in the @addFolderMergedIntoGroupName group. Use its β‹― β†’ New Session β†’ πŸ“‚ Existing to open it. +
+
+ +
+ } + else + { +
+ + +
- - +
+ } + } + else + { + + @if (confirmRepoReplace) + { +
+ @confirmRepoName already exists. Re-fetch? +
+ + +
+
+ } + else + { +
+ + +
+ } } @if (isAddingRepo && !string.IsNullOrEmpty(addRepoProgress)) { @@ -409,14 +458,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; -
@@ -425,6 +476,10 @@ else { @groupProvider.Icon } + else if (isLocalFolderGroup) + { + πŸ“ + } else if (isRepoGroup) { βŒ₯ @@ -487,7 +542,33 @@ else {
- @if (isRepoGroup) + @if (isLocalFolderGroup) + { + var localFolderPath = group.LocalPath!; + + @if (!string.IsNullOrEmpty(group.RepoId)) + { + @* πŸ“ group backed by a bare clone β€” offer full branch/worktree features *@ + var lfRepoId = group.RepoId!; + var lfLocalPath = group.LocalPath!; + + + } + +
+ + } + else @if (isRepoGroup) { var rId = group.RepoId!;
- @if (quickBranchRepoId == group.RepoId && isRepoGroup) + @if (quickBranchRepoId == group.RepoId && (isRepoGroup || isLocalFolderGroup)) { var qbRepoId = group.RepoId!;
@@ -1246,9 +1327,16 @@ 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 = ""; + // 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; + private string? quickBranchGroupId = null; + private string? quickBranchLocalPath = null; private string quickBranchInput = ""; private bool quickBranchIsCreating = false; private string? quickBranchError = null; @@ -1761,7 +1849,32 @@ else } } - private async Task QuickCreateSessionForRepo(string repoId) + private async Task QuickCreateSessionForFolder(string groupId, string localPath) + { + if (isCreating) return; + isCreating = true; + createError = null; + try + { + var folderName = Path.GetFileName(localPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + var sessionName = $"{folderName}-{DateTime.Now:yyyyMMdd-HHmmss}"; + var sessionInfo = await CopilotService.CreateSessionAsync(sessionName, selectedModel, workingDirectory: localPath, groupId: groupId); + CopilotService.SwitchSession(sessionInfo.Name); + CopilotService.SaveUiState(currentPage, selectedModel: selectedModel); + await OnSessionSelected.InvokeAsync(); + } + catch (Exception ex) + { + createError = ex.Message; + Console.WriteLine($"Error creating session for folder: {ex}"); + } + finally + { + isCreating = false; + } + } + + private async Task QuickCreateSessionForRepo(string repoId, string? targetGroupId = null, string? localPath = null) { if (isCreating) return; isCreating = true; @@ -1772,7 +1885,9 @@ else { var sessionInfo = await CopilotService.CreateSessionWithWorktreeAsync( repoId: repoId, - model: selectedModel); + model: selectedModel, + targetGroupId: targetGroupId, + localPath: localPath); CopilotService.SaveUiState(currentPage, selectedModel: selectedModel); await OnSessionSelected.InvokeAsync(); } @@ -1789,9 +1904,11 @@ else } } - private void StartQuickBranch(string repoId) + private void StartQuickBranch(string repoId, string? targetGroupId = null, string? localPath = null) { quickBranchRepoId = repoId; + quickBranchGroupId = targetGroupId; + quickBranchLocalPath = localPath; quickBranchInput = ""; quickBranchError = null; } @@ -1799,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) @@ -1829,9 +1946,13 @@ else repoId: repoId, branchName: branchName, prNumber: prNumber, - model: selectedModel); + model: selectedModel, + targetGroupId: quickBranchGroupId, + localPath: quickBranchLocalPath); quickBranchRepoId = null; + quickBranchGroupId = null; + quickBranchLocalPath = null; quickBranchInput = ""; CopilotService.SaveUiState(currentPage, selectedModel: selectedModel); await OnSessionSelected.InvokeAsync(); @@ -2406,6 +2527,83 @@ 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 = ""; + addFolderMergedIntoGroupName = null; + 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; + addFolderMergedIntoGroupName = null; + isAddingRepo = true; + addRepoProgress = null; + try + { + var result = await CopilotService.AddRepoFromLocalFolderAsync(addRepoFolderPath.Trim(), progress => + { + addRepoProgress = progress; + InvokeAsync(StateHasChanged); + }); + if (result.ExistingGroupId != null) + { + // Merged into an existing repo group β€” show guidance instead of closing + addFolderMergedIntoGroupName = result.ExistingGroupName; + } + else + { + 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/Models/SessionOrganization.cs b/PolyPilot/Models/SessionOrganization.cs index 313a9b42..bb07611b 100644 --- a/PolyPilot/Models/SessionOrganization.cs +++ b/PolyPilot/Models/SessionOrganization.cs @@ -16,6 +16,17 @@ public class SessionGroup /// If set, this group auto-tracks a repository managed by RepoManager. public string? RepoId { get; set; } + /// + /// When set, this group represents a pinned local folder (e.g. ~/Projects/maui3). + /// Sessions in this group are created with this path as the working directory. + /// The folder is NOT backed by a PolyPilot-managed bare clone. + /// + public string? LocalPath { get; set; } + + /// True when this group represents a pinned local folder on disk. + [System.Text.Json.Serialization.JsonIgnore] + public bool IsLocalFolder => !string.IsNullOrWhiteSpace(LocalPath); + /// When true, this group operates as a multi-agent orchestration group. public bool IsMultiAgent { get; set; } diff --git a/PolyPilot/Services/CopilotService.Bridge.cs b/PolyPilot/Services/CopilotService.Bridge.cs index 4ac16404..2d022b19 100644 --- a/PolyPilot/Services/CopilotService.Bridge.cs +++ b/PolyPilot/Services/CopilotService.Bridge.cs @@ -577,6 +577,71 @@ 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. + /// + /// + /// 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) + { + 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."); + + // Expand ~ and normalize path before any validation + 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}'"); + + // Register/update the bare clone and external worktree for this local path + var repo = await _repoManager.AddRepositoryFromLocalAsync(localPath, onProgress, ct); + + // 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); + } + + // 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) { if (!IsRemoteMode) diff --git a/PolyPilot/Services/CopilotService.Organization.cs b/PolyPilot/Services/CopilotService.Organization.cs index 352dcf33..ef4f6733 100644 --- a/PolyPilot/Services/CopilotService.Organization.cs +++ b/PolyPilot/Services/CopilotService.Organization.cs @@ -318,13 +318,18 @@ internal void ReconcileOrganization(bool allowPruning = true) meta.WorktreeId = worktree.Id; _repoManager.LinkSessionToWorktree(worktree.Id, name); - // Move session to repo's group - var repo = _repoManager.Repositories.FirstOrDefault(r => r.Id == worktree.RepoId); - if (repo != null) + // Move session to repo's group β€” but skip if the session is already + // in a local folder group (those sessions stay in their local folder group). + var currentGroup = Organization.Groups.FirstOrDefault(g => g.Id == meta.GroupId); + if (currentGroup?.IsLocalFolder != true) { - var repoGroup = GetOrCreateRepoGroup(repo.Id, repo.Name); - if (repoGroup != null) - meta.GroupId = repoGroup.Id; + var repo = _repoManager.Repositories.FirstOrDefault(r => r.Id == worktree.RepoId); + if (repo != null) + { + var repoGroup = GetOrCreateRepoGroup(repo.Id, repo.Name); + if (repoGroup != null) + meta.GroupId = repoGroup.Id; + } } changed = true; } @@ -376,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 @@ -815,6 +844,49 @@ internal bool IsWorkerInMultiAgentGroup(string sessionName) return group; } + /// + /// 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, string? repoId = null) + { + var normalized = Path.GetFullPath(localPath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + var existing = Organization.Groups.FirstOrDefault(g => + g.IsLocalFolder && + !g.IsMultiAgent && + string.Equals( + Path.GetFullPath(g.LocalPath!).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + normalized, + StringComparison.OrdinalIgnoreCase)); + if (existing != null) + { + 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; + } + + var folderName = Path.GetFileName(normalized); + var group = new SessionGroup + { + Id = Guid.NewGuid().ToString(), + Name = folderName, + LocalPath = normalized, + RepoId = repoId, + SortOrder = Organization.Groups.Any() ? Organization.Groups.Max(g => g.SortOrder) + 1 : 0 + }; + AddGroup(group); + SaveOrganization(); + OnStateChanged?.Invoke(); + return group; + } + #endregion #region Multi-Agent Orchestration @@ -2219,7 +2291,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 { diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 90e67ccd..5b97f12f 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -2540,6 +2540,8 @@ public async Task CreateSessionWithWorktreeAsync( string? sessionName = null, 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. @@ -2603,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; @@ -2640,9 +2642,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 8a5292a4..dd9032f7 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. @@ -528,22 +531,139 @@ 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 also appears in the + // "πŸ“‚ Existing" picker when creating repo-based sessions. + 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); } + + 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(); + } - return await AddRepositoryAsync(remoteUrl, ct); + /// + /// 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; + } } /// /// 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) @@ -562,9 +682,31 @@ 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); + + // 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 + { + // Centralized strategy: place worktree in ~/.polypilot/worktrees/{repoId}-{guid8}/ + Directory.CreateDirectory(WorktreesDir); + worktreePath = Path.Combine(WorktreesDir, $"{repoId}-{worktreeId}"); + } try { @@ -597,6 +739,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, @@ -735,10 +908,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 { } }