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
99 changes: 99 additions & 0 deletions PolyPilot.Tests/SessionOrganizationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -933,6 +933,105 @@ public void PromoteOrCreateLocalFolderGroup_PromotesMostRecentUrlGroup_WhenMulti
Assert.False(olderGroup!.IsLocalFolder); // older group stays URL-based
}

[Fact]
public void PromoteOrCreateLocalFolderGroup_PreservesUserGroupName_WhenFolderNameDiffers()
{
// Regression test: when the user's group is named "maui" and the local folder
// is "~/Projects/maui2", promotion must NOT rename the group to "maui2".
var svc = CreateService();
// Note: path need not exist on disk; promotion matches on repoId, not disk state.
var localRepoPath = Path.Combine(Path.GetTempPath(), "maui2");
var expectedPath = Path.GetFullPath(localRepoPath)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);

// Simulate: user has a group named "maui" for repo "dotnet-maui"
var urlGroup = svc.GetOrCreateRepoGroup("dotnet-maui", "maui");
Assert.NotNull(urlGroup);
Assert.Equal("maui", urlGroup!.Name);

// Promote with a folder whose basename is "maui2" — NOT "maui"
var result = svc.PromoteOrCreateLocalFolderGroup(localRepoPath, "dotnet-maui");

Assert.Equal(urlGroup.Id, result.Id);
Assert.True(result.IsLocalFolder);
Assert.Equal(expectedPath, result.LocalPath);
// Name must be preserved — NOT overwritten with "maui2"
Assert.Equal("maui", result.Name);
}

[Fact]
public void ReconcileOrganization_ExternalWorktree_PreservesGroupName_WhenPromoting()
{
// Regression test: ReconcileOrganization's external worktree migration must
// preserve the group's name when promoting it to a local folder group.
var repos = new List<RepositoryInfo>
{
new() { Id = "dotnet-maui", Name = "maui", Url = "https://github.com/dotnet/maui" }
};
// External worktree folder name is "maui2" — differs from group name "maui"
// Note: path need not exist on disk; ReconcileOrganization matches on repoId, not disk state.
var extPath = Path.Combine(Path.GetTempPath(), "maui2");
var worktrees = new List<WorktreeInfo>
{
new() { Id = "ext-1", RepoId = "dotnet-maui", Branch = "main", Path = extPath }
};
var rm = CreateRepoManagerWithState(repos, worktrees);
var svc = CreateService(rm);

// Create a URL-based group named "maui" (user's custom name)
var urlGroup = svc.GetOrCreateRepoGroup("dotnet-maui", "maui");
Assert.Equal("maui", urlGroup!.Name);

svc.ReconcileOrganization();

var promoted = svc.Organization.Groups.First(g => g.Id == urlGroup.Id);
Assert.True(promoted.IsLocalFolder);
// Name must be "maui" — NOT "maui2" (the folder basename)
Assert.Equal("maui", promoted.Name);
}

[Fact]
public void PromoteOrCreateLocalFolderGroup_CreationPath_UsesFolderBasename()
{
// Documents intentional asymmetry: when no URL group exists to promote,
// the creation path names the group after the folder basename.
// This differs from promotion (which preserves the existing group name).
var svc = CreateService();
// Note: path need not exist on disk; creation matches on repoId, not disk state.
var localRepoPath = Path.Combine(Path.GetTempPath(), "my-project");
var expectedName = "my-project";

// No existing group for "new-repo" — creation path will be used
var result = svc.PromoteOrCreateLocalFolderGroup(localRepoPath, "new-repo");

Assert.True(result.IsLocalFolder);
Assert.Equal(expectedName, result.Name);
}

[Fact]
public void PromoteOrCreateLocalFolderGroup_FallsBackToFolderName_WhenGroupNameIsEmpty()
{
// Defensive: if a promotable group has an empty name (corrupt state),
// promotion falls back to the folder basename instead of preserving blank.
var svc = CreateService();
var localRepoPath = Path.Combine(Path.GetTempPath(), "fallback-name");
var expectedPath = Path.GetFullPath(localRepoPath)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);

// Create a URL group and then blank out its name to simulate corrupt state
var urlGroup = svc.GetOrCreateRepoGroup("corrupt-repo", "original");
Assert.NotNull(urlGroup);
urlGroup!.Name = "";

var result = svc.PromoteOrCreateLocalFolderGroup(localRepoPath, "corrupt-repo");

Assert.Equal(urlGroup.Id, result.Id);
Assert.True(result.IsLocalFolder);
Assert.Equal(expectedPath, result.LocalPath);
// Empty name should be repaired with the folder basename
Assert.Equal("fallback-name", result.Name);
}

[Fact]
public void ReconcileOrganization_ExternalWorktree_PromotesUrlGroupToLocalFolderGroup()
{
Expand Down
22 changes: 18 additions & 4 deletions PolyPilot/Services/CopilotService.Organization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -741,9 +741,14 @@ internal void ReconcileOrganization(bool allowPruning = true)
if (groupToPromote != null)
{
groupToPromote.LocalPath = normalizedExtPath;
groupToPromote.Name = Path.GetFileName(normalizedExtPath);
// Preserve the user's group name — don't overwrite with the folder basename.
// The old code did: groupToPromote.Name = Path.GetFileName(normalizedExtPath)
// which destroyed user-customized names (e.g., "maui" → "maui2").
// Fallback: if the group somehow has an empty name, use the folder basename.
if (string.IsNullOrWhiteSpace(groupToPromote.Name))
groupToPromote.Name = Path.GetFileName(normalizedExtPath);
changed = true;
Debug($"ReconcileOrganization: promoted group '{groupToPromote.Id}' to local folder group for '{normalizedExtPath}'");
Debug($"ReconcileOrganization: promoted group '{groupToPromote.Id}' ('{groupToPromote.Name}') to local folder group for '{normalizedExtPath}'");

// Migrate sessions whose worktrees are NOT under the new LocalPath to the
// URL-based repo group. Without this, sessions linked to managed worktrees
Expand Down Expand Up @@ -1533,15 +1538,24 @@ public SessionGroup PromoteOrCreateLocalFolderGroup(string localPath, string? re
if (candidate != null)
{
candidate.LocalPath = normalized;
candidate.Name = Path.GetFileName(normalized);
// Preserve the user's group name — don't overwrite with the folder basename.
// The old code did: candidate.Name = Path.GetFileName(normalized)
// which destroyed user-customized names (e.g., "maui" → "maui2").
// Fallback: if the group somehow has an empty name, use the folder basename.
if (string.IsNullOrWhiteSpace(candidate.Name))
candidate.Name = Path.GetFileName(normalized);
SaveOrganization();
OnStateChanged?.Invoke();
Debug($"PromoteOrCreateLocalFolderGroup: promoted '{candidate.Id}' to local folder group for '{normalized}'");
Debug($"PromoteOrCreateLocalFolderGroup: promoted '{candidate.Id}' ('{candidate.Name}') to local folder group for '{normalized}'");
return candidate;
}
}

// No existing group to promote — create a fresh local folder group.
// Note: the creation path uses Path.GetFileName(localPath) as the group name,
// which differs from promotion (which preserves the existing name). This is
// intentional: new groups get a sensible default from the folder name, while
// existing groups keep whatever the user (or auto-creation) named them.
return GetOrCreateLocalFolderGroup(localPath, repoId);
}

Expand Down