Skip to content
Merged
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
80 changes: 80 additions & 0 deletions PolyPilot.Tests/SessionOrganizationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1098,6 +1098,86 @@ public void ReconcileOrganization_ExternalWorktree_DoesNotPromoteWhenLocalGroupA
localGroup.LocalPath);
}

[Fact]
public void ReconcileOrganization_LocalOnlyRepo_DoesNotCreateUrlGroup_ForCentralizedWorktree()
{
// Regression test: when a repo ONLY has a local folder group (no URL-based group),
// sessions with centralized worktrees (~/.polypilot/worktrees/...) should stay in
// the local folder group. ReconcileOrganization must NOT create a duplicate URL-based
// group just to move the session into it.
// Note: repo ID must use the real "-local-" format from RepoManager.AddRepositoryFromLocalAsync.
var repos = new List<RepositoryInfo>
{
new() { Id = "dotnet-maui-local-a1b2c3d4", Name = "maui", Url = "https://github.com/dotnet/maui" }
};
var localPath = Path.Combine(Path.GetTempPath(), "maui3");
var centralPath = Path.Combine(Path.GetTempPath(), ".polypilot", "worktrees", "dotnet-maui-local-wt1");
var worktrees = new List<WorktreeInfo>
{
new() { Id = "wt-central", RepoId = "dotnet-maui-local-a1b2c3d4", Branch = "session-123", Path = centralPath }
};
var rm = CreateRepoManagerWithState(repos, worktrees);
var svc = CreateService(rm);

// Create ONLY a local folder group (no URL-based group)
var localGroup = svc.GetOrCreateLocalFolderGroup(localPath, "dotnet-maui-local-a1b2c3d4");
Assert.True(localGroup.IsLocalFolder);

// Put a session in the local folder group with a centralized worktree
var meta = new SessionMeta
{
SessionName = "test-session",
GroupId = localGroup.Id,
WorktreeId = "wt-central"
};
svc.Organization.Sessions.Add(meta);

typeof(CopilotService).GetProperty("IsInitialized")!.SetValue(svc, true);
svc.ReconcileOrganization(allowPruning: false);

// Session should stay in the local folder group — no URL group created
Assert.Equal(localGroup.Id, meta.GroupId);
Assert.DoesNotContain(svc.Organization.Groups,
g => g.RepoId == "dotnet-maui-local-a1b2c3d4" && !g.IsLocalFolder && !g.IsMultiAgent);
}

[Fact]
public void GetOrCreateRepoGroup_ReturnsNull_ForLocalOnlyRepoWithExistingLocalGroup()
{
// Direct unit test: GetOrCreateRepoGroup must return null for local-only repos
// (IDs containing "-local-") when a local folder group already covers the repo.
var svc = CreateService();
var localPath = Path.Combine(Path.GetTempPath(), "my-project");

// Create a local folder group for a local-only repo
svc.GetOrCreateLocalFolderGroup(localPath, "owner-repo-local-a1b2c3d4");

// GetOrCreateRepoGroup should return null — not create a duplicate
var result = svc.GetOrCreateRepoGroup("owner-repo-local-a1b2c3d4", "repo");
Assert.Null(result);

// No URL-based group should exist
Assert.DoesNotContain(svc.Organization.Groups,
g => g.RepoId == "owner-repo-local-a1b2c3d4" && !g.IsLocalFolder);
}

[Fact]
public void GetOrCreateRepoGroup_AllowsCreation_ForNonLocalRepo_WithLocalFolderGroup()
{
// Ensure the guard does NOT block URL group creation for repos without "-local-" in ID.
// This supports the heal-stranded-sessions scenario where both URL and local groups coexist.
var svc = CreateService();
var localPath = Path.Combine(Path.GetTempPath(), "my-project");

// Create a local folder group for a non-local repo (same ID for both)
svc.GetOrCreateLocalFolderGroup(localPath, "owner-repo");

// GetOrCreateRepoGroup should succeed — this is NOT a local-only repo
var result = svc.GetOrCreateRepoGroup("owner-repo", "repo");
Assert.NotNull(result);
Assert.False(result!.IsLocalFolder);
}

[Fact]
public void ReconcileOrganization_NestedWorktree_IsNotTreatedAsExternalWorktree()
{
Expand Down
18 changes: 18 additions & 0 deletions PolyPilot/Services/CopilotService.Organization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,10 @@ internal void ReconcileOrganization(bool allowPruning = true)
meta.GroupId = urlGroup.Id;
changed = true;
}
else
{
Debug($"ReconcileOrganization: keeping '{meta.SessionName}' in local folder group '{localGroup.Name}' — no URL group for local-only repo '{localGroup.RepoId}'");
}
}
}
}
Expand Down Expand Up @@ -1438,6 +1442,20 @@ internal bool IsWorkerInMultiAgentGroup(string sessionName)
if (!explicitly && Organization.DeletedRepoGroupRepoIds.Contains(repoId))
return null;

// Don't create a URL-based group when a local folder group already covers this repo
// and no URL-based group exists. This prevents duplicate sidebar entries for local-only
// repos (e.g., "maui" appearing twice — once as local folder, once as URL-based).
// Local-only repos are identified by RepoManager.IsLocalOnlyRepoId (IDs containing the
// "-local-" infix from AddRepositoryFromLocalAsync). For these repos, the local folder
// group IS the repo's group — no URL-based group should be created.
// Exception: repos WITHOUT the local infix (same ID for both URL and local groups)
// are allowed to create URL groups for the heal-stranded-sessions scenario.
if (!explicitly && RepoManager.IsLocalOnlyRepoId(repoId)
&& Organization.Groups.Any(g => g.RepoId == repoId && g.IsLocalFolder && !g.IsMultiAgent))
{
return null;
}

// Clear the deleted flag when explicitly re-adding
Organization.DeletedRepoGroupRepoIds.Remove(repoId);

Expand Down
18 changes: 14 additions & 4 deletions PolyPilot/Services/RepoManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ namespace PolyPilot.Services;
/// </summary>
public class RepoManager
{
/// <summary>
/// Infix used in repo IDs for local folder repos (e.g., "dotnet-maui-local-a1b2c3d4").
/// Generated by <see cref="AddRepositoryFromLocalAsync"/> when a URL-based repo already exists.
/// </summary>
internal const string LocalRepoIdInfix = "-local-";

/// <summary>Returns true if the repo ID identifies a local-only repo (added via "Existing Folder").</summary>
internal static bool IsLocalOnlyRepoId(string repoId) =>
repoId.Contains(LocalRepoIdInfix, StringComparison.Ordinal);

private static string? _baseDirOverride;
private static readonly object _pathLock = new();
private static string? _stateFile;
Expand Down Expand Up @@ -456,7 +466,7 @@ public static string RepoNameFromUrl(string? url, string? fallbackId = null)
// Strip "-local-{hash}" suffix before deriving name so local repo IDs like
// "dotnet-maui-local-a1b2c3d4" produce "maui" instead of "maui-local-a1b2c3d4".
var cleanId = fallbackId;
var localIdx = cleanId.IndexOf("-local-", StringComparison.Ordinal);
var localIdx = cleanId.IndexOf(LocalRepoIdInfix, StringComparison.Ordinal);
if (localIdx > 0)
cleanId = cleanId[..localIdx];
var dashIdx = cleanId.IndexOf('-');
Expand Down Expand Up @@ -501,7 +511,7 @@ internal static string WorktreeDirName(string repoId, string worktreeId)
// Strip "-local-{hash}" suffixes from "Existing Folder" repo IDs to shorten the path.
// e.g., "dotnet-maui-local-a1b2c3d4" → "dotnet-maui"
var abbreviated = repoId;
var localIdx = abbreviated.IndexOf("-local-", StringComparison.Ordinal);
var localIdx = abbreviated.IndexOf(LocalRepoIdInfix, StringComparison.Ordinal);
if (localIdx > 0)
abbreviated = abbreviated[..localIdx];

Expand Down Expand Up @@ -802,7 +812,7 @@ public async Task<RepositoryInfo> AddRepositoryFromLocalAsync(
// Use SHA256 for a deterministic hash — string.GetHashCode() is randomized per-process
// in .NET Core 3.0+ and must not be persisted.
var pathHash = DeterministicPathHash(localPath);
var localId = $"{baseId}-local-{pathHash}";
var localId = $"{baseId}{LocalRepoIdInfix}{pathHash}";

// Ensure we don't collide with an existing entry with this generated ID.
// Idempotency is guaranteed by the `existingLocal` path-match check above (line 781),
Expand All @@ -816,7 +826,7 @@ public async Task<RepositoryInfo> AddRepositoryFromLocalAsync(
else if (alreadyLocal != null)
{
// True hash collision — same ID, different path. Disambiguate with GUID.
localId = $"{baseId}-local-{Guid.NewGuid().ToString("N")[..8]}";
localId = $"{baseId}{LocalRepoIdInfix}{Guid.NewGuid().ToString("N")[..8]}";
repo = new RepositoryInfo
{
Id = localId,
Expand Down