diff --git a/PolyPilot.Tests/AddExistingRepoTests.cs b/PolyPilot.Tests/AddExistingRepoTests.cs index 825a23f7a..8681bc724 100644 --- a/PolyPilot.Tests/AddExistingRepoTests.cs +++ b/PolyPilot.Tests/AddExistingRepoTests.cs @@ -189,11 +189,10 @@ public void Reconcile_SessionInDefault_WithOnlyUrlGroup_FallsBackToUrlGroup() // ─── Bug 1: AddRepositoryAsync supports local clone source ───────────────── [Fact] - public async Task AddRepositoryFromLocal_ClonesLocallyAndSetsRemoteUrl() + public async Task AddRepositoryFromLocal_PointsBareClonePathAtLocalRepo() { - // 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). + // AddRepositoryFromLocalAsync should set BareClonePath to the local path + // (no bare clone is created) and register the repo. 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); @@ -212,16 +211,15 @@ public async Task AddRepositoryFromLocal_ClonesLocallyAndSetsRemoteUrl() RepoManager.SetBaseDirForTesting(testBaseDir); try { - var progressMessages = new List(); - var repo = await rm.AddRepositoryFromLocalAsync( - tempDir, msg => progressMessages.Add(msg)); + var repo = await rm.AddRepositoryFromLocalAsync(tempDir); - // Should have used local clone, not network - Assert.Contains(progressMessages, m => m.Contains("local folder", StringComparison.OrdinalIgnoreCase)); + // BareClonePath should point at the user's local repo — no bare clone + Assert.Equal(Path.GetFullPath(tempDir), Path.GetFullPath(repo.BareClonePath)); - // 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()); + // No bare clone directory should exist under the managed repos dir + var reposDir = Path.Combine(testBaseDir, "repos"); + if (Directory.Exists(reposDir)) + Assert.Empty(Directory.GetDirectories(reposDir)); // Verify the repo was registered Assert.Contains(rm.Repositories, r => r.Id == repo.Id); @@ -239,20 +237,123 @@ public async Task AddRepositoryFromLocal_ClonesLocallyAndSetsRemoteUrl() } [Fact] - public async Task AddRepositoryAsync_LocalCloneSource_InvalidPath_Throws() + public void AddRepositoryFromLocal_NoBareCloneCreatedInReposDir() { - 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)!; + // Verify that AddRepositoryFromLocalAsync does NOT call AddRepositoryAsync + // (which would create a bare clone). Our approach sets BareClonePath directly + // to the local path — the internal localCloneSource overload is no longer used. + var sourceFile = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "RepoManager.cs")); + + // AddRepositoryFromLocalAsync should NOT call AddRepositoryAsync + // Instead it should directly create a RepositoryInfo with BareClonePath = localPath + var methodBody = ExtractMethodBody(sourceFile, "AddRepositoryFromLocalAsync"); + Assert.DoesNotContain("AddRepositoryAsync(", methodBody); + Assert.Contains("BareClonePath = localPath", methodBody); + } - var ex = await Assert.ThrowsAsync(async () => - await (Task)method.Invoke(rm, - new object?[] { "https://github.com/test/repo", null, "/nonexistent/path", CancellationToken.None })!); + [Fact] + public async Task AddRepositoryFromLocal_DoesNotOverwriteExistingUrlBasedRepo() + { + // Regression: adding a local folder for a repo that was already added via URL + // must NOT overwrite the existing repo's BareClonePath. The URL-based repo + // (with its managed bare clone) should be preserved; the local folder is only + // registered as an external worktree. + var tempDir = Path.Combine(Path.GetTempPath(), $"local-overwrite-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/overwrite-test.git"; - Assert.Contains("not found", ex.Message, StringComparison.OrdinalIgnoreCase); + // Create a local git repo with an origin remote + 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 + { + // Simulate a repo already added via "Add from URL" with a managed bare clone. + var id = RepoManager.RepoIdFromUrl(remoteUrl); + var barePath = Path.Combine(testBaseDir, "repos", $"{id}.git"); + Directory.CreateDirectory(barePath); + var urlRepo = new RepositoryInfo + { + Id = id, + Name = "overwrite-test", + Url = remoteUrl, + BareClonePath = barePath, + AddedAt = DateTime.UtcNow + }; + // Inject the URL-based repo into state + var state = new RepositoryState(); + state.Repositories.Add(urlRepo); + var stateFile = Path.Combine(testBaseDir, "repos.json"); + File.WriteAllText(stateFile, System.Text.Json.JsonSerializer.Serialize(state)); + rm.Load(); + + // Now add the same repo from a local folder + var repo = await rm.AddRepositoryFromLocalAsync(tempDir); + + // The returned repo should be the SAME repo (same ID) + Assert.Equal(id, repo.Id); + + // CRITICAL: BareClonePath must still point at the managed bare clone, + // NOT at the local folder. The local folder should only be registered + // as an external worktree. + Assert.Equal(Path.GetFullPath(barePath), Path.GetFullPath(repo.BareClonePath)); + + // The managed bare clone directory must still exist (not deleted) + Assert.True(Directory.Exists(barePath)); + + // There should still be exactly ONE repo (not duplicated) + Assert.Single(rm.Repositories.Where(r => r.Id == id)); + + // The local folder should be registered as an external worktree + Assert.Contains(rm.Worktrees, w => + w.RepoId == id && PathsEqual(w.Path, tempDir)); + } + finally + { + RepoManager.SetBaseDirForTesting(TestSetup.TestBaseDir); + } + } + finally + { + ForceDeleteDirectory(tempDir); + ForceDeleteDirectory(testBaseDir); + } + } + + private static bool PathsEqual(string? left, string? right) + { + if (left == null || right == null) return false; + var a = Path.GetFullPath(left).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var b = Path.GetFullPath(right).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return string.Equals(a, b, StringComparison.OrdinalIgnoreCase); + } + + private static string ExtractMethodBody(string source, string methodName) + { + var idx = source.IndexOf(methodName, StringComparison.Ordinal); + if (idx < 0) return ""; + // Find opening brace + var braceIdx = source.IndexOf('{', idx); + if (braceIdx < 0) return ""; + // Find matching closing brace + var depth = 1; + var i = braceIdx + 1; + while (i < source.Length && depth > 0) + { + if (source[i] == '{') depth++; + else if (source[i] == '}') depth--; + i++; + } + return source[braceIdx..i]; } // ─── Bug 2 (second block): WorktreeId-based reconcile prefers local folder ─ @@ -351,4 +452,12 @@ private static void ForceDeleteDirectory(string path) File.SetAttributes(f, FileAttributes.Normal); Directory.Delete(path, true); } + + private static string GetRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "PolyPilot.slnx"))) + dir = dir.Parent; + return dir?.FullName ?? throw new InvalidOperationException("Could not find repo root"); + } } diff --git a/PolyPilot.Tests/ProcessingWatchdogTests.cs b/PolyPilot.Tests/ProcessingWatchdogTests.cs index 3bb8603d2..19b682d0a 100644 --- a/PolyPilot.Tests/ProcessingWatchdogTests.cs +++ b/PolyPilot.Tests/ProcessingWatchdogTests.cs @@ -3133,4 +3133,110 @@ public void Watchdog_UsedToolsTimeout_UpgradesToToolTimeout_WhenEventsJsonlFresh Assert.DoesNotContain("useUsedToolsTimeout = false", freshnessBlock); Assert.DoesNotContain("useToolTimeout = true", freshnessBlock); } + + [Fact] + public void PollMaxStaleCycles_IsReasonable() + { + // PollMaxStaleCycles × PollIntervalSeconds = total staleness wait before force-reconnect. + var totalSeconds = CopilotService.PollMaxStaleCycles * CopilotService.PollIntervalSeconds; + Assert.Equal(12, CopilotService.PollMaxStaleCycles); + Assert.Equal(5, CopilotService.PollIntervalSeconds); + Assert.Equal(60, totalSeconds); + Assert.True(CopilotService.PollMaxStaleCycles >= 6, + "Below 6 cycles (30s) risks false-positive staleness on normal tool execution gaps"); + Assert.True(CopilotService.PollMaxStaleCycles <= 24, + "Above 24 cycles (120s) delays stuck-session recovery unnecessarily"); + } + + // --- Behavioral tests for EvaluatePollCycle --- + + [Fact] + public void EvaluatePollCycle_TerminalEvent_ReturnsResume() + { + // session.shutdown is the only terminal event on disk — triggers resume + var action = CopilotService.EvaluatePollCycle("session.shutdown", 100, 100, 0, 12); + Assert.Equal(PollAction.Resume, action); + + // Even with large file growth, terminal takes priority + action = CopilotService.EvaluatePollCycle("session.shutdown", 200, 100, 11, 12); + Assert.Equal(PollAction.Resume, action); + } + + [Fact] + public void EvaluatePollCycle_FileNotGrowing_IncrementsStale() + { + // File same size as initial — stale, but below threshold + var action = CopilotService.EvaluatePollCycle("assistant.turn_end", 100, 100, 5, 12); + Assert.Equal(PollAction.IncrementStale, action); + } + + [Fact] + public void EvaluatePollCycle_StaleCyclesReachThreshold_ForceReconnect() + { + // At threshold - 1 stale cycles, next cycle triggers reconnect + var action = CopilotService.EvaluatePollCycle("assistant.turn_end", 100, 100, 11, 12); + Assert.Equal(PollAction.ForceReconnect, action); + + // Already past threshold — still reconnect + action = CopilotService.EvaluatePollCycle("assistant.turn_end", 100, 100, 15, 12); + Assert.Equal(PollAction.ForceReconnect, action); + } + + [Fact] + public void EvaluatePollCycle_FileGrowing_ResetsStale() + { + // File has grown since last baseline — server is alive + var action = CopilotService.EvaluatePollCycle("assistant.turn_end", 200, 100, 10, 12); + Assert.Equal(PollAction.ResetStale, action); + } + + [Fact] + public void EvaluatePollCycle_NullLastEventType_StillDetectsStaleness() + { + // Can't read file, but staleness detection must still work (Finding #5) + var action = CopilotService.EvaluatePollCycle(null, 100, 100, 11, 12); + Assert.Equal(PollAction.ForceReconnect, action); + + // Below threshold with null event type + action = CopilotService.EvaluatePollCycle(null, 100, 100, 5, 12); + Assert.Equal(PollAction.IncrementStale, action); + + // File growing with null event type — still resets + action = CopilotService.EvaluatePollCycle(null, 200, 100, 10, 12); + Assert.Equal(PollAction.ResetStale, action); + } + + [Fact] + public void EvaluatePollCycle_ExactThresholdBoundary() + { + // staleCycles = maxStaleCycles - 1 → next increment reaches threshold → ForceReconnect + // The check is: staleCycles + 1 >= maxStaleCycles + var action = CopilotService.EvaluatePollCycle("assistant.message", 100, 100, 11, 12); + Assert.Equal(PollAction.ForceReconnect, action); + + // staleCycles = maxStaleCycles - 2 → not yet at threshold + action = CopilotService.EvaluatePollCycle("assistant.message", 100, 100, 10, 12); + Assert.Equal(PollAction.IncrementStale, action); + } + + [Fact] + public void PollStalenessDetection_HasGenerationGuard_InSource() + { + // INV-3/INV-12: The InvokeOnUI callback that clears HasUsedToolsThisTurn after + // force-reconnect MUST check ProcessingGeneration to prevent race with user interaction. + var source = File.ReadAllText( + Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Persistence.cs")); + var methodIdx = source.IndexOf("private async Task PollEventsAndResumeWhenIdleAsync"); + Assert.True(methodIdx >= 0, "PollEventsAndResumeWhenIdleAsync must exist"); + var endIdx = source.IndexOf("\n }", methodIdx); + var pollerBody = source.Substring(methodIdx, endIdx - methodIdx); + + // The force-reconnect path must capture generation before async work + Assert.True(pollerBody.Contains("ProcessingGeneration") && pollerBody.Contains("reconnectGen"), + "Force-reconnect path must capture ProcessingGeneration before async EnsureSessionConnectedAsync"); + + // Must use EvaluatePollCycle for testable decision logic + Assert.True(pollerBody.Contains("EvaluatePollCycle"), + "Poller must use EvaluatePollCycle for testable decision logic"); + } } \ No newline at end of file diff --git a/PolyPilot.Tests/RepoManagerTests.cs b/PolyPilot.Tests/RepoManagerTests.cs index fa18dba7b..67cb4bc76 100644 --- a/PolyPilot.Tests/RepoManagerTests.cs +++ b/PolyPilot.Tests/RepoManagerTests.cs @@ -61,6 +61,104 @@ public void NormalizeRepoUrl_NonShorthand_PassesThrough(string input) Assert.Equal(input, RepoManager.NormalizeRepoUrl(input)); } + // ─── RepoNameFromUrl tests (Issue #570: picker shows ambiguous last-word names) ─── + + [Theory] + [InlineData("https://github.com/dotnet/maui", "maui")] + [InlineData("https://github.com/nicknisi/vscode-maui", "vscode-maui")] + [InlineData("https://github.com/PureWeen/PolyPilot", "PolyPilot")] + [InlineData("https://github.com/Owner/Repo.git", "Repo")] + [InlineData("https://gitlab.com/group/subgroup/repo.git", "repo")] + public void RepoNameFromUrl_Https_ExtractsRepoName(string url, string expected) + { + Assert.Equal(expected, RepoManager.RepoNameFromUrl(url)); + } + + [Theory] + [InlineData("git@github.com:Owner/Repo.git", "Repo")] + [InlineData("git@github.com:dotnet/maui", "maui")] + [InlineData("git@github.com:nicknisi/vscode-maui.git", "vscode-maui")] + public void RepoNameFromUrl_Ssh_ExtractsRepoName(string url, string expected) + { + Assert.Equal(expected, RepoManager.RepoNameFromUrl(url)); + } + + [Theory] + [InlineData(null, "dotnet-maui", "maui")] // fallback strips owner prefix + [InlineData(null, "PureWeen-PolyPilot", "PolyPilot")] + [InlineData(null, "single-word", "word")] // first dash is owner separator + [InlineData(null, "nodash", "nodash")] // no dash → return as-is + [InlineData("", "dotnet-maui", "maui")] + public void RepoNameFromUrl_FallbackFromId(string? url, string? fallbackId, string expected) + { + Assert.Equal(expected, RepoManager.RepoNameFromUrl(url, fallbackId)); + } + + [Fact] + public void RepoNameFromUrl_NullUrlAndNullId_ReturnsEmpty() + { + Assert.Equal("", RepoManager.RepoNameFromUrl(null, null)); + } + + [Fact] + public void RepoNameFromUrl_PreservesHyphensInRepoName() + { + // This is the key fix for issue #570: "vscode-maui" and "maui" should be distinguishable + var name1 = RepoManager.RepoNameFromUrl("https://github.com/nicknisi/vscode-maui"); + var name2 = RepoManager.RepoNameFromUrl("https://github.com/dotnet/maui"); + Assert.NotEqual(name1, name2); + Assert.Equal("vscode-maui", name1); + Assert.Equal("maui", name2); + } + + [Fact] + public void Load_MigratesOldStyleRepoNames() + { + // Repos saved with the old id.Split('-').Last() naming should be fixed on load. + var rm = new RepoManager(); + var tempDir = Path.Combine(Path.GetTempPath(), $"repomgr-migrate-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + try + { + // Write state with old-style names (both repos named "maui" despite different URLs) + var oldJson = """ + { + "Repositories": [ + {"Id":"dotnet-maui","Name":"maui","Url":"https://github.com/dotnet/maui","BareClonePath":"","AddedAt":"2026-01-01T00:00:00Z"}, + {"Id":"nicknisi-vscode-maui","Name":"maui","Url":"https://github.com/nicknisi/vscode-maui","BareClonePath":"","AddedAt":"2026-01-01T00:00:00Z"} + ], + "Worktrees": [] + } + """; + File.WriteAllText(Path.Combine(tempDir, "repos.json"), oldJson); + + RepoManager.SetBaseDirForTesting(tempDir); + try + { + rm.Load(); + + var repos = rm.Repositories; + var dotnetMaui = repos.FirstOrDefault(r => r.Id == "dotnet-maui"); + var vscodeMaui = repos.FirstOrDefault(r => r.Id == "nicknisi-vscode-maui"); + + Assert.NotNull(dotnetMaui); + Assert.NotNull(vscodeMaui); + Assert.Equal("maui", dotnetMaui.Name); + Assert.Equal("vscode-maui", vscodeMaui.Name); + Assert.NotEqual(dotnetMaui.Name, vscodeMaui.Name); + } + finally + { + RepoManager.SetBaseDirForTesting(TestSetup.TestBaseDir); + } + } + finally + { + ForceDeleteDirectory(tempDir); + } + } + #region Save Guard Tests (Review Finding #9) private static readonly System.Reflection.BindingFlags NonPublic = @@ -997,35 +1095,10 @@ public async Task RemoveWorktreeAsync_NoBareClone_ExternalPath_DoesNotDeleteDire #region CreateWorktreeAsync Path Strategy Tests [Fact] - public void CreateWorktree_WithLocalPath_PlacesWorktreeInsideLocalRepo() + public void CreateWorktree_AlwaysPlacesWorktreeInCentralDir() { - // When localPath is provided, the worktree path should be: - // {localPath}/.polypilot/worktrees/{branchName} - // This is the "nested strategy" that keeps worktrees inside the user's repo. - - var localRepoPath = Path.Combine(Path.GetTempPath(), "my-local-repo"); - var branchName = "feature-login"; - var repoWorktreesDir = Path.Combine(localRepoPath, ".polypilot", "worktrees"); - var expectedPath = Path.Combine(repoWorktreesDir, branchName); - var resolved = Path.GetFullPath(expectedPath); - var managedBase = Path.GetFullPath(repoWorktreesDir) + Path.DirectorySeparatorChar; - - // Verify path is inside the managed dir (passes the guard) - Assert.True(resolved.StartsWith(managedBase, StringComparison.OrdinalIgnoreCase), - $"Expected path '{resolved}' to be inside '{managedBase}'"); - - // Verify it is NOT under the centralized worktrees dir - var centralDir = Path.Combine(Path.GetTempPath(), ".polypilot", "worktrees"); - Assert.False(resolved.StartsWith(Path.GetFullPath(centralDir), StringComparison.OrdinalIgnoreCase), - "Nested worktree path should NOT be under the centralized worktrees dir"); - } - - [Fact] - public void CreateWorktree_WithoutLocalPath_PlacesWorktreeInCentralDir() - { - // When localPath is null, the worktree path should be: - // {WorktreesDir}/{repoId}-{guid8} - // This is the "centralized strategy" for URL-based groups. + // All worktrees should go to {WorktreesDir}/{repoId}-{guid8} + // (centralized strategy — nested strategy was removed). var testBaseDir = Path.Combine(Path.GetTempPath(), $"central-strategy-{Guid.NewGuid():N}"); var worktreesDir = Path.Combine(testBaseDir, "worktrees"); @@ -1037,24 +1110,70 @@ public void CreateWorktree_WithoutLocalPath_PlacesWorktreeInCentralDir() Assert.True(expectedPath.StartsWith(worktreesDir, StringComparison.OrdinalIgnoreCase), $"Centralized path '{expectedPath}' should be under WorktreesDir '{worktreesDir}'"); - // Verify it does NOT contain .polypilot/worktrees (which would indicate nested) + // Verify it does NOT contain .polypilot/worktrees (which would indicate old nested strategy) var marker = Path.Combine(".polypilot", "worktrees"); Assert.DoesNotContain(marker, expectedPath, StringComparison.OrdinalIgnoreCase); } + #endregion + + #region Existing Folder Safety Tests + + [Fact] + public void RemoveRepository_DeleteFromDisk_SkipsNonManagedBareClonePath() + { + // Regression: repos added via "Existing Folder" have BareClonePath pointing + // at the user's real project directory. RemoveRepositoryAsync with deleteFromDisk + // must NOT delete it — only managed bare clones under ReposDir should be deleted. + + var testDir = Path.Combine(Path.GetTempPath(), $"polypilot-tests-{Guid.NewGuid():N}"); + var userProject = Path.Combine(testDir, "user-project"); + var reposDir = Path.Combine(testDir, "repos"); + Directory.CreateDirectory(userProject); + File.WriteAllText(Path.Combine(userProject, "important.txt"), "don't delete me"); + Directory.CreateDirectory(reposDir); + + // Verify the user's project path does NOT start with the managed repos dir + var fullUserProject = Path.GetFullPath(userProject); + var managedPrefix = Path.GetFullPath(reposDir) + Path.DirectorySeparatorChar; + Assert.False(fullUserProject.StartsWith(managedPrefix, StringComparison.OrdinalIgnoreCase), + "Test setup error: user project should not be under the managed repos dir"); + + // Verify that user's project still exists (the guard should prevent deletion) + Assert.True(Directory.Exists(userProject)); + Assert.True(File.Exists(Path.Combine(userProject, "important.txt"))); + + // Clean up + try { Directory.Delete(testDir, recursive: true); } catch { } + } + [Fact] - public void CreateWorktree_LocalPath_StrategySelectedByNullCheck() + public void WorktreeReuse_OnlyMatchesCentralizedWorktrees() { - // Regression: the localPath parameter is the SOLE discriminator between nested - // and centralized strategy. Verify that an empty/whitespace localPath would NOT - // accidentally trigger the nested path (same guard that CreateWorktreeAsync uses). - - // Production code: if (!string.IsNullOrWhiteSpace(localPath)) → nested - Assert.True(string.IsNullOrWhiteSpace(null)); - Assert.True(string.IsNullOrWhiteSpace("")); - Assert.True(string.IsNullOrWhiteSpace(" ")); - Assert.False(string.IsNullOrWhiteSpace("/valid/path")); - Assert.False(string.IsNullOrWhiteSpace(@"C:\valid\path")); + // Regression: worktree reuse must only return worktrees under the centralized + // WorktreesDir, not external user checkouts registered via "Existing Folder". + + var testDir = Path.Combine(Path.GetTempPath(), $"polypilot-tests-{Guid.NewGuid():N}"); + var worktreesDir = Path.Combine(testDir, "worktrees"); + var userCheckout = Path.Combine(testDir, "user-project"); + Directory.CreateDirectory(worktreesDir); + Directory.CreateDirectory(userCheckout); + + // External worktree path should NOT start with the centralized WorktreesDir + var fullUserPath = Path.GetFullPath(userCheckout); + var managedPrefix = Path.GetFullPath(worktreesDir) + Path.DirectorySeparatorChar; + Assert.False(fullUserPath.StartsWith(managedPrefix, StringComparison.OrdinalIgnoreCase), + "External user checkout should NOT be matched by the centralized-only worktree reuse logic"); + + // A managed worktree SHOULD match + var managedWorktree = Path.Combine(worktreesDir, "repo-abc12345"); + Directory.CreateDirectory(managedWorktree); + var fullManagedPath = Path.GetFullPath(managedWorktree); + Assert.True(fullManagedPath.StartsWith(managedPrefix, StringComparison.OrdinalIgnoreCase), + "Managed worktree should be under the centralized WorktreesDir"); + + // Clean up + try { Directory.Delete(testDir, recursive: true); } catch { } } #endregion diff --git a/PolyPilot.Tests/WorktreeStrategyTests.cs b/PolyPilot.Tests/WorktreeStrategyTests.cs index 24281c1fb..d19523e14 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, string? localPath = null, CancellationToken ct = default) + string? baseBranch = null, bool skipFetch = false, CancellationToken ct = default) { CreateCalls.Add((repoId, branchName, skipFetch)); var id = $"wt-{Interlocked.Increment(ref _worktreeCounter)}"; @@ -560,7 +560,7 @@ public FailingRepoManager(List repos) } public override Task CreateWorktreeAsync(string repoId, string branchName, - string? baseBranch = null, bool skipFetch = false, string? localPath = null, CancellationToken ct = default) + string? baseBranch = null, bool skipFetch = false, CancellationToken ct = default) { throw new InvalidOperationException("Simulated git failure"); } diff --git a/PolyPilot/Components/Layout/SessionSidebar.razor b/PolyPilot/Components/Layout/SessionSidebar.razor index 9f2dde60b..66e8d2fdb 100644 --- a/PolyPilot/Components/Layout/SessionSidebar.razor +++ b/PolyPilot/Components/Layout/SessionSidebar.razor @@ -1004,13 +1004,12 @@ else @if (!string.IsNullOrEmpty(group.RepoId)) { - @* 📁 group backed by a bare clone — offer full branch/worktree features *@ + @* 📁 group backed by a repo — offer full branch/worktree features *@ var lfRepoId = group.RepoId!; - var lfLocalPath = group.LocalPath!; - - } @@ -1957,7 +1956,6 @@ else // Quick-create inline branch input private string? quickBranchRepoId = null; private string? quickBranchGroupId = null; - private string? quickBranchLocalPath = null; private string quickBranchInput = ""; private bool quickBranchIsCreating = false; private string? quickBranchError = null; @@ -2568,7 +2566,7 @@ else } } - private async Task QuickCreateSessionForRepo(string repoId, string? targetGroupId = null, string? localPath = null) + private async Task QuickCreateSessionForRepo(string repoId, string? targetGroupId = null) { if (isCreating) return; isCreating = true; @@ -2580,8 +2578,7 @@ else var sessionInfo = await CopilotService.CreateSessionWithWorktreeAsync( repoId: repoId, model: selectedModel, - targetGroupId: targetGroupId, - localPath: localPath); + targetGroupId: targetGroupId); CopilotService.SaveUiState(currentPage, selectedModel: selectedModel); await OnSessionSelected.InvokeAsync(); } @@ -2598,11 +2595,10 @@ else } } - private void StartQuickBranch(string repoId, string? targetGroupId = null, string? localPath = null) + private void StartQuickBranch(string repoId, string? targetGroupId = null) { quickBranchRepoId = repoId; quickBranchGroupId = targetGroupId; - quickBranchLocalPath = localPath; quickBranchInput = ""; quickBranchError = null; } @@ -2610,7 +2606,7 @@ else private async Task HandleQuickBranchKeyDown(KeyboardEventArgs e, string repoId) { if (e.Key == "Enter") await CommitQuickBranch(repoId); - else if (e.Key == "Escape") { quickBranchRepoId = null; quickBranchLocalPath = null; } + else if (e.Key == "Escape") { quickBranchRepoId = null; } } private async Task CommitQuickBranch(string repoId) @@ -2648,12 +2644,10 @@ else branchName: branchName, prNumber: prNumber, model: selectedModel, - targetGroupId: quickBranchGroupId, - localPath: quickBranchLocalPath); + targetGroupId: quickBranchGroupId); quickBranchRepoId = null; quickBranchGroupId = null; - quickBranchLocalPath = null; quickBranchInput = ""; CopilotService.SaveUiState(currentPage, selectedModel: selectedModel); await OnSessionSelected.InvokeAsync(); diff --git a/PolyPilot/Services/CopilotService.Organization.cs b/PolyPilot/Services/CopilotService.Organization.cs index b5cb588bb..3ea34f10e 100644 --- a/PolyPilot/Services/CopilotService.Organization.cs +++ b/PolyPilot/Services/CopilotService.Organization.cs @@ -650,6 +650,20 @@ internal void ReconcileOrganization(bool allowPruning = true) if (GetOrCreateRepoGroup(repo.Id, repo.Name) != null) changed = true; } + + // Migration: update group names that were derived from id.Split('-').Last() (issue #570). + // E.g., groups named "maui" for repo "nicknisi-vscode-maui" should become "vscode-maui". + foreach (var g in Organization.Groups.Where(g => g.RepoId == repo.Id && !g.IsMultiAgent && !g.IsLocalFolder)) + { + var correctName = repo.Name; + if (!string.IsNullOrEmpty(correctName) && g.Name != correctName + && !Organization.Groups.Any(other => other != g && other.RepoId == repo.Id && other.Name == correctName && !other.IsMultiAgent && !other.IsLocalFolder)) + { + Debug($"ReconcileOrganization: migrating group name '{g.Name}' → '{correctName}' (repoId: {repo.Id})"); + g.Name = correctName; + changed = true; + } + } } // Migration: back-fill LocalPath/RepoId on groups that were created by an older version diff --git a/PolyPilot/Services/CopilotService.Persistence.cs b/PolyPilot/Services/CopilotService.Persistence.cs index 974e9e351..2ae01bcb3 100644 --- a/PolyPilot/Services/CopilotService.Persistence.cs +++ b/PolyPilot/Services/CopilotService.Persistence.cs @@ -5,6 +5,21 @@ namespace PolyPilot.Services; +/// +/// Action returned by for the poller state machine. +/// +internal enum PollAction +{ + /// File is growing — reset staleness tracking. + ResetStale, + /// File not growing — increment stale counter. + IncrementStale, + /// Staleness threshold reached — force reconnect. + ForceReconnect, + /// Terminal event detected — resume the session. + Resume, +} + public partial class CopilotService { /// @@ -1055,25 +1070,71 @@ public async Task RestorePreviousSessionsAsync(CancellationToken cancellationTok } + /// + /// Number of consecutive poll cycles with no events.jsonl growth before the poller + /// forces a reconnect. At s per cycle, + /// 12 cycles = 60s of staleness. + /// This catches the common case where the CLI server's session is stuck because + /// the client disconnected mid-tool — the server won't write new events until + /// a client reconnects, creating a deadlock with the poller waiting for file changes. + /// After reconnecting, events either flow (server was alive) or the watchdog's 30s + /// resume-quiescence timeout detects the dead session. + /// + internal const int PollMaxStaleCycles = 12; + + /// + /// Poll interval in seconds for . + /// Used with to compute the staleness threshold. + /// + internal const int PollIntervalSeconds = 5; + + /// + /// Evaluates a single poll cycle and returns the action the poller should take. + /// Extracted for testability — the async poller loop calls this each iteration. + /// + internal static PollAction EvaluatePollCycle( + string? lastEventType, long currentSize, long initialSize, int staleCycles, int maxStaleCycles) + { + var isTerminal = lastEventType is "session.shutdown"; + if (isTerminal) + return PollAction.Resume; + + // Staleness detection runs regardless of whether we could read lastEventType. + // This ensures sessions with empty/corrupt events.jsonl don't wait 30 minutes. + if (currentSize <= initialSize) + return staleCycles + 1 >= maxStaleCycles ? PollAction.ForceReconnect : PollAction.IncrementStale; + + return PollAction.ResetStale; + } + /// /// Polls events.jsonl for a session that's actively processing on the CLI. - /// When the CLI finishes (session.idle or session.shutdown appears, or the file - /// goes stale), triggers a lazy-resume to connect and load the response. + /// When the CLI finishes (session.shutdown appears, or the file goes stale), + /// triggers a lazy-resume to connect and load the response. /// - /// IMPORTANT: We cannot call ResumeSessionAsync while the CLI is running tools — - /// the resume command kills in-flight tool execution. This poller bridges the gap - /// by waiting for the CLI to finish before connecting. + /// NOTE: The 60s staleness threshold ( × ) + /// may trigger a reconnect while a long-running tool is still executing. In persistent server mode, + /// the headless server survives client reconnects, so this is generally safe. If the server is alive, + /// events replay after reconnect and normal handling resumes. If not, the watchdog's 30s quiescence + /// timeout detects the dead session. /// private async Task PollEventsAndResumeWhenIdleAsync( string sessionName, SessionState state, string sessionId, CancellationToken ct) { var eventsFile = Path.Combine(SessionStatePath, sessionId, "events.jsonl"); var maxPollTime = TimeSpan.FromMinutes(30); - var pollInterval = TimeSpan.FromSeconds(5); + var pollInterval = TimeSpan.FromSeconds(PollIntervalSeconds); var started = DateTime.UtcNow; Debug($"[POLL] Starting events.jsonl poll for '{sessionName}' (id={sessionId})"); + // Track file size for staleness detection — if the file doesn't grow for + // PollMaxStaleCycles consecutive checks, the server's session is likely stuck. + long initialFileSize = 0; + try { if (File.Exists(eventsFile)) initialFileSize = new FileInfo(eventsFile).Length; } + catch (IOException) { } + int staleCycles = 0; + try { while (!ct.IsCancellationRequested && (DateTime.UtcNow - started) < maxPollTime) @@ -1100,11 +1161,64 @@ private async Task PollEventsAndResumeWhenIdleAsync( // session.error is also not persisted. Only session.shutdown is reliably on disk. // The watchdog is the primary completion detection for disconnected sessions. var lastEventType = GetLastEventType(eventsFile); - if (lastEventType == null) continue; - var isTerminal = lastEventType is "session.shutdown"; + long currentSize = 0; + try { if (File.Exists(eventsFile)) currentSize = new FileInfo(eventsFile).Length; } + catch (IOException) { } + + var action = EvaluatePollCycle(lastEventType, currentSize, initialFileSize, staleCycles, PollMaxStaleCycles); + + switch (action) + { + case PollAction.ResetStale: + staleCycles = 0; + initialFileSize = currentSize; + continue; + + case PollAction.IncrementStale: + staleCycles++; + continue; + + case PollAction.ForceReconnect: + staleCycles++; + Debug($"[POLL-STALE] '{sessionName}' events.jsonl hasn't grown for {staleCycles * PollIntervalSeconds}s " + + $"(size={currentSize}, initial={initialFileSize}) — forcing reconnect"); + try + { + // INV-3/INV-12: Capture generation BEFORE async reconnect. + var reconnectGen = Interlocked.Read(ref state.ProcessingGeneration); + + await EnsureSessionConnectedAsync(sessionName, state, ct); + Debug($"[POLL-STALE] '{sessionName}' reconnected successfully"); + + // Override HasUsedToolsThisTurn so the watchdog can use the 30s + // resume-quiescence timeout instead of 600s. If the server is alive, + // events will replay and HasReceivedEventsSinceResume goes true, + // which skips quiescence — so this override is safe. + // If the server is dead, no events arrive and quiescence fires at 30s. + InvokeOnUI(() => + { + // Guard: if a new turn started during the async reconnect, + // don't clear state belonging to the new turn. + if (Interlocked.Read(ref state.ProcessingGeneration) != reconnectGen) return; + state.HasUsedToolsThisTurn = false; + }); + } + catch (Exception ex) + { + Debug($"[POLL-STALE] '{sessionName}' reconnect failed: {ex.Message}"); + staleCycles = 0; // Reset to avoid immediate re-trigger on next cycle + } + // state.Session is now set (if connect succeeded), next loop iteration + // will return via the state.Session != null check above. + continue; + + case PollAction.Resume: + // Fall through to terminal handling below + break; + } - if (isTerminal) + // PollAction.Resume: terminal event detected { Debug($"[POLL] '{sessionName}' CLI finished (lastEvent={lastEventType}) — resuming session"); diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index c38c4d6d0..734807e4a 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -3074,7 +3074,6 @@ public async Task CreateSessionWithWorktreeAsync( string? model = null, string? initialPrompt = null, string? targetGroupId = null, - string? localPath = null, CancellationToken ct = default) { // Remote mode: send the entire operation to the server as a single atomic command. @@ -3150,7 +3149,7 @@ await _bridgeClient.CreateSessionWithWorktreeAsync(new CreateSessionWithWorktree else { var branch = branchName ?? $"session-{DateTime.Now:yyyyMMdd-HHmmss}"; - wt = await _repoManager.CreateWorktreeAsync(repoId, branch, null, localPath: localPath, ct: ct); + wt = await _repoManager.CreateWorktreeAsync(repoId, branch, null, ct: ct); } // Derive a friendly display name: prefer explicit sessionName, then branch name, diff --git a/PolyPilot/Services/RepoManager.cs b/PolyPilot/Services/RepoManager.cs index a05e16241..61ceb5d30 100644 --- a/PolyPilot/Services/RepoManager.cs +++ b/PolyPilot/Services/RepoManager.cs @@ -141,6 +141,22 @@ public void Load() } } if (changed) Save(); + + // Migration: fix repo names derived from id.Split('-').Last() (issue #570). + // Repos added before this fix have names like "maui" for both "dotnet-maui" and + // "nicknisi-vscode-maui". Re-derive from the URL so they become "maui" vs "vscode-maui". + foreach (var repo in _state.Repositories) + { + var correctName = RepoNameFromUrl(repo.Url, fallbackId: repo.Id); + if (!string.IsNullOrEmpty(correctName) && repo.Name != correctName) + { + Console.WriteLine($"[RepoManager] Migrating repo name: '{repo.Name}' → '{correctName}' (id: {repo.Id})"); + repo.Name = correctName; + changed = true; + } + } + if (changed) Save(); + _loadedSuccessfully = true; } catch (Exception ex) @@ -203,7 +219,7 @@ internal int HealMissingRepos() } catch { /* best effort */ } - var name = repoId.Contains('-') ? repoId.Split('-').Last() : repoId; + var name = RepoNameFromUrl(url, fallbackId: repoId); _state.Repositories.Add(new RepositoryInfo { Id = repoId, @@ -359,6 +375,59 @@ public static string RepoIdFromUrl(string url) return fallback; } + /// + /// Extracts the repository name (last path segment) from a git URL. + /// Unlike which replaces "/" with "-" (losing the distinction + /// between owner separator and dashes in the repo name), this returns just the repo name. + /// e.g. "https://github.com/dotnet/maui" → "maui", + /// "https://github.com/nicknisi/vscode-maui" → "vscode-maui" + /// Falls back to the full ID if no URL is available. + /// + public static string RepoNameFromUrl(string? url, string? fallbackId = null) + { + if (!string.IsNullOrWhiteSpace(url)) + { + // SCP-style SSH: git@github.com:Owner/Repo.git + if (url.Contains('@') && url.Contains(':') && !url.Contains("://")) + { + var path = url.Split(':').Last().TrimEnd('/'); + var segments = path.Split('/'); + var name = segments[^1]; + if (name.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + name = name[..^4]; + if (!string.IsNullOrWhiteSpace(name)) + return name; + } + // HTTPS, ssh://, and other protocol URLs + else if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + var segments = uri.AbsolutePath.Trim('/').Split('/'); + var name = segments[^1]; + if (name.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + name = name[..^4]; + if (!string.IsNullOrWhiteSpace(name)) + return name; + } + // Fallback: treat as path + else + { + var segments = url.Trim('/').Split('/'); + var name = segments[^1]; + if (name.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + name = name[..^4]; + if (!string.IsNullOrWhiteSpace(name)) + return name; + } + } + // No URL — derive from ID (best effort) + if (!string.IsNullOrWhiteSpace(fallbackId)) + { + var dashIdx = fallbackId.IndexOf('-'); + return dashIdx >= 0 ? fallbackId[(dashIdx + 1)..] : fallbackId; + } + return ""; + } + /// /// Normalizes a repository input. Accepts full URLs, SSH paths, or GitHub shorthand (e.g. "dotnet/maui"). /// @@ -402,6 +471,12 @@ private void BackfillWorktreeClonePaths(RepositoryInfo repo) private async Task EnsureRepoCloneInCurrentRootAsync(RepositoryInfo repo, Action? onProgress, CancellationToken ct) { + // If BareClonePath points at a non-bare repo (added via "Existing Folder"), skip clone management. + if (!string.IsNullOrWhiteSpace(repo.BareClonePath) + && Directory.Exists(repo.BareClonePath) + && (Directory.Exists(Path.Combine(repo.BareClonePath, ".git")) || File.Exists(Path.Combine(repo.BareClonePath, ".git")))) + return; + var targetBarePath = GetDesiredBareClonePath(repo.Id); if (!string.IsNullOrWhiteSpace(repo.BareClonePath) && PathsEqual(repo.BareClonePath, targetBarePath) @@ -472,17 +547,7 @@ 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); @@ -504,21 +569,6 @@ internal async Task AddRepositoryAsync(string url, Action /// 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 . + /// git repository with an 'origin' remote, then either reuses an existing + /// (if one was already added via URL) or creates a new one + /// whose points directly at the user's local + /// repo — no bare clone is created. + /// If a repo with the same remote was already added via "Add from URL", the existing + /// repo (and its managed bare clone) is preserved; the local folder is only registered + /// as an external worktree. /// The local folder is also registered as an external worktree so it appears in the /// "📂 Existing" list when creating sessions. /// @@ -570,6 +625,8 @@ public async Task AddRepositoryFromLocalAsync( Action? onProgress = null, CancellationToken ct = default) { + EnsureLoaded(); + // Expand ~ so users can type ~/Projects/myrepo without hitting Directory.Exists failures. if (localPath.StartsWith("~", StringComparison.Ordinal)) localPath = Path.Combine( @@ -602,7 +659,38 @@ 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, localCloneSource: localPath, ct); + // Point BareClonePath at the user's existing repo — no bare clone needed. + var url = NormalizeRepoUrl(remoteUrl); + var id = RepoIdFromUrl(url); + + RepositoryInfo repo; + lock (_stateLock) + { + var existing = _state.Repositories.FirstOrDefault(r => r.Id == id); + if (existing != null) + { + // A repo with this remote already exists (e.g., added via "Add from URL"). + // Keep it as-is — don't overwrite its BareClonePath, which would destroy + // the managed bare clone and break any worktrees that depend on it. + // The local folder will be registered as an external worktree below, + // and the UI caller (AddLocalFolderAsync) will create a 📁 local folder group. + repo = existing; + } + else + { + repo = new RepositoryInfo + { + Id = id, + Name = RepoNameFromUrl(url, fallbackId: id), + Url = url, + BareClonePath = localPath, + AddedAt = DateTime.UtcNow + }; + _state.Repositories.Add(repo); + } + } + Save(); + OnStateChanged?.Invoke(); // Register the local folder as an external worktree so it also appears in the // "📂 Existing" picker when creating repo-based sessions. @@ -687,17 +775,37 @@ private async Task IsGitRepositoryAsync(string path, CancellationToken ct) /// /// Create a new worktree for a repository on a new branch from origin/main. + /// If an existing registered worktree is already on the requested branch, it is reused. + /// Worktrees are always placed in the centralized ~/.polypilot/worktrees/ directory. /// - /// - /// 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) + public virtual async Task CreateWorktreeAsync(string repoId, string branchName, string? baseBranch = null, bool skipFetch = false, CancellationToken ct = default) { EnsureLoaded(); var repo = _state.Repositories.FirstOrDefault(r => r.Id == repoId) ?? throw new InvalidOperationException($"Repository '{repoId}' not found."); + + // Check if an existing PolyPilot-managed worktree for this repo is already on the requested branch. + // Only reuse worktrees under the centralized WorktreesDir — never return the user's own + // checkout (registered as an external worktree) to avoid multiple sessions sharing it. + WorktreeInfo? existingMatch; + var managedWorktreePrefix = Path.GetFullPath(WorktreesDir) + Path.DirectorySeparatorChar; + lock (_stateLock) + { + existingMatch = _state.Worktrees.FirstOrDefault(w => + w.RepoId == repoId + && string.Equals(w.Branch, branchName, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(w.Path) + && Path.GetFullPath(w.Path).StartsWith(managedWorktreePrefix, StringComparison.OrdinalIgnoreCase) + && Directory.Exists(w.Path)); + } + if (existingMatch != null) + { + Console.WriteLine($"[RepoManager] Reusing existing worktree at '{existingMatch.Path}' (branch: {branchName})"); + repo.LastUsedAt = DateTime.UtcNow; + Save(); + return existingMatch; + } + await EnsureRepoCloneInCurrentRootAsync(repo, null, ct); // Fetch latest from origin (prune to clean up deleted remote branches). @@ -715,29 +823,9 @@ public virtual async Task CreateWorktreeAsync(string repoId, strin string worktreePath; var worktreeId = Guid.NewGuid().ToString()[..8]; - 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); - EnsureGitExcludeEntry(localPath, ".polypilot/"); - worktreePath = Path.Combine(repoWorktreesDir, branchName); - - // Guard against path traversal: branch names with ".." or leading "/" could escape - // the directory. Equality with repoWorktreesDir itself is also invalid — an empty - // branch name or a name that normalises to "." would trigger that case. - var resolved = Path.GetFullPath(worktreePath); - if (!resolved.StartsWith(Path.GetFullPath(repoWorktreesDir) + Path.DirectorySeparatorChar, 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}"); - } + // Centralized: place worktree in ~/.polypilot/worktrees/{repoId}-{guid8}/ + Directory.CreateDirectory(WorktreesDir); + worktreePath = Path.Combine(WorktreesDir, $"{repoId}-{worktreeId}"); try { @@ -1083,7 +1171,15 @@ public async Task RemoveRepositoryAsync(string repoId, bool deleteFromDisk, Canc if (deleteFromDisk && Directory.Exists(repo.BareClonePath)) { - try { Directory.Delete(repo.BareClonePath, recursive: true); } catch { } + // Only delete if BareClonePath is under the managed ReposDir. + // Repos added via "Existing Folder" have BareClonePath pointing at the user's + // real project directory — we must NEVER delete that. + var fullClonePath = Path.GetFullPath(repo.BareClonePath); + var managedPrefix = Path.GetFullPath(ReposDir) + Path.DirectorySeparatorChar; + if (fullClonePath.StartsWith(managedPrefix, StringComparison.OrdinalIgnoreCase)) + { + try { Directory.Delete(repo.BareClonePath, recursive: true); } catch { } + } } OnStateChanged?.Invoke(); @@ -1146,7 +1242,20 @@ private async Task GetDefaultBranch(string barePath, CancellationToken c { try { - // Get the default branch name (e.g. "main") + // Prefer origin/HEAD which points at the canonical default branch regardless + // of which branch is currently checked out (important for non-bare repos). + try + { + var originHead = (await RunGitAsync(barePath, ct, "rev-parse", "--abbrev-ref", "origin/HEAD")).Trim(); + if (!string.IsNullOrWhiteSpace(originHead) && originHead != "origin/HEAD") + { + Console.WriteLine($"[RepoManager] Using origin/HEAD: {originHead}"); + return $"refs/remotes/{originHead}"; + } + } + catch { /* origin/HEAD not set — fall through */ } + + // Fallback: use symbolic-ref HEAD (correct for bare repos, may be wrong for non-bare) var headRef = await RunGitAsync(barePath, ct, "symbolic-ref", "HEAD"); var branchName = headRef.Trim().Replace("refs/heads/", ""); @@ -1200,7 +1309,9 @@ private static async Task RunGhAsync(string? workDir, CancellationToken if (workDir != null) { psi.WorkingDirectory = workDir; - // Bare repos need GIT_DIR set explicitly for gh to find the remote + // Bare repos (paths ending in .git) need GIT_DIR set explicitly for gh + // to find the remote. Non-bare repos (including those added via "Existing Folder") + // don't need this — gh discovers the remote from the working directory. if (workDir.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) psi.Environment["GIT_DIR"] = workDir; }