diff --git a/PolyPilot.Tests/RepoManagerTests.cs b/PolyPilot.Tests/RepoManagerTests.cs index fa18dba7b..5e7cc5954 100644 --- a/PolyPilot.Tests/RepoManagerTests.cs +++ b/PolyPilot.Tests/RepoManagerTests.cs @@ -6,6 +6,15 @@ namespace PolyPilot.Tests; [Collection("BaseDir")] public class RepoManagerTests { + /// + /// Create the minimal files a bare git repo needs so IsValidBareRepository returns true. + /// + private static void CreateFakeBareRepoSkeleton(string bareDir) + { + Directory.CreateDirectory(bareDir); + Directory.CreateDirectory(Path.Combine(bareDir, "refs")); + File.WriteAllText(Path.Combine(bareDir, "HEAD"), "ref: refs/heads/main\n"); + } [Theory] [InlineData("https://github.com/Owner/Repo.git", "Owner-Repo")] [InlineData("https://github.com/Owner/Repo", "Owner-Repo")] @@ -215,7 +224,7 @@ public void HealMissingRepos_DiscoversUntracked_BareClones() { // Create a fake bare clone directory with a git config var bareDir = Path.Combine(reposDir, "Owner-Repo.git"); - Directory.CreateDirectory(bareDir); + CreateFakeBareRepoSkeleton(bareDir); File.WriteAllText(Path.Combine(bareDir, "config"), "[remote \"origin\"]\n\turl = https://github.com/Owner/Repo\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n"); @@ -308,7 +317,7 @@ public void HealMissingRepos_MultipleUntracked_AllDiscovered() foreach (var name in new[] { "dotnet-maui.git", "PureWeen-PolyPilot.git", "github-sdk.git" }) { var dir = Path.Combine(reposDir, name); - Directory.CreateDirectory(dir); + CreateFakeBareRepoSkeleton(dir); File.WriteAllText(Path.Combine(dir, "config"), $"[remote \"origin\"]\n\turl = https://github.com/test/{name.Replace(".git", "")}\n"); } @@ -351,7 +360,7 @@ public void Load_WithCorruptedState_HealsFromDisk() { // Create a bare clone on disk var bareDir = Path.Combine(reposDir, "Owner-Repo.git"); - Directory.CreateDirectory(bareDir); + CreateFakeBareRepoSkeleton(bareDir); File.WriteAllText(Path.Combine(bareDir, "config"), "[remote \"origin\"]\n\turl = https://github.com/Owner/Repo\n"); diff --git a/PolyPilot.Tests/WorktreeStrategyTests.cs b/PolyPilot.Tests/WorktreeStrategyTests.cs index 24281c1fb..7931d1c93 100644 --- a/PolyPilot.Tests/WorktreeStrategyTests.cs +++ b/PolyPilot.Tests/WorktreeStrategyTests.cs @@ -769,4 +769,149 @@ public async Task FullyIsolated_WorktreeIdSetOnAgentSessionInfo() } #endregion + + #region Worktree Failure Fallback (Windows long-path / git failure scenarios) + + [Fact] + public async Task GroupShared_WorktreeFailure_FallsBackToExistingWorktree() + { + // Simulate scenario: worktree creation fails (e.g., Windows long-path issue) + // but an existing worktree for the repo exists — should use it instead of temp dir. + var existingWt = new WorktreeInfo + { + Id = "existing-wt", + RepoId = "repo-1", + Branch = "main", + Path = "/existing/worktree/path" + }; + var rm = new FailingRepoManagerWithExistingWorktree( + new() { new() { Id = "repo-1", Name = "Repo" } }, + new() { existingWt }); + var svc = CreateDemoService(rm); + var preset = MakePreset(2, WorktreeStrategy.GroupShared); + + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: null, + repoId: "repo-1"); + + Assert.NotNull(group); + // Should have fallen back to the existing worktree + Assert.Equal("existing-wt", group!.WorktreeId); + + // Sessions should use the existing worktree path, not a temp dir + var organized = svc.GetOrganizedSessions(); + var groupSessions = organized.FirstOrDefault(g => g.Group.Id == group!.Id).Sessions; + Assert.NotNull(groupSessions); + Assert.All(groupSessions, s => + { + Assert.NotNull(s.WorkingDirectory); + Assert.Equal("/existing/worktree/path", s.WorkingDirectory); + }); + } + + [Fact] + public async Task GroupShared_WorktreeFailure_WithWorkingDirectory_UsesWorkingDirectory() + { + // When worktree creation fails but a workingDirectory was provided, + // sessions should use the workingDirectory (not temp) + var rm = new FailingRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(2, WorktreeStrategy.GroupShared); + + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: "/provided/fallback", + repoId: "repo-1"); + + Assert.NotNull(group); + + // orchWorkDir should still be /provided/fallback since worktree failed + var organized = svc.GetOrganizedSessions(); + var groupSessions = organized.FirstOrDefault(g => g.Group.Id == group!.Id).Sessions; + Assert.NotNull(groupSessions); + Assert.All(groupSessions, s => + { + Assert.NotNull(s.WorkingDirectory); + Assert.Equal("/provided/fallback", s.WorkingDirectory); + }); + } + + [Fact] + public async Task FullyIsolated_WorktreeFailure_FallsBackToExistingWorktree() + { + var existingWt = new WorktreeInfo + { + Id = "existing-wt", + RepoId = "repo-1", + Branch = "main", + Path = "/existing/worktree/path" + }; + var rm = new FailingRepoManagerWithExistingWorktree( + new() { new() { Id = "repo-1", Name = "Repo" } }, + new() { existingWt }); + var svc = CreateDemoService(rm); + var preset = MakePreset(2, WorktreeStrategy.FullyIsolated); + + var group = await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: null, + repoId: "repo-1"); + + Assert.NotNull(group); + // Orchestrator should have fallen back to existing worktree + Assert.Equal("existing-wt", group!.WorktreeId); + + // Sessions should still be created + var members = svc.Organization.Sessions + .Where(s => s.GroupId == group!.Id) + .ToList(); + Assert.Equal(3, members.Count); // 1 orch + 2 workers + } + + [Fact] + public async Task GroupShared_BranchName_UsesSharedPrefix() + { + // GroupShared should create a worktree with "-shared-" in the branch name, + // not "-orchestrator-" — this is the explicit handling fix. + var rm = new FakeRepoManager(new() { new() { Id = "repo-1", Name = "Repo" } }); + var svc = CreateDemoService(rm); + var preset = MakePreset(2, WorktreeStrategy.GroupShared); + + await svc.CreateGroupFromPresetAsync(preset, + workingDirectory: null, + repoId: "repo-1", + nameOverride: "MyTeam"); + + Assert.Single(rm.CreateCalls); + Assert.Contains("shared", rm.CreateCalls[0].BranchName); + Assert.DoesNotContain("orchestrator", rm.CreateCalls[0].BranchName); + } + + /// + /// A FailingRepoManager that also has existing worktrees in its state, + /// so the fallback logic can find them. + /// + private class FailingRepoManagerWithExistingWorktree : RepoManager + { + public FailingRepoManagerWithExistingWorktree(List repos, List worktrees) + { + 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(this, new RepositoryState { Repositories = repos, Worktrees = worktrees }); + loadedField.SetValue(this, true); + } + + public override Task CreateWorktreeAsync(string repoId, string branchName, + string? baseBranch = null, bool skipFetch = false, string? localPath = null, CancellationToken ct = default) + { + throw new InvalidOperationException("Simulated Windows long-path failure"); + } + + public override Task FetchAsync(string repoId, CancellationToken ct = default) + { + return Task.CompletedTask; // Fetch succeeds, only worktree creation fails + } + } + + #endregion } diff --git a/PolyPilot/Services/CopilotService.Organization.cs b/PolyPilot/Services/CopilotService.Organization.cs index b4ba6151b..b1008cbbf 100644 --- a/PolyPilot/Services/CopilotService.Organization.cs +++ b/PolyPilot/Services/CopilotService.Organization.cs @@ -3243,14 +3243,15 @@ public string GetEffectiveModel(string sessionName) var orchWorkDir = workingDirectory; var orchWtId = worktreeId; - // Pre-fetch once to avoid parallel git lock contention (local mode only; server handles fetch in remote) - if (repoId != null && strategy != WorktreeStrategy.Shared && !IsRemoteMode) + // Pre-fetch once to avoid parallel git lock contention (local mode only; server handles fetch in remote). + // Shared and GroupShared fetch inside their own block below. + if (repoId != null && strategy != WorktreeStrategy.Shared && strategy != WorktreeStrategy.GroupShared && !IsRemoteMode) { try { await _repoManager.FetchAsync(repoId, ct); } catch (Exception ex) { Debug($"Pre-fetch failed (continuing): {ex.Message}"); } } - // For Shared strategy with a repo but no worktree, create a single shared worktree + // For Shared strategy with a repo but no worktree/dir, create a single shared worktree if (repoId != null && strategy == WorktreeStrategy.Shared && string.IsNullOrEmpty(worktreeId) && string.IsNullOrEmpty(workingDirectory)) { try @@ -3264,11 +3265,45 @@ public string GetEffectiveModel(string sessionName) } catch (Exception ex) { - Debug($"Failed to create shared worktree (sessions will use temp dirs): {ex.Message}"); + Debug($"[WorktreeStrategy] Failed to create shared worktree for strategy={strategy}, repoId={repoId}: {ex.GetType().Name}: {ex.Message}"); + orchWorkDir = TryGetExistingWorktreePath(repoId, ref orchWtId, group); + if (orchWorkDir != null) + Debug($"[WorktreeStrategy] Using existing worktree as fallback: {orchWorkDir}"); + else + Debug($"[WorktreeStrategy] No existing worktree found — sessions will use temp dirs"); } } - if (repoId != null && strategy != WorktreeStrategy.Shared && string.IsNullOrEmpty(worktreeId)) + // GroupShared: always create one shared worktree for the group (even if workingDirectory is set, + // because the group needs its own branch). Uses same naming as Shared. + if (repoId != null && strategy == WorktreeStrategy.GroupShared && string.IsNullOrEmpty(worktreeId)) + { + try + { + if (!IsRemoteMode) await _repoManager.FetchAsync(repoId, ct); + var sharedWt = await CreateWorktreeLocalOrRemoteAsync(repoId, $"{branchPrefix}-shared-{Guid.NewGuid().ToString()[..4]}", ct); + orchWorkDir = sharedWt.Path; + orchWtId = sharedWt.Id; + group.WorktreeId = orchWtId; + group.CreatedWorktreeIds.Add(orchWtId); + } + catch (Exception ex) + { + Debug($"[WorktreeStrategy] Failed to create shared worktree for strategy={strategy}, repoId={repoId}: {ex.GetType().Name}: {ex.Message}"); + // Try to fall back to an existing worktree for this repo instead of temp dir + if (orchWorkDir == null) + { + orchWorkDir = TryGetExistingWorktreePath(repoId, ref orchWtId, group); + if (orchWorkDir != null) + Debug($"[WorktreeStrategy] Using existing worktree as fallback: {orchWorkDir}"); + else + Debug($"[WorktreeStrategy] No existing worktree found — sessions will use temp dirs"); + } + } + } + + // OrchestratorIsolated / FullyIsolated: create a dedicated orchestrator worktree + if (repoId != null && strategy != WorktreeStrategy.Shared && strategy != WorktreeStrategy.GroupShared && string.IsNullOrEmpty(worktreeId)) { try { @@ -3280,7 +3315,15 @@ public string GetEffectiveModel(string sessionName) } catch (Exception ex) { - Debug($"Failed to create orchestrator worktree (falling back to shared): {ex.Message}"); + Debug($"[WorktreeStrategy] Failed to create orchestrator worktree for strategy={strategy}, repoId={repoId}: {ex.GetType().Name}: {ex.Message}"); + if (orchWorkDir == null) + { + orchWorkDir = TryGetExistingWorktreePath(repoId, ref orchWtId, group); + if (orchWorkDir != null) + Debug($"[WorktreeStrategy] Using existing worktree as fallback: {orchWorkDir}"); + else + Debug($"[WorktreeStrategy] No existing worktree found — sessions will use temp dirs"); + } } } @@ -3300,7 +3343,7 @@ public string GetEffectiveModel(string sessionName) } catch (Exception ex) { - Debug($"Failed to create worker-{i + 1} worktree (falling back to shared): {ex.Message}"); + Debug($"[WorktreeStrategy] Failed to create worker-{i + 1} worktree: {ex.GetType().Name}: {ex.Message}"); } } } @@ -3318,7 +3361,7 @@ public string GetEffectiveModel(string sessionName) } catch (Exception ex) { - Debug($"Failed to create shared worker worktree (falling back to shared): {ex.Message}"); + Debug($"[WorktreeStrategy] Failed to create shared worker worktree: {ex.GetType().Name}: {ex.Message}"); } } @@ -4257,5 +4300,18 @@ private void AutoAdjustFromFeedback(string groupId, SessionGroup group, List + /// When worktree creation fails (e.g., long paths on Windows), try to find an existing + /// worktree for the repo so sessions get a real working directory instead of a temp dir. + /// + private string? TryGetExistingWorktreePath(string repoId, ref string? worktreeId, SessionGroup group) + { + var existing = _repoManager.Worktrees.FirstOrDefault(w => w.RepoId == repoId); + if (existing == null) return null; + worktreeId = existing.Id; + group.WorktreeId = existing.Id; + return existing.Path; + } + #endregion } diff --git a/PolyPilot/Services/RepoManager.cs b/PolyPilot/Services/RepoManager.cs index 727415bed..eb8a09153 100644 --- a/PolyPilot/Services/RepoManager.cs +++ b/PolyPilot/Services/RepoManager.cs @@ -183,6 +183,13 @@ internal int HealMissingRepos() if (trackedIds.Contains(repoId)) continue; + // Skip corrupted bare clones (e.g. only objects/ survived) + if (!IsValidBareRepository(bareDir)) + { + Console.WriteLine($"[RepoManager] Skipping corrupted bare clone during heal: {bareDir}"); + continue; + } + // Read remote URL from bare clone's git config var url = ""; try @@ -385,6 +392,17 @@ public static string NormalizeRepoUrl(string input) private string GetDesiredBareClonePath(string repoId) => Path.Combine(ReposDir, $"{repoId}.git"); + /// + /// Quick sanity check: a valid bare repo must have HEAD and refs/. + /// Detects corruption where only objects/ survives. + /// + private static bool IsValidBareRepository(string barePath) + { + if (!Directory.Exists(barePath)) return false; + return File.Exists(Path.Combine(barePath, "HEAD")) + && Directory.Exists(Path.Combine(barePath, "refs")); + } + private static bool PathsEqual(string left, string right) { var normalizedLeft = Path.GetFullPath(left).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); @@ -405,12 +423,20 @@ private async Task EnsureRepoCloneInCurrentRootAsync(RepositoryInfo repo, Action var targetBarePath = GetDesiredBareClonePath(repo.Id); if (!string.IsNullOrWhiteSpace(repo.BareClonePath) && PathsEqual(repo.BareClonePath, targetBarePath) - && Directory.Exists(targetBarePath)) + && IsValidBareRepository(targetBarePath)) return; lock (_stateLock) BackfillWorktreeClonePaths(repo); Directory.CreateDirectory(ReposDir); + // If the directory exists but is corrupt (e.g. only objects/ survived), + // nuke it so we can re-clone cleanly. + if (Directory.Exists(targetBarePath) && !IsValidBareRepository(targetBarePath)) + { + Console.WriteLine($"[RepoManager] Bare clone corrupted (missing HEAD/refs), removing: {targetBarePath}"); + try { Directory.Delete(targetBarePath, recursive: true); } catch { } + } + if (Directory.Exists(targetBarePath)) { onProgress?.Invoke($"Fetching {repo.Id}…"); @@ -419,6 +445,11 @@ private async Task EnsureRepoCloneInCurrentRootAsync(RepositoryInfo repo, Action } else { + if (string.IsNullOrWhiteSpace(repo.Url)) + throw new InvalidOperationException( + $"Cannot clone repository '{repo.Id}': no URL configured. " + + "Re-add the repository with a valid URL."); + onProgress?.Invoke($"Cloning {repo.Url}…"); await RunGitWithProgressAsync(null, onProgress, ct, "clone", "--bare", "--progress", repo.Url, targetBarePath); await RunGitAsync(targetBarePath, ct, "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"); @@ -726,6 +757,15 @@ public virtual async Task CreateWorktreeAsync(string repoId, strin throw; } + // On Windows, enable long paths in the worktree so files with deep directory + // structures (e.g., MAUI repo) can be checked out. The bare clone already has + // this set, but worktree-local operations may need it explicitly. + if (OperatingSystem.IsWindows()) + { + try { await RunGitAsync(worktreePath, ct, "config", "core.longpaths", "true"); } + catch { /* best-effort — bare clone config is inherited as fallback */ } + } + var wt = new WorktreeInfo { Id = worktreeId,