diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e73e5c2d9..b70101ba8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -23,11 +23,15 @@ When the user says **"Run the SDK update audit"** or updates the SDK NuGet packa ### Mac Catalyst (primary dev target) ```bash -./relaunch.sh # Build + async hot-relaunch (ALWAYS use this after code changes) -./relaunch.sh --sync # Build + blocking relaunch (for interactive terminal use only) +PolyPilot/relaunch.sh # Build + async hot-relaunch (ALWAYS use this after code changes) +PolyPilot/relaunch.sh --sync # Build + blocking relaunch (for interactive terminal use only) dotnet build -f net10.0-maccatalyst # Build only ``` +> **Run relaunch.sh from YOUR worktree**, not from `~/Projects/AutoPilot/PolyPilot/`. +> The script is tracked in git at `PolyPilot/relaunch.sh` and uses `dirname "$0"` to +> resolve its build directory, so each worktree's copy builds its own code. + #### ⚠️ Relaunch from a Copilot agent session `relaunch.sh` is **async by default** — it returns immediately after a successful build, then kills the old UI and launches the new one in a detached background process after a 10-second delay. This is critical because PolyPilot hosts the Copilot sessions via TCP to the persistent CLI server. If the script blocked and killed the UI synchronously, the TCP connection would drop mid-tool-call and the agent's turn would be interrupted. @@ -44,7 +48,7 @@ dotnet build -f net10.0-maccatalyst # Build only **Correct pattern — keep working after relaunch:** ```bash # Tool call 1: relaunch (returns immediately after build) -./relaunch.sh +PolyPilot/relaunch.sh ``` After relaunch.sh returns, the old UI will be killed in ~10s and a new one launched. Your turn may get interrupted if a tool call is in-flight when the kill happens — that's OK, @@ -61,10 +65,10 @@ maui devflow cdp Runtime evaluate '...' **NEVER do this:** ```bash # ❌ WRONG — chaining in the same bash call blocks the tool return -./relaunch.sh && sleep 15 && cat ~/.polypilot/relaunch.log +PolyPilot/relaunch.sh && sleep 15 && cat ~/.polypilot/relaunch.log # ❌ WRONG — sleep/long commands chained after relaunch -./relaunch.sh; sleep 10; tail ~/.polypilot/relaunch.log +PolyPilot/relaunch.sh; sleep 10; tail ~/.polypilot/relaunch.log ``` The `--sync` flag restores the old blocking behavior (for human terminal use only — NEVER use from an agent). diff --git a/PolyPilot.Tests/SessionOrganizationTests.cs b/PolyPilot.Tests/SessionOrganizationTests.cs index db7edf884..1565a2889 100644 --- a/PolyPilot.Tests/SessionOrganizationTests.cs +++ b/PolyPilot.Tests/SessionOrganizationTests.cs @@ -960,34 +960,48 @@ public void PromoteOrCreateLocalFolderGroup_PreservesUserGroupName_WhenFolderNam } [Fact] - public void ReconcileOrganization_ExternalWorktree_PreservesGroupName_WhenPromoting() + public void ReconcileOrganization_ExternalWorktree_DoesNotPromoteUrlGroup() { - // Regression test: ReconcileOrganization's external worktree migration must - // preserve the group's name when promoting it to a local folder group. + // ReconcileOrganization must never promote a URL-based group to a local folder + // group. Instead, it creates a separate local folder group for the external path. + // This prevents the "3 maui groups" bug where promotion + session migration + // created multiple groups for the same repo. var repos = new List { new() { Id = "dotnet-maui", Name = "maui", Url = "https://github.com/dotnet/maui" } }; // External worktree folder name is "maui2" — differs from group name "maui" - // Note: path need not exist on disk; ReconcileOrganization matches on repoId, not disk state. - var extPath = Path.Combine(Path.GetTempPath(), "maui2"); - var worktrees = new List + var extPath = Path.Combine(Path.GetTempPath(), $"test-maui2-{Guid.NewGuid():N}"); + Directory.CreateDirectory(extPath); + try { - new() { Id = "ext-1", RepoId = "dotnet-maui", Branch = "main", Path = extPath } - }; - var rm = CreateRepoManagerWithState(repos, worktrees); - var svc = CreateService(rm); - - // Create a URL-based group named "maui" (user's custom name) - var urlGroup = svc.GetOrCreateRepoGroup("dotnet-maui", "maui"); - Assert.Equal("maui", urlGroup!.Name); - - svc.ReconcileOrganization(); - - var promoted = svc.Organization.Groups.First(g => g.Id == urlGroup.Id); - Assert.True(promoted.IsLocalFolder); - // Name must be "maui" — NOT "maui2" (the folder basename) - Assert.Equal("maui", promoted.Name); + var worktrees = new List + { + new() { Id = "ext-1", RepoId = "dotnet-maui", Branch = "main", Path = extPath } + }; + var rm = CreateRepoManagerWithState(repos, worktrees); + var svc = CreateService(rm); + + // Create a URL-based group named "maui" (user's custom name) + var urlGroup = svc.GetOrCreateRepoGroup("dotnet-maui", "maui"); + Assert.Equal("maui", urlGroup!.Name); + + svc.ReconcileOrganization(); + + // URL group must remain URL-based — NOT promoted + var originalGroup = svc.Organization.Groups.First(g => g.Id == urlGroup.Id); + Assert.False(originalGroup.IsLocalFolder); + Assert.Equal("maui", originalGroup.Name); + + // A separate local folder group should be created for the external path + var localGroup = svc.Organization.Groups.FirstOrDefault(g => + g.IsLocalFolder && g.LocalPath != null && + string.Equals(Path.GetFullPath(g.LocalPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + Path.GetFullPath(extPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase)); + Assert.NotNull(localGroup); + } + finally { try { Directory.Delete(extPath, true); } catch { } } } [Fact] @@ -1033,39 +1047,47 @@ public void PromoteOrCreateLocalFolderGroup_FallsBackToFolderName_WhenGroupNameI } [Fact] - public void ReconcileOrganization_ExternalWorktree_PromotesUrlGroupToLocalFolderGroup() + public void ReconcileOrganization_ExternalWorktree_CreatesLocalFolderGroup_LeavesUrlGroupAlone() { - // Regression test: on startup, ReconcileOrganization should automatically promote - // URL-based groups to local folder groups when an external worktree is registered - // but no local folder group exists yet. + // ReconcileOrganization should create a separate local folder group for the + // external worktree path WITHOUT modifying the existing URL-based group. var repos = new List { new() { Id = "repo-1", Name = "MyRepo", Url = "https://github.com/test/repo" } }; - // Use cross-platform temp paths to avoid Windows-only literal failures on macOS/Linux - var extPath = Path.Combine(Path.GetTempPath(), "MyRepo"); + var extPath = Path.Combine(Path.GetTempPath(), $"test-MyRepo-{Guid.NewGuid():N}"); var centralPath = Path.Combine(Path.GetTempPath(), ".polypilot", "worktrees", "repo-1-wt1"); - var worktrees = new List + Directory.CreateDirectory(extPath); + try { - // External: user's local folder, NOT under the managed worktrees dir and NOT nested - new() { Id = "ext-1", RepoId = "repo-1", Branch = "main", Path = extPath }, - // Centralized: under the managed worktrees dir (simulated by putting it under .polypilot/worktrees) - new() { Id = "wt-1", RepoId = "repo-1", Branch = "session-123", Path = centralPath } - }; - var rm = CreateRepoManagerWithState(repos, worktrees); - var svc = CreateService(rm); - - // Set up: a URL-based group (no LocalPath) — simulates old code behavior - var urlGroup = svc.GetOrCreateRepoGroup("repo-1", "MyRepo"); - Assert.False(urlGroup!.IsLocalFolder); - - // Run reconciliation — it should detect the external worktree and promote urlGroup - svc.ReconcileOrganization(); - - var promoted = svc.Organization.Groups.First(g => g.Id == urlGroup.Id); - Assert.True(promoted.IsLocalFolder); - Assert.Equal(Path.GetFullPath(extPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), - promoted.LocalPath); + var worktrees = new List + { + new() { Id = "ext-1", RepoId = "repo-1", Branch = "main", Path = extPath }, + new() { Id = "wt-1", RepoId = "repo-1", Branch = "session-123", Path = centralPath } + }; + var rm = CreateRepoManagerWithState(repos, worktrees); + var svc = CreateService(rm); + + // Set up: a URL-based group (no LocalPath) — simulates old code behavior + var urlGroup = svc.GetOrCreateRepoGroup("repo-1", "MyRepo"); + Assert.False(urlGroup!.IsLocalFolder); + + svc.ReconcileOrganization(); + + // URL group must remain URL-based + var originalGroup = svc.Organization.Groups.First(g => g.Id == urlGroup.Id); + Assert.False(originalGroup.IsLocalFolder); + + // A new local folder group should exist for the external path + var normalizedExt = Path.GetFullPath(extPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var localGroup = svc.Organization.Groups.FirstOrDefault(g => + g.IsLocalFolder && g.LocalPath != null && + string.Equals( + Path.GetFullPath(g.LocalPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + normalizedExt, StringComparison.OrdinalIgnoreCase)); + Assert.NotNull(localGroup); + } + finally { try { Directory.Delete(extPath, true); } catch { } } } [Fact] @@ -1206,84 +1228,229 @@ public void ReconcileOrganization_NestedWorktree_IsNotTreatedAsExternalWorktree( } [Fact] - public void ReconcileOrganization_Promotion_MigratesNonLocalSessions() + public void ReconcileOrganization_NoPromotion_SessionsStayInUrlGroup() { - // When a URL-based group is promoted to a local folder group, sessions whose - // worktree paths are NOT under the new LocalPath should be migrated to a - // fresh URL-based group instead of being stranded in the local folder group. + // With the no-promotion fix, sessions in a URL-based group stay exactly where + // they are even when external worktrees exist. No migration is needed. var repos = new List { new() { Id = "repo-1", Name = "MyRepo", Url = "https://github.com/test/repo" } }; - var extPath = Path.Combine(Path.GetTempPath(), "MyRepo"); + var extPath = Path.Combine(Path.GetTempPath(), $"test-MyRepo-{Guid.NewGuid():N}"); var managedPath = Path.Combine(Path.GetTempPath(), ".polypilot", "worktrees", "repo-1-wt1"); - var worktrees = new List + Directory.CreateDirectory(extPath); + try + { + var worktrees = new List + { + new() { Id = "ext-1", RepoId = "repo-1", Branch = "main", Path = extPath }, + new() { Id = "managed-1", RepoId = "repo-1", Branch = "feature-x", Path = managedPath } + }; + var rm = CreateRepoManagerWithState(repos, worktrees); + var svc = CreateService(rm); + + // Create URL-based group and put a session with a managed worktree in it + var urlGroup = svc.GetOrCreateRepoGroup("repo-1", "MyRepo"); + svc.Organization.Sessions.Add(new SessionMeta + { + SessionName = "managed-session", + GroupId = urlGroup!.Id, + WorktreeId = "managed-1" + }); + + typeof(CopilotService).GetProperty("IsInitialized")!.SetValue(svc, true); + svc.ReconcileOrganization(allowPruning: false); + + // URL group must remain URL-based + var originalGroup = svc.Organization.Groups.First(g => g.Id == urlGroup.Id); + Assert.False(originalGroup.IsLocalFolder); + + // Session must stay in the URL group — NOT migrated anywhere + var meta = svc.Organization.Sessions.First(m => m.SessionName == "managed-session"); + Assert.Equal(urlGroup.Id, meta.GroupId); + } + finally { try { Directory.Delete(extPath, true); } catch { } } + } + + [Fact] + public void ReconcileOrganization_NoPromotion_NestedWorktreeSession_StaysInUrlGroup() + { + // Sessions with nested worktrees (under the external path's .polypilot/worktrees) + // stay in the URL group — no promotion happens. The external path must exist on + // disk so the Directory.Exists guard doesn't short-circuit the test. + var repos = new List { - new() { Id = "ext-1", RepoId = "repo-1", Branch = "main", Path = extPath }, - new() { Id = "managed-1", RepoId = "repo-1", Branch = "feature-x", Path = managedPath } + new() { Id = "repo-1", Name = "MyRepo", Url = "https://github.com/test/repo" } }; - var rm = CreateRepoManagerWithState(repos, worktrees); - var svc = CreateService(rm); + var extPath = Path.Combine(Path.GetTempPath(), $"test-MyRepo-{Guid.NewGuid():N}"); + var nestedPath = Path.Combine(extPath, ".polypilot", "worktrees", "feature-y"); + Directory.CreateDirectory(extPath); + try + { + var worktrees = new List + { + new() { Id = "ext-1", RepoId = "repo-1", Branch = "main", Path = extPath }, + new() { Id = "nested-1", RepoId = "repo-1", Branch = "feature-y", Path = nestedPath } + }; + var rm = CreateRepoManagerWithState(repos, worktrees); + var svc = CreateService(rm); - // Create URL-based group and put a session with a managed worktree in it - var urlGroup = svc.GetOrCreateRepoGroup("repo-1", "MyRepo"); - svc.Organization.Sessions.Add(new SessionMeta + var urlGroup = svc.GetOrCreateRepoGroup("repo-1", "MyRepo"); + svc.Organization.Sessions.Add(new SessionMeta + { + SessionName = "nested-session", + GroupId = urlGroup!.Id, + WorktreeId = "nested-1" + }); + + typeof(CopilotService).GetProperty("IsInitialized")!.SetValue(svc, true); + svc.ReconcileOrganization(allowPruning: false); + + // URL group must remain URL-based + var originalGroup = svc.Organization.Groups.First(g => g.Id == urlGroup.Id); + Assert.False(originalGroup.IsLocalFolder); + + // The session stays in the URL group — no promotion or migration occurred + var meta = svc.Organization.Sessions.First(m => m.SessionName == "nested-session"); + Assert.Equal(urlGroup.Id, meta.GroupId); + } + finally { try { Directory.Delete(extPath, true); } catch { } } + } + + [Fact] + public void ReconcileOrganization_MultipleExternalWorktrees_SameRepo_CreatesGroupPerPath() + { + // When multiple external worktrees exist for the same repo (e.g., ~/Projects/maui2 + // and ~/Projects/maui3), ReconcileOrganization should create a separate local + // folder group for each path — NOT modify the URL-based group. + var repos = new List { - SessionName = "managed-session", - GroupId = urlGroup!.Id, - WorktreeId = "managed-1" - }); + new() { Id = "dotnet-maui", Name = "maui", Url = "https://github.com/dotnet/maui" } + }; + var extPath1 = Path.Combine(Path.GetTempPath(), $"test-maui2-{Guid.NewGuid():N}"); + var extPath2 = Path.Combine(Path.GetTempPath(), $"test-maui3-{Guid.NewGuid():N}"); + Directory.CreateDirectory(extPath1); + Directory.CreateDirectory(extPath2); + try + { + var worktrees = new List + { + new() { Id = "ext-1", RepoId = "dotnet-maui", Branch = "main", Path = extPath1 }, + new() { Id = "ext-2", RepoId = "dotnet-maui", Branch = "feature", Path = extPath2 } + }; + var rm = CreateRepoManagerWithState(repos, worktrees); + var svc = CreateService(rm); - // Must set IsInitialized or the guard skips reconciliation when Sessions.Count > 0 - typeof(CopilotService).GetProperty("IsInitialized")!.SetValue(svc, true); + var urlGroup = svc.GetOrCreateRepoGroup("dotnet-maui", "maui"); + // Add sessions to the URL group + svc.Organization.Sessions.Add(new SessionMeta { SessionName = "s1", GroupId = urlGroup!.Id }); + svc.Organization.Sessions.Add(new SessionMeta { SessionName = "s2", GroupId = urlGroup.Id }); - // Run reconcile — should promote urlGroup to local folder AND migrate managed-session out - svc.ReconcileOrganization(allowPruning: false); + typeof(CopilotService).GetProperty("IsInitialized")!.SetValue(svc, true); + svc.ReconcileOrganization(allowPruning: false); - // The promoted group should now be a local folder - var promoted = svc.Organization.Groups.First(g => g.Id == urlGroup.Id); - Assert.True(promoted.IsLocalFolder); + // URL group must remain URL-based with all its sessions + var originalGroup = svc.Organization.Groups.First(g => g.Id == urlGroup.Id); + Assert.False(originalGroup.IsLocalFolder); + Assert.Equal(2, svc.Organization.Sessions.Count(m => m.GroupId == urlGroup.Id)); + + // Two separate local folder groups should exist + var localGroups = svc.Organization.Groups.Where(g => g.IsLocalFolder && g.RepoId == "dotnet-maui").ToList(); + Assert.Equal(2, localGroups.Count); + } + finally + { + try { Directory.Delete(extPath1, true); } catch { } + try { Directory.Delete(extPath2, true); } catch { } + } + } + + [Fact] + public void AutoAssignment_SiblingPaths_DoesNotCrossMatch() + { + // Regression test: /Users/alice/maui must NOT match a group with + // LocalPath=/Users/alice/maui2 (and vice versa). StartsWith without + // a trailing separator causes false prefix matches. + var basePath = Path.Combine(Path.GetTempPath(), $"test-sibling-{Guid.NewGuid():N}"); + var mauiPath = Path.Combine(basePath, "maui"); + var maui2Path = Path.Combine(basePath, "maui2"); + Directory.CreateDirectory(mauiPath); + Directory.CreateDirectory(maui2Path); + try + { + var repos = new List + { + new() { Id = "dotnet-maui", Name = "maui", Url = "https://github.com/dotnet/maui" } + }; + // Worktree under maui2, NOT maui + var worktrees = new List + { + new() { Id = "wt-maui2", RepoId = "dotnet-maui", Branch = "feature", Path = maui2Path } + }; + var rm = CreateRepoManagerWithState(repos, worktrees); + var svc = CreateService(rm); + + // Create both local folder groups + var mauiGroup = svc.GetOrCreateLocalFolderGroup(mauiPath, "dotnet-maui"); + var maui2Group = svc.GetOrCreateLocalFolderGroup(maui2Path, "dotnet-maui"); + + // Add active session (must be in _sessions for auto-assignment to trigger) + 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]; + var info = new AgentSessionInfo { Name = "s1", Model = "test-model" }; + var state = System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(stateType); + stateType.GetProperty("Info")!.SetValue(state, info); + dict.GetType().GetMethod("TryAdd")!.Invoke(dict, new[] { "s1", state }); + // Add session meta in _default with WorktreeId already linked to maui2 + svc.Organization.Sessions.Add(new SessionMeta + { + SessionName = "s1", + GroupId = "_default", + WorktreeId = "wt-maui2" + }); + + typeof(CopilotService).GetProperty("IsInitialized")!.SetValue(svc, true); + svc.ReconcileOrganization(allowPruning: false); - // managed-session should NOT be in the promoted group — it should be in a new URL-based group - var meta = svc.Organization.Sessions.First(m => m.SessionName == "managed-session"); - Assert.NotEqual(urlGroup.Id, meta.GroupId); - var newGroup = svc.Organization.Groups.First(g => g.Id == meta.GroupId); - Assert.False(newGroup.IsLocalFolder); - Assert.Equal("repo-1", newGroup.RepoId); + // Auto-assignment should place s1 in maui2Group (not mauiGroup) + var session = svc.Organization.Sessions.First(m => m.SessionName == "s1"); + Assert.Equal(maui2Group!.Id, session.GroupId); + } + finally + { + try { Directory.Delete(basePath, true); } catch { } + } } [Fact] - public void ReconcileOrganization_Promotion_SessionUnderLocalPath_StaysInPromotedGroup() + public void ReconcileOrganization_StaleExternalWorktree_SkippedWhenPathNotOnDisk() { - // Sessions whose worktree IS under the LocalPath should stay in the promoted group. + // If an external worktree's path no longer exists on disk (stale repos.json entry), + // ReconcileOrganization should skip it — not create an empty local folder group. var repos = new List { new() { Id = "repo-1", Name = "MyRepo", Url = "https://github.com/test/repo" } }; - var extPath = Path.Combine(Path.GetTempPath(), "MyRepo"); - var nestedPath = Path.Combine(extPath, ".polypilot", "worktrees", "feature-y"); + // Use a path that definitely doesn't exist + var stalePath = Path.Combine(Path.GetTempPath(), $"nonexistent-{Guid.NewGuid():N}"); var worktrees = new List { - new() { Id = "ext-1", RepoId = "repo-1", Branch = "main", Path = extPath }, - new() { Id = "nested-1", RepoId = "repo-1", Branch = "feature-y", Path = nestedPath } + new() { Id = "ext-1", RepoId = "repo-1", Branch = "main", Path = stalePath } }; var rm = CreateRepoManagerWithState(repos, worktrees); var svc = CreateService(rm); var urlGroup = svc.GetOrCreateRepoGroup("repo-1", "MyRepo"); - svc.Organization.Sessions.Add(new SessionMeta - { - SessionName = "nested-session", - GroupId = urlGroup!.Id, - WorktreeId = "nested-1" - }); - typeof(CopilotService).GetProperty("IsInitialized")!.SetValue(svc, true); svc.ReconcileOrganization(allowPruning: false); - // The session's worktree is under the local path — it should stay in the promoted group - var meta = svc.Organization.Sessions.First(m => m.SessionName == "nested-session"); - Assert.Equal(urlGroup.Id, meta.GroupId); + // URL group must remain URL-based + Assert.False(urlGroup!.IsLocalFolder); + // No local folder group should be created for the stale path + var localGroups = svc.Organization.Groups.Where(g => g.IsLocalFolder && g.RepoId == "repo-1").ToList(); + Assert.Empty(localGroups); } [Fact] @@ -1462,59 +1629,61 @@ public void Scenario_TwoFoldersForSameRepo_SessionsRouteToCorrectGroup() public void Scenario_StartupMigration_AutoFixesExistingInstall() { // User had folder added via old code that created a URL-based group (no LocalPath). - // On startup, ReconcileOrganization should detect the external worktree and promote - // the URL-based group to a local folder group — without user intervention. + // On startup, ReconcileOrganization creates a separate local folder group for the + // external worktree — the URL-based group and its sessions stay untouched. - var sourceReposPath = Path.Combine(Path.GetTempPath(), "source", "repos", "PolyPilot"); + var sourceReposPath = Path.Combine(Path.GetTempPath(), $"test-PolyPilot-{Guid.NewGuid():N}"); var centralPath = Path.Combine(Path.GetTempPath(), ".polypilot", "worktrees", "polypilot-abc12345"); - - var repos = new List - { - new() { Id = "owner-polypilot", Name = "PolyPilot", Url = "https://github.com/owner/PolyPilot" } - }; - var worktrees = new List - { - new() { Id = "ext-1", RepoId = "owner-polypilot", Branch = "main", Path = sourceReposPath }, - new() { Id = "cen-1", RepoId = "owner-polypilot", Branch = "session-1", Path = centralPath } - }; - var rm = CreateRepoManagerWithState(repos, worktrees); - var svc = CreateService(rm); - - // Old-style state: only a URL-based group, no LocalPath - var oldUrlGroup = svc.GetOrCreateRepoGroup("owner-polypilot", "PolyPilot"); - Assert.False(oldUrlGroup!.IsLocalFolder); - Assert.Null(oldUrlGroup.LocalPath); - - // Existing session in the URL group (simulating old persisted sessions) - svc.Organization.Sessions.Add(new SessionMeta + Directory.CreateDirectory(sourceReposPath); + try { - SessionName = "my-old-session", - GroupId = oldUrlGroup.Id, - WorktreeId = "cen-1" - }); + var repos = new List + { + new() { Id = "owner-polypilot", Name = "PolyPilot", Url = "https://github.com/owner/PolyPilot" } + }; + var worktrees = new List + { + new() { Id = "ext-1", RepoId = "owner-polypilot", Branch = "main", Path = sourceReposPath }, + new() { Id = "cen-1", RepoId = "owner-polypilot", Branch = "session-1", Path = centralPath } + }; + var rm = CreateRepoManagerWithState(repos, worktrees); + var svc = CreateService(rm); - // Simulate the restore-phase reconciliation: IsInitialized must be true to pass the - // startup guard, and allowPruning=false prevents sessions without live counterparts - // from being pruned (matching RestorePreviousSessionsAsync behavior). - typeof(CopilotService).GetProperty("IsInitialized")!.SetValue(svc, true); + // Old-style state: only a URL-based group, no LocalPath + var oldUrlGroup = svc.GetOrCreateRepoGroup("owner-polypilot", "PolyPilot"); + Assert.False(oldUrlGroup!.IsLocalFolder); + Assert.Null(oldUrlGroup.LocalPath); - // Startup reconciliation runs (allowPruning:false = during session-restore window) - svc.ReconcileOrganization(allowPruning: false); + // Existing session in the URL group (simulating old persisted sessions) + svc.Organization.Sessions.Add(new SessionMeta + { + SessionName = "my-old-session", + GroupId = oldUrlGroup.Id, + WorktreeId = "cen-1" + }); - // The URL group should be promoted to a local folder group - var promotedGroup = svc.Organization.Groups.First(g => g.Id == oldUrlGroup.Id); - Assert.True(promotedGroup.IsLocalFolder); - Assert.Equal( - Path.GetFullPath(sourceReposPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), - promotedGroup.LocalPath); - - // Existing session has a centralized worktree (not under the local folder path), - // so it should be migrated to a new URL-based group by the promotion migration logic. - var oldSession = svc.Organization.Sessions.First(m => m.SessionName == "my-old-session"); - Assert.NotEqual(oldUrlGroup.Id, oldSession.GroupId); - var urlGroup = svc.Organization.Groups.First(g => g.Id == oldSession.GroupId); - Assert.False(urlGroup.IsLocalFolder); - Assert.Equal("owner-polypilot", urlGroup.RepoId); + typeof(CopilotService).GetProperty("IsInitialized")!.SetValue(svc, true); + svc.ReconcileOrganization(allowPruning: false); + + // URL group must remain URL-based — NOT promoted + var originalGroup = svc.Organization.Groups.First(g => g.Id == oldUrlGroup.Id); + Assert.False(originalGroup.IsLocalFolder); + + // Session stays in the URL group + var oldSession = svc.Organization.Sessions.First(m => m.SessionName == "my-old-session"); + Assert.Equal(oldUrlGroup.Id, oldSession.GroupId); + + // A separate local folder group should exist for the external path + var normalizedExt = Path.GetFullPath(sourceReposPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var localGroup = svc.Organization.Groups.FirstOrDefault(g => + g.IsLocalFolder && g.LocalPath != null && + string.Equals( + Path.GetFullPath(g.LocalPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + normalizedExt, StringComparison.OrdinalIgnoreCase)); + Assert.NotNull(localGroup); + Assert.Equal("owner-polypilot", localGroup!.RepoId); + } + finally { try { Directory.Delete(sourceReposPath, true); } catch { } } } [Fact] diff --git a/PolyPilot/Services/CopilotService.Organization.cs b/PolyPilot/Services/CopilotService.Organization.cs index 205a433d9..5c6daed58 100644 --- a/PolyPilot/Services/CopilotService.Organization.cs +++ b/PolyPilot/Services/CopilotService.Organization.cs @@ -573,11 +573,23 @@ internal void ReconcileOrganization(bool allowPruning = true) var repo = _repoManager.Repositories.FirstOrDefault(r => r.Id == worktree.RepoId); if (repo != null) { - // 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". + // Prefer the local folder group whose LocalPath matches + // the session's worktree path. With multiple local folder + // groups per repo (one per external path), FirstOrDefault + // by RepoId alone would pick the wrong one. + // Use separator-aware matching to avoid /maui matching /maui2. + var normalizedWtPathAuto = Path.GetFullPath(worktree.Path) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var localFolderGroup = Organization.Groups.FirstOrDefault(g => - g.RepoId == repo.Id && g.IsLocalFolder && !g.IsMultiAgent); + g.RepoId == repo.Id && g.IsLocalFolder && !g.IsMultiAgent && + g.LocalPath != null && + (normalizedWtPathAuto.StartsWith(g.LocalPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + || string.Equals(normalizedWtPathAuto, g.LocalPath, StringComparison.OrdinalIgnoreCase))) + // Fallback: if no path-aware match (e.g., managed worktree under + // ~/.polypilot/worktrees/), pick any local folder group for this repo. + // The heal-stranded-sessions block below corrects any misassignment. + ?? Organization.Groups.FirstOrDefault(g => + g.RepoId == repo.Id && g.IsLocalFolder && !g.IsMultiAgent); if (localFolderGroup != null) { meta.GroupId = localFolderGroup.Id; @@ -607,10 +619,18 @@ internal void ReconcileOrganization(bool allowPruning = true) var repo = _repoManager.Repositories.FirstOrDefault(r => r.Id == worktree.RepoId); if (repo != null) { - // Prefer an existing local folder group (same fix as the - // workingDir-based block above) to avoid duplicate sidebar entries. + // Prefer the local folder group whose LocalPath matches + // the session's worktree path (same separator-aware logic as above). + var normalizedWtPathAuto2 = Path.GetFullPath(worktree.Path) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var localFolderGroup = Organization.Groups.FirstOrDefault(g => - g.RepoId == repo.Id && g.IsLocalFolder && !g.IsMultiAgent); + g.RepoId == repo.Id && g.IsLocalFolder && !g.IsMultiAgent && + g.LocalPath != null && + (normalizedWtPathAuto2.StartsWith(g.LocalPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + || string.Equals(normalizedWtPathAuto2, g.LocalPath, StringComparison.OrdinalIgnoreCase))) + // Fallback: same heal-stranded-sessions dependency as above. + ?? Organization.Groups.FirstOrDefault(g => + g.RepoId == repo.Id && g.IsLocalFolder && !g.IsMultiAgent); if (localFolderGroup != null) { meta.GroupId = localFolderGroup.Id; @@ -711,8 +731,14 @@ internal void ReconcileOrganization(bool allowPruning = true) // folders) have a corresponding 📁 local folder group. An external worktree is any // worktree whose path is NOT under the managed worktrees directory AND does NOT contain // ".polypilot/worktrees" (which marks nested worktrees inside local folders). - // When a local folder group is missing, promote the most-recently-created URL-based - // group for that repo to a local folder group rather than creating a duplicate. + // When a local folder group is missing, create one via GetOrCreateLocalFolderGroup. + // + // IMPORTANT: We do NOT promote the URL-based group to a local folder group anymore. + // Promotion caused the "3 maui groups" bug: when the URL-based group was promoted, + // all its sessions on managed worktrees (~/.polypilot/worktrees/...) had to be migrated + // to a new URL-based group, resulting in 2+ groups for the same repo. Instead, we + // always create a separate local folder group for each external path, leaving the + // URL-based group untouched. var sep = Path.DirectorySeparatorChar; var polypilotWorktreesMarker = $".polypilot{sep}worktrees"; var externalWorktrees = _repoManager.Worktrees.Where(wt => @@ -732,56 +758,26 @@ internal void ReconcileOrganization(bool allowPruning = true) normalizedExtPath, StringComparison.OrdinalIgnoreCase)); if (hasLocalGroup) continue; - // Promote the most-recently-added URL-based group for this repo. - var groupToPromote = Organization.Groups - .Where(g => g.RepoId == ext.RepoId && !g.IsLocalFolder && !g.IsMultiAgent) - .OrderByDescending(g => g.SortOrder) - .FirstOrDefault(); - - if (groupToPromote != null) + // Verify the external path still exists on disk — skip stale entries + if (!Directory.Exists(normalizedExtPath)) { - groupToPromote.LocalPath = normalizedExtPath; - // Preserve the user's group name — don't overwrite with the folder basename. - // The old code did: groupToPromote.Name = Path.GetFileName(normalizedExtPath) - // which destroyed user-customized names (e.g., "maui" → "maui2"). - // Fallback: if the group somehow has an empty name, use the folder basename. - if (string.IsNullOrWhiteSpace(groupToPromote.Name)) - groupToPromote.Name = Path.GetFileName(normalizedExtPath); - changed = true; - Debug($"ReconcileOrganization: promoted group '{groupToPromote.Id}' ('{groupToPromote.Name}') to local folder group for '{normalizedExtPath}'"); - - // Migrate sessions whose worktrees are NOT under the new LocalPath to the - // URL-based repo group. Without this, sessions linked to managed worktrees - // (~/.polypilot/worktrees/...) get stranded in the promoted local folder group. - var repoName = _repoManager.Repositories.FirstOrDefault(r => r.Id == ext.RepoId)?.Name ?? ext.RepoId; - var urlGroup = GetOrCreateRepoGroup(ext.RepoId, repoName); - if (urlGroup != null) - { - foreach (var meta in Organization.Sessions.Where(m => m.GroupId == groupToPromote.Id)) - { - if (meta.WorktreeId == null) continue; - var wt = _repoManager.Worktrees.FirstOrDefault(w => w.Id == meta.WorktreeId); - if (wt != null) - { - // Normalize wt.Path before comparing: on Windows, stored paths may use - // forward slashes or relative forms that differ from the GetFullPath result. - var normalizedWtPath = Path.GetFullPath(wt.Path) - .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (!normalizedWtPath.StartsWith(normalizedExtPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) - && !string.Equals(normalizedWtPath, normalizedExtPath, StringComparison.OrdinalIgnoreCase)) - { - Debug($"ReconcileOrganization: migrating '{meta.SessionName}' from promoted local folder group to URL group '{urlGroup.Id}'"); - meta.GroupId = urlGroup.Id; - } - } - } - } + Debug($"ReconcileOrganization: skipping external worktree '{normalizedExtPath}' — path no longer exists"); + continue; } + + // Create a dedicated local folder group for this external path. + // This never touches the existing URL-based group. + // skipNotify: true — batch the save to the end of ReconcileOrganization + GetOrCreateLocalFolderGroup(normalizedExtPath, ext.RepoId, skipNotify: true); + changed = true; + Debug($"ReconcileOrganization: created local folder group for external worktree '{normalizedExtPath}' (repo={ext.RepoId})"); } // Heal sessions stranded in local folder groups: if a session's worktree path // is NOT under the group's LocalPath, move it to the URL-based repo group. - // This fixes state from before the promotion migration was added. + // This handles legacy state from older promotion-based reconciliation, explicit + // folder additions where sessions ended up in the wrong local folder group, or + // sessions whose worktree was moved after initial assignment. foreach (var localGroup in Organization.Groups.Where(g => g.IsLocalFolder && !g.IsMultiAgent && g.RepoId != null).ToList()) { var normalizedLocalPath = Path.GetFullPath(localGroup.LocalPath!) @@ -1488,6 +1484,15 @@ internal bool IsWorkerInMultiAgentGroup(string sessionName) /// full worktree/branch menu can be offered. /// public SessionGroup GetOrCreateLocalFolderGroup(string localPath, string? repoId = null) + => GetOrCreateLocalFolderGroup(localPath, repoId, skipNotify: false); + + /// + /// Core implementation. When is true, the caller is + /// responsible for calling and raising + /// — used by to + /// batch saves instead of firing N+1 times in the external worktree loop. + /// + internal SessionGroup GetOrCreateLocalFolderGroup(string localPath, string? repoId, bool skipNotify) { var normalized = Path.GetFullPath(localPath) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); @@ -1528,7 +1533,7 @@ public SessionGroup GetOrCreateLocalFolderGroup(string localPath, string? repoId notify = true; } } - if (notify) { SaveOrganization(); OnStateChanged?.Invoke(); } + if (notify && !skipNotify) { SaveOrganization(); OnStateChanged?.Invoke(); } return result; }