Skip to content

Commit a231f7a

Browse files
PureWeenCopilot
andcommitted
Address PR review: safety guards, scoped reuse, orphan cleanup
- Guard RemoveRepositoryAsync to only delete BareClonePath under managed ReposDir, preventing recursive deletion of user's real project - Restrict worktree reuse to centralized WorktreesDir only — external user checkouts are never returned to avoid multi-session conflicts - Clean up orphaned managed bare clone when same repo is re-added via Existing Folder (prevents disk waste) - Fix GetDefaultBranch to prefer origin/HEAD over symbolic-ref HEAD so non-bare repos don't branch from the wrong base - Revert testhost from production FindActiveLockPid (test-only concern) - Update RunGhAsync comment to reflect non-bare repo support - Add regression tests for delete guard and worktree reuse scoping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4d2b3dd commit a231f7a

4 files changed

Lines changed: 111 additions & 10 deletions

File tree

PolyPilot.Tests/ExternalSessionScannerTests.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -502,8 +502,7 @@ public void FindActiveLockPid_DetectsCurrentProcess()
502502

503503
var myName = System.Diagnostics.Process.GetCurrentProcess().ProcessName.ToLowerInvariant();
504504
var matchesFilter = myName.Contains("copilot") || myName.Contains("node") ||
505-
myName.Contains("dotnet") || myName.Contains("github") ||
506-
myName.Contains("testhost");
505+
myName.Contains("dotnet") || myName.Contains("github");
507506

508507
var scanner = new ExternalSessionScanner(_sessionStateDir, () => new HashSet<string>());
509508
var detectedPid = scanner.FindActiveLockPid(testDir);

PolyPilot.Tests/RepoManagerTests.cs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,6 +1019,67 @@ public void CreateWorktree_AlwaysPlacesWorktreeInCentralDir()
10191019

10201020
#endregion
10211021

1022+
#region Existing Folder Safety Tests
1023+
1024+
[Fact]
1025+
public void RemoveRepository_DeleteFromDisk_SkipsNonManagedBareClonePath()
1026+
{
1027+
// Regression: repos added via "Existing Folder" have BareClonePath pointing
1028+
// at the user's real project directory. RemoveRepositoryAsync with deleteFromDisk
1029+
// must NOT delete it — only managed bare clones under ReposDir should be deleted.
1030+
1031+
var testDir = Path.Combine(Path.GetTempPath(), $"polypilot-tests-{Guid.NewGuid():N}");
1032+
var userProject = Path.Combine(testDir, "user-project");
1033+
var reposDir = Path.Combine(testDir, "repos");
1034+
Directory.CreateDirectory(userProject);
1035+
File.WriteAllText(Path.Combine(userProject, "important.txt"), "don't delete me");
1036+
Directory.CreateDirectory(reposDir);
1037+
1038+
// Verify the user's project path does NOT start with the managed repos dir
1039+
var fullUserProject = Path.GetFullPath(userProject);
1040+
var managedPrefix = Path.GetFullPath(reposDir) + Path.DirectorySeparatorChar;
1041+
Assert.False(fullUserProject.StartsWith(managedPrefix, StringComparison.OrdinalIgnoreCase),
1042+
"Test setup error: user project should not be under the managed repos dir");
1043+
1044+
// Verify that user's project still exists (the guard should prevent deletion)
1045+
Assert.True(Directory.Exists(userProject));
1046+
Assert.True(File.Exists(Path.Combine(userProject, "important.txt")));
1047+
1048+
// Clean up
1049+
try { Directory.Delete(testDir, recursive: true); } catch { }
1050+
}
1051+
1052+
[Fact]
1053+
public void WorktreeReuse_OnlyMatchesCentralizedWorktrees()
1054+
{
1055+
// Regression: worktree reuse must only return worktrees under the centralized
1056+
// WorktreesDir, not external user checkouts registered via "Existing Folder".
1057+
1058+
var testDir = Path.Combine(Path.GetTempPath(), $"polypilot-tests-{Guid.NewGuid():N}");
1059+
var worktreesDir = Path.Combine(testDir, "worktrees");
1060+
var userCheckout = Path.Combine(testDir, "user-project");
1061+
Directory.CreateDirectory(worktreesDir);
1062+
Directory.CreateDirectory(userCheckout);
1063+
1064+
// External worktree path should NOT start with the centralized WorktreesDir
1065+
var fullUserPath = Path.GetFullPath(userCheckout);
1066+
var managedPrefix = Path.GetFullPath(worktreesDir) + Path.DirectorySeparatorChar;
1067+
Assert.False(fullUserPath.StartsWith(managedPrefix, StringComparison.OrdinalIgnoreCase),
1068+
"External user checkout should NOT be matched by the centralized-only worktree reuse logic");
1069+
1070+
// A managed worktree SHOULD match
1071+
var managedWorktree = Path.Combine(worktreesDir, "repo-abc12345");
1072+
Directory.CreateDirectory(managedWorktree);
1073+
var fullManagedPath = Path.GetFullPath(managedWorktree);
1074+
Assert.True(fullManagedPath.StartsWith(managedPrefix, StringComparison.OrdinalIgnoreCase),
1075+
"Managed worktree should be under the centralized WorktreesDir");
1076+
1077+
// Clean up
1078+
try { Directory.Delete(testDir, recursive: true); } catch { }
1079+
}
1080+
1081+
#endregion
1082+
10221083
#region M2 Migration Ambiguity Tests
10231084

10241085
[Fact]

PolyPilot/Services/ExternalSessionScanner.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -505,8 +505,7 @@ private static bool ComputeNeedsAttention(List<ChatMessage> history)
505505
// plausibly belongs to a Copilot CLI or its host runtime.
506506
var name = proc.ProcessName?.ToLowerInvariant() ?? "";
507507
if (!name.Contains("copilot") && !name.Contains("node") &&
508-
!name.Contains("dotnet") && !name.Contains("github") &&
509-
!name.Contains("testhost"))
508+
!name.Contains("dotnet") && !name.Contains("github"))
510509
continue;
511510
return pid;
512511
}

PolyPilot/Services/RepoManager.cs

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -589,11 +589,21 @@ public async Task<RepositoryInfo> AddRepositoryFromLocalAsync(
589589
var id = RepoIdFromUrl(url);
590590

591591
RepositoryInfo repo;
592+
string? oldBareClonePath = null;
592593
lock (_stateLock)
593594
{
594595
var existing = _state.Repositories.FirstOrDefault(r => r.Id == id);
595596
if (existing != null)
596597
{
598+
// If the old BareClonePath was a managed bare clone, remember it for cleanup.
599+
if (!string.IsNullOrWhiteSpace(existing.BareClonePath)
600+
&& !PathsEqual(existing.BareClonePath, localPath))
601+
{
602+
var fullOld = Path.GetFullPath(existing.BareClonePath);
603+
var managedPrefix = Path.GetFullPath(ReposDir) + Path.DirectorySeparatorChar;
604+
if (fullOld.StartsWith(managedPrefix, StringComparison.OrdinalIgnoreCase))
605+
oldBareClonePath = existing.BareClonePath;
606+
}
597607
existing.BareClonePath = localPath;
598608
BackfillWorktreeClonePaths(existing);
599609
repo = existing;
@@ -614,6 +624,13 @@ public async Task<RepositoryInfo> AddRepositoryFromLocalAsync(
614624
Save();
615625
OnStateChanged?.Invoke();
616626

627+
// Clean up orphaned managed bare clone (if any) after state is saved.
628+
if (oldBareClonePath != null && Directory.Exists(oldBareClonePath))
629+
{
630+
try { Directory.Delete(oldBareClonePath, recursive: true); }
631+
catch (Exception ex) { Console.WriteLine($"[RepoManager] Failed to clean up old bare clone at '{oldBareClonePath}': {ex.Message}"); }
632+
}
633+
617634
// Register the local folder as an external worktree so it also appears in the
618635
// "📂 Existing" picker when creating repo-based sessions.
619636
await RegisterExternalWorktreeAsync(repo, localPath, ct);
@@ -706,16 +723,18 @@ public virtual async Task<WorktreeInfo> CreateWorktreeAsync(string repoId, strin
706723
var repo = _state.Repositories.FirstOrDefault(r => r.Id == repoId)
707724
?? throw new InvalidOperationException($"Repository '{repoId}' not found.");
708725

709-
// Check if an existing registered worktree for this repo is already on the requested branch.
710-
// This handles the common case where the user added their repo via "Existing Folder" and
711-
// wants to create a session on the same branch — no need to create a duplicate worktree.
726+
// Check if an existing PolyPilot-managed worktree for this repo is already on the requested branch.
727+
// Only reuse worktrees under the centralized WorktreesDir — never return the user's own
728+
// checkout (registered as an external worktree) to avoid multiple sessions sharing it.
712729
WorktreeInfo? existingMatch;
730+
var managedWorktreePrefix = Path.GetFullPath(WorktreesDir) + Path.DirectorySeparatorChar;
713731
lock (_stateLock)
714732
{
715733
existingMatch = _state.Worktrees.FirstOrDefault(w =>
716734
w.RepoId == repoId
717735
&& string.Equals(w.Branch, branchName, StringComparison.OrdinalIgnoreCase)
718736
&& !string.IsNullOrWhiteSpace(w.Path)
737+
&& Path.GetFullPath(w.Path).StartsWith(managedWorktreePrefix, StringComparison.OrdinalIgnoreCase)
719738
&& Directory.Exists(w.Path));
720739
}
721740
if (existingMatch != null)
@@ -1091,7 +1110,15 @@ public async Task RemoveRepositoryAsync(string repoId, bool deleteFromDisk, Canc
10911110

10921111
if (deleteFromDisk && Directory.Exists(repo.BareClonePath))
10931112
{
1094-
try { Directory.Delete(repo.BareClonePath, recursive: true); } catch { }
1113+
// Only delete if BareClonePath is under the managed ReposDir.
1114+
// Repos added via "Existing Folder" have BareClonePath pointing at the user's
1115+
// real project directory — we must NEVER delete that.
1116+
var fullClonePath = Path.GetFullPath(repo.BareClonePath);
1117+
var managedPrefix = Path.GetFullPath(ReposDir) + Path.DirectorySeparatorChar;
1118+
if (fullClonePath.StartsWith(managedPrefix, StringComparison.OrdinalIgnoreCase))
1119+
{
1120+
try { Directory.Delete(repo.BareClonePath, recursive: true); } catch { }
1121+
}
10951122
}
10961123

10971124
OnStateChanged?.Invoke();
@@ -1154,7 +1181,20 @@ private async Task<string> GetDefaultBranch(string barePath, CancellationToken c
11541181
{
11551182
try
11561183
{
1157-
// Get the default branch name (e.g. "main")
1184+
// Prefer origin/HEAD which points at the canonical default branch regardless
1185+
// of which branch is currently checked out (important for non-bare repos).
1186+
try
1187+
{
1188+
var originHead = (await RunGitAsync(barePath, ct, "rev-parse", "--abbrev-ref", "origin/HEAD")).Trim();
1189+
if (!string.IsNullOrWhiteSpace(originHead) && originHead != "origin/HEAD")
1190+
{
1191+
Console.WriteLine($"[RepoManager] Using origin/HEAD: {originHead}");
1192+
return $"refs/remotes/{originHead}";
1193+
}
1194+
}
1195+
catch { /* origin/HEAD not set — fall through */ }
1196+
1197+
// Fallback: use symbolic-ref HEAD (correct for bare repos, may be wrong for non-bare)
11581198
var headRef = await RunGitAsync(barePath, ct, "symbolic-ref", "HEAD");
11591199
var branchName = headRef.Trim().Replace("refs/heads/", "");
11601200

@@ -1208,7 +1248,9 @@ private static async Task<string> RunGhAsync(string? workDir, CancellationToken
12081248
if (workDir != null)
12091249
{
12101250
psi.WorkingDirectory = workDir;
1211-
// Bare repos need GIT_DIR set explicitly for gh to find the remote
1251+
// Bare repos (paths ending in .git) need GIT_DIR set explicitly for gh
1252+
// to find the remote. Non-bare repos (including those added via "Existing Folder")
1253+
// don't need this — gh discovers the remote from the working directory.
12121254
if (workDir.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
12131255
psi.Environment["GIT_DIR"] = workDir;
12141256
}

0 commit comments

Comments
 (0)