diff --git a/PolyPilot.Tests/AddExistingRepoTests.cs b/PolyPilot.Tests/AddExistingRepoTests.cs new file mode 100644 index 0000000000..825a23f7a5 --- /dev/null +++ b/PolyPilot.Tests/AddExistingRepoTests.cs @@ -0,0 +1,354 @@ +using PolyPilot.Models; +using PolyPilot.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace PolyPilot.Tests; + +/// +/// Tests for the "Add Existing Repository" flow (AddRepositoryFromLocalAsync). +/// Covers two bugs: +/// 1. Adding an existing local repo should clone from the local path, not the remote URL. +/// 2. ReconcileOrganization should prefer a local folder group over creating a duplicate URL-based group. +/// +[Collection("BaseDir")] +public class AddExistingRepoTests +{ + private readonly StubChatDatabase _chatDb = new(); + private readonly StubServerManager _serverManager = new(); + private readonly StubWsBridgeClient _bridgeClient = new(); + private readonly StubDemoService _demoService = new(); + private readonly IServiceProvider _serviceProvider; + + public AddExistingRepoTests() + { + var services = new ServiceCollection(); + _serviceProvider = services.BuildServiceProvider(); + } + + private static RepoManager CreateRepoManagerWithState(List repos, List worktrees) + { + var rm = new RepoManager(); + 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)!; + stateField.SetValue(rm, new RepositoryState { Repositories = repos, Worktrees = worktrees }); + loadedField.SetValue(rm, true); + return rm; + } + + private CopilotService CreateService(RepoManager? repoManager = null) => + new CopilotService(_chatDb, _serverManager, _bridgeClient, repoManager ?? new RepoManager(), _serviceProvider, _demoService); + + /// + /// Injects a SessionState with a specific working directory so ReconcileOrganization + /// can match it to a worktree via workingDir.StartsWith(w.Path). + /// + private static void AddDummySessionWithWorkingDir(CopilotService svc, string sessionName, string workingDirectory) + { + var sessionsField = typeof(CopilotService).GetField("_sessions", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + var dict = sessionsField.GetValue(svc)!; + var stateType = sessionsField.FieldType.GenericTypeArguments[1]; // SessionState + + var info = new AgentSessionInfo + { + Name = sessionName, + Model = "test-model", + WorkingDirectory = workingDirectory + }; + var state = System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(stateType); + stateType.GetProperty("Info")!.SetValue(state, info); + dict.GetType().GetMethod("TryAdd")!.Invoke(dict, new[] { sessionName, (object)state }); + } + + // ─── Bug 2: ReconcileOrganization should prefer local folder groups ──────── + + [Fact] + public void Reconcile_SessionInDefault_WithLocalFolderGroupOnly_AssignsToLocalFolderGroup() + { + // Bug scenario: user added a repo via "Existing folder" (only a local folder group exists). + // A new session whose working dir matches the worktree should be assigned to the + // local folder group — NOT cause a new URL-based repo group to be created. + var localRepoPath = Path.Combine(Path.GetTempPath(), "MAUI.Sherpa"); + var nestedWtPath = Path.Combine(localRepoPath, ".polypilot", "worktrees", "session-1"); + + var repos = new List + { + new() { Id = "redth-MAUI.Sherpa", Name = "MAUI.Sherpa", Url = "https://github.com/redth/MAUI.Sherpa" } + }; + var worktrees = new List + { + new() { Id = "ext-1", RepoId = "redth-MAUI.Sherpa", Branch = "main", Path = localRepoPath }, + new() { Id = "wt-1", RepoId = "redth-MAUI.Sherpa", Branch = "feature", Path = nestedWtPath } + }; + var rm = CreateRepoManagerWithState(repos, worktrees); + var svc = CreateService(rm); + + // Only a local folder group exists (as when user added via "Existing folder") + var localGroup = svc.GetOrCreateLocalFolderGroup(localRepoPath, "redth-MAUI.Sherpa"); + Assert.True(localGroup.IsLocalFolder); + + // Session starts in default group, working in a nested worktree + var meta = new SessionMeta + { + SessionName = "MAUI.Sherpa", + GroupId = SessionGroup.DefaultId + }; + svc.Organization.Sessions.Add(meta); + AddDummySessionWithWorkingDir(svc, "MAUI.Sherpa", nestedWtPath); + + // Before reconcile: no URL-based group exists + var urlGroupsBefore = svc.Organization.Groups.Count(g => g.RepoId == "redth-MAUI.Sherpa" && !g.IsLocalFolder); + Assert.Equal(0, urlGroupsBefore); + + svc.ReconcileOrganization(); + + // After reconcile: session should be in the local folder group + var updatedMeta = svc.Organization.Sessions.First(m => m.SessionName == "MAUI.Sherpa"); + Assert.Equal(localGroup.Id, updatedMeta.GroupId); + + // No URL-based repo group should have been created + var urlGroupsAfter = svc.Organization.Groups.Count(g => g.RepoId == "redth-MAUI.Sherpa" && !g.IsLocalFolder && !g.IsMultiAgent); + Assert.Equal(0, urlGroupsAfter); + } + + [Fact] + public void Reconcile_SessionInDefault_WithBothGroupTypes_PrefersLocalFolderGroup() + { + // When both a local folder group and a URL-based group exist for the same repo, + // ReconcileOrganization should prefer the local folder group for unassigned sessions. + var localRepoPath = Path.Combine(Path.GetTempPath(), "MyProject"); + var nestedWtPath = Path.Combine(localRepoPath, ".polypilot", "worktrees", "feature-x"); + + var repos = new List + { + new() { Id = "repo-1", Name = "MyProject", Url = "https://github.com/test/myproject" } + }; + var worktrees = new List + { + new() { Id = "ext-1", RepoId = "repo-1", Branch = "main", Path = localRepoPath }, + new() { Id = "wt-1", RepoId = "repo-1", Branch = "feature-x", Path = nestedWtPath } + }; + var rm = CreateRepoManagerWithState(repos, worktrees); + var svc = CreateService(rm); + + // Both group types exist — local folder group takes priority + svc.GetOrCreateRepoGroup("repo-1", "MyProject"); + var localGroup = svc.GetOrCreateLocalFolderGroup(localRepoPath, "repo-1"); + + var meta = new SessionMeta + { + SessionName = "test-session", + GroupId = SessionGroup.DefaultId + }; + svc.Organization.Sessions.Add(meta); + AddDummySessionWithWorkingDir(svc, "test-session", nestedWtPath); + + svc.ReconcileOrganization(); + + var updated = svc.Organization.Sessions.First(m => m.SessionName == "test-session"); + Assert.Equal(localGroup.Id, updated.GroupId); + } + + [Fact] + public void Reconcile_SessionInDefault_WithOnlyUrlGroup_FallsBackToUrlGroup() + { + // When only a URL-based repo group exists (no local folder group), the session + // should be assigned to the URL-based group as before (existing behavior preserved). + var nestedWtPath = Path.Combine(Path.GetTempPath(), "worktrees", "feature-x"); + + var repos = new List + { + new() { Id = "repo-1", Name = "MyProject", Url = "https://github.com/test/myproject" } + }; + var worktrees = new List + { + new() { Id = "wt-1", RepoId = "repo-1", Branch = "feature-x", Path = nestedWtPath } + }; + var rm = CreateRepoManagerWithState(repos, worktrees); + var svc = CreateService(rm); + + // Only URL-based group exists + var urlGroup = svc.GetOrCreateRepoGroup("repo-1", "MyProject"); + + var meta = new SessionMeta + { + SessionName = "test-session", + GroupId = SessionGroup.DefaultId + }; + svc.Organization.Sessions.Add(meta); + AddDummySessionWithWorkingDir(svc, "test-session", nestedWtPath); + + svc.ReconcileOrganization(); + + var updated = svc.Organization.Sessions.First(m => m.SessionName == "test-session"); + Assert.Equal(urlGroup!.Id, updated.GroupId); + } + + // ─── Bug 1: AddRepositoryAsync supports local clone source ───────────────── + + [Fact] + public async Task AddRepositoryFromLocal_ClonesLocallyAndSetsRemoteUrl() + { + // Create a real local git repo with an origin remote, then call + // AddRepositoryFromLocalAsync and verify the bare clone's remote URL + // is the network URL (not the local path). + var tempDir = Path.Combine(Path.GetTempPath(), $"local-clone-test-{Guid.NewGuid():N}"); + var testBaseDir = Path.Combine(Path.GetTempPath(), $"rmtest-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + Directory.CreateDirectory(testBaseDir); + try + { + var remoteUrl = "https://github.com/test-owner/local-clone-test.git"; + + await RunProcess("git", "init", tempDir); + await RunProcess("git", "-C", tempDir, "config", "user.email", "test@test.com"); + await RunProcess("git", "-C", tempDir, "config", "user.name", "Test"); + await RunProcess("git", "-C", tempDir, "commit", "--allow-empty", "-m", "init"); + await RunProcess("git", "-C", tempDir, "remote", "add", "origin", remoteUrl); + + var rm = new RepoManager(); + RepoManager.SetBaseDirForTesting(testBaseDir); + try + { + var progressMessages = new List(); + var repo = await rm.AddRepositoryFromLocalAsync( + tempDir, msg => progressMessages.Add(msg)); + + // Should have used local clone, not network + Assert.Contains(progressMessages, m => m.Contains("local folder", StringComparison.OrdinalIgnoreCase)); + + // The bare clone's remote origin should point to the network URL + var bareRemoteUrl = await RunGitOutput(repo.BareClonePath, "remote", "get-url", "origin"); + Assert.Equal(remoteUrl, bareRemoteUrl.Trim()); + + // Verify the repo was registered + Assert.Contains(rm.Repositories, r => r.Id == repo.Id); + } + finally + { + RepoManager.SetBaseDirForTesting(TestSetup.TestBaseDir); + } + } + finally + { + ForceDeleteDirectory(tempDir); + ForceDeleteDirectory(testBaseDir); + } + } + + [Fact] + public async Task AddRepositoryAsync_LocalCloneSource_InvalidPath_Throws() + { + var rm = new RepoManager(); + var method = typeof(RepoManager).GetMethod("AddRepositoryAsync", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, + null, + new[] { typeof(string), typeof(Action), typeof(string), typeof(CancellationToken) }, + null)!; + + var ex = await Assert.ThrowsAsync(async () => + await (Task)method.Invoke(rm, + new object?[] { "https://github.com/test/repo", null, "/nonexistent/path", CancellationToken.None })!); + + Assert.Contains("not found", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + // ─── Bug 2 (second block): WorktreeId-based reconcile prefers local folder ─ + + [Fact] + public void Reconcile_SessionWithWorktreeId_InDefault_WithLocalFolderGroup_AssignsToLocalGroup() + { + // When a session has a WorktreeId but is in the Default group (e.g., after + // group deletion healing), ReconcileOrganization should prefer the local + // folder group over creating a duplicate URL-based group. + var localRepoPath = Path.Combine(Path.GetTempPath(), "WorktreeIdTest"); + var nestedWtPath = Path.Combine(localRepoPath, ".polypilot", "worktrees", "wt-1"); + + var repos = new List + { + new() { Id = "test-wt-repo", Name = "WorktreeIdTest", Url = "https://github.com/test/worktreeidtest" } + }; + var worktrees = new List + { + new() { Id = "ext-1", RepoId = "test-wt-repo", Branch = "main", Path = localRepoPath }, + new() { Id = "wt-1", RepoId = "test-wt-repo", Branch = "feature", Path = nestedWtPath } + }; + var rm = CreateRepoManagerWithState(repos, worktrees); + var svc = CreateService(rm); + + // Create local folder group (as when user added via "Existing folder") + var localGroup = svc.GetOrCreateLocalFolderGroup(localRepoPath, "test-wt-repo"); + + // Session has a WorktreeId but is in Default (simulates group-deletion healing) + var meta = new SessionMeta + { + SessionName = "healed-session", + GroupId = SessionGroup.DefaultId, + WorktreeId = "wt-1" + }; + svc.Organization.Sessions.Add(meta); + AddDummySessionWithWorkingDir(svc, "healed-session", nestedWtPath); + + svc.ReconcileOrganization(); + + // Session should land in the local folder group, not a new URL-based group + var updated = svc.Organization.Sessions.First(m => m.SessionName == "healed-session"); + Assert.Equal(localGroup.Id, updated.GroupId); + + // No URL-based repo group should have been created + var urlGroups = svc.Organization.Groups.Count(g => + g.RepoId == "test-wt-repo" && !g.IsLocalFolder && !g.IsMultiAgent); + Assert.Equal(0, urlGroups); + } + + // ─── Helpers ─────────────────────────────────────────────────────────────── + + 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); + 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; + } + + private static async Task RunGitOutput(string workingDir, params string[] args) + { + var psi = new System.Diagnostics.ProcessStartInfo("git") + { + WorkingDirectory = workingDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + foreach (var a in args) psi.ArgumentList.Add(a); + using var p = System.Diagnostics.Process.Start(psi)!; + var output = await p.StandardOutput.ReadToEndAsync(); + await p.WaitForExitAsync(); + if (p.ExitCode != 0) + throw new Exception($"git exited with {p.ExitCode}"); + return output; + } + + private static void ForceDeleteDirectory(string path) + { + if (!Directory.Exists(path)) + return; + foreach (var f in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) + File.SetAttributes(f, FileAttributes.Normal); + Directory.Delete(path, true); + } +} diff --git a/PolyPilot/Services/CopilotService.Organization.cs b/PolyPilot/Services/CopilotService.Organization.cs index 37d39ba157..b5cb588bb2 100644 --- a/PolyPilot/Services/CopilotService.Organization.cs +++ b/PolyPilot/Services/CopilotService.Organization.cs @@ -573,9 +573,21 @@ internal void ReconcileOrganization(bool allowPruning = true) 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; + // Prefer an existing local folder group for this repo over creating + // a new URL-based repo group. This prevents duplicate sidebar entries + // when the user added the repo via "Existing folder". + var localFolderGroup = Organization.Groups.FirstOrDefault(g => + g.RepoId == repo.Id && g.IsLocalFolder && !g.IsMultiAgent); + if (localFolderGroup != null) + { + meta.GroupId = localFolderGroup.Id; + } + else + { + var repoGroup = GetOrCreateRepoGroup(repo.Id, repo.Name); + if (repoGroup != null) + meta.GroupId = repoGroup.Id; + } } } changed = true; @@ -595,12 +607,24 @@ internal void ReconcileOrganization(bool allowPruning = true) var repo = _repoManager.Repositories.FirstOrDefault(r => r.Id == worktree.RepoId); if (repo != null) { - var repoGroup = GetOrCreateRepoGroup(repo.Id, repo.Name); - if (repoGroup != null) + // Prefer an existing local folder group (same fix as the + // workingDir-based block above) to avoid duplicate sidebar entries. + var localFolderGroup = Organization.Groups.FirstOrDefault(g => + g.RepoId == repo.Id && g.IsLocalFolder && !g.IsMultiAgent); + if (localFolderGroup != null) { - meta.GroupId = repoGroup.Id; + meta.GroupId = localFolderGroup.Id; changed = true; } + else + { + var repoGroup = GetOrCreateRepoGroup(repo.Id, repo.Name); + if (repoGroup != null) + { + meta.GroupId = repoGroup.Id; + changed = true; + } + } } } } diff --git a/PolyPilot/Services/RepoManager.cs b/PolyPilot/Services/RepoManager.cs index 727415bed4..a05e162413 100644 --- a/PolyPilot/Services/RepoManager.cs +++ b/PolyPilot/Services/RepoManager.cs @@ -472,7 +472,17 @@ public Task AddRepositoryAsync(string url, CancellationToken ct => AddRepositoryAsync(url, null, ct); public async Task AddRepositoryAsync(string url, Action? onProgress, CancellationToken ct = default) + => await AddRepositoryAsync(url, onProgress, localCloneSource: null, ct); + + /// + /// When non-null, clone from this local path instead of the remote URL. + /// The remote origin is then set to so future fetches go to the network. + /// This avoids a redundant network clone when the user adds an existing local repository. + /// + internal async Task AddRepositoryAsync(string url, Action? onProgress, string? localCloneSource, CancellationToken ct = default) { + if (localCloneSource != null && !Directory.Exists(localCloneSource)) + throw new ArgumentException($"Local clone source not found: '{localCloneSource}'", nameof(localCloneSource)); url = NormalizeRepoUrl(url); EnsureLoaded(); var id = RepoIdFromUrl(url); @@ -494,6 +504,21 @@ public async Task AddRepositoryAsync(string url, Action? "+refs/heads/*:refs/remotes/origin/*"); } catch { } await RunGitWithProgressAsync(barePath, onProgress, ct, "fetch", "--progress", "origin"); } + else if (localCloneSource != null) + { + // Clone from local path — fast, no network required. + // Intentionally skips the network fetch that the remote-clone branch does: + // the bare repo mirrors whatever the user's local repo already has. + // Missing remote-only branches (if any) will appear on the next scheduled fetch. + onProgress?.Invoke("Cloning from local folder…"); + await RunGitWithProgressAsync(null, onProgress, ct, "clone", "--bare", "--progress", localCloneSource, barePath); + + // Point remote origin at the real remote URL so future fetches go to the network + await RunGitAsync(barePath, ct, "remote", "set-url", "origin", url); + + await RunGitAsync(barePath, ct, "config", "remote.origin.fetch", + "+refs/heads/*:refs/remotes/origin/*"); + } else { onProgress?.Invoke($"Cloning {url}…"); @@ -577,7 +602,7 @@ public async Task AddRepositoryFromLocalAsync( $"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); + var repo = await AddRepositoryAsync(remoteUrl, onProgress, localCloneSource: localPath, ct); // Register the local folder as an external worktree so it also appears in the // "📂 Existing" picker when creating repo-based sessions.