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,