Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions PolyPilot.Tests/RepoManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ namespace PolyPilot.Tests;
[Collection("BaseDir")]
public class RepoManagerTests
{
/// <summary>
/// Create the minimal files a bare git repo needs so IsValidBareRepository returns true.
/// </summary>
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")]
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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");

Expand Down
145 changes: 145 additions & 0 deletions PolyPilot.Tests/WorktreeStrategyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <summary>
/// A FailingRepoManager that also has existing worktrees in its state,
/// so the fallback logic can find them.
/// </summary>
private class FailingRepoManagerWithExistingWorktree : RepoManager
{
public FailingRepoManagerWithExistingWorktree(List<RepositoryInfo> repos, List<WorktreeInfo> 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<WorktreeInfo> 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
}
72 changes: 64 additions & 8 deletions PolyPilot/Services/CopilotService.Organization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
{
Expand All @@ -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");
}
}
}

Expand All @@ -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}");
}
}
}
Expand All @@ -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}");
}
}

Expand Down Expand Up @@ -4257,5 +4300,18 @@ private void AutoAdjustFromFeedback(string groupId, SessionGroup group, List<Wor
}
}

/// <summary>
/// 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.
/// </summary>
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
}
42 changes: 41 additions & 1 deletion PolyPilot/Services/RepoManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -385,6 +392,17 @@ public static string NormalizeRepoUrl(string input)

private string GetDesiredBareClonePath(string repoId) => Path.Combine(ReposDir, $"{repoId}.git");

/// <summary>
/// Quick sanity check: a valid bare repo must have HEAD and refs/.
/// Detects corruption where only objects/ survives.
/// </summary>
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);
Expand All @@ -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}…");
Expand All @@ -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/*");
Expand Down Expand Up @@ -726,6 +757,15 @@ public virtual async Task<WorktreeInfo> 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,
Expand Down