Skip to content
291 changes: 291 additions & 0 deletions PolyPilot.Tests/RepoManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -369,4 +369,295 @@ public void Load_WithCorruptedState_HealsFromDisk()
}

#endregion

#region AddRepositoryFromLocalAsync Validation Tests

[Fact]
public async Task AddRepositoryFromLocal_NonExistentFolder_ThrowsWithClearMessage()
{
var rm = new RepoManager();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => rm.AddRepositoryFromLocalAsync("/this/path/does/not/exist"));
Assert.Contains("not found", ex.Message);
}

[Fact]
public async Task AddRepositoryFromLocal_FolderWithNoGit_ThrowsWithClearMessage()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"not-a-repo-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
var rm = new RepoManager();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => rm.AddRepositoryFromLocalAsync(tempDir));
Assert.Contains("not a git repository", ex.Message, StringComparison.OrdinalIgnoreCase);
}
finally
{
Directory.Delete(tempDir, true);
}
}

[Fact]
public async Task AddRepositoryFromLocal_GitRepoWithNoOrigin_ThrowsWithClearMessage()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"no-origin-repo-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
// Initialize a real git repo with no remotes
await RunProcess("git", "init", tempDir);
var rm = new RepoManager();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => rm.AddRepositoryFromLocalAsync(tempDir));
Assert.Contains("origin", ex.Message, StringComparison.OrdinalIgnoreCase);
}
finally
{
Directory.Delete(tempDir, true);
}
}

[Fact]
public async Task RegisterExternalWorktree_AddsWorktreeToState()
{
// STRUCTURAL: Verifies that RegisterExternalWorktreeAsync stores a WorktreeInfo
// and fires OnStateChanged, so the sidebar updates after adding a local folder.
var tempDir = Path.Combine(Path.GetTempPath(), $"ext-wt-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
await RunProcess("git", "init", tempDir);
await RunProcess("git", "-C", tempDir, "commit", "--allow-empty", "-m", "init");

var rm = new RepoManager();
RepoManager.SetBaseDirForTesting(Path.Combine(Path.GetTempPath(), $"rmtest-{Guid.NewGuid():N}"));
try
{
// Seed a fake repo entry (skip network)
var repoId = "test-owner-testrepo";
var fakeRepo = new RepositoryInfo { Id = repoId, Name = "testrepo", Url = "https://github.com/test-owner/testrepo.git" };
var stateChangedFired = false;
rm.OnStateChanged += () => stateChangedFired = true;

// Directly inject state (bypass load)
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)!;
var successField = typeof(RepoManager).GetField("_loadedSuccessfully", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
var state = new RepositoryState { Repositories = [fakeRepo] };
stateField.SetValue(rm, state);
loadedField.SetValue(rm, true);
successField.SetValue(rm, true);

await rm.RegisterExternalWorktreeAsync(fakeRepo, tempDir, default);

Assert.True(stateChangedFired, "OnStateChanged must fire so the sidebar refreshes");
Assert.Single(rm.Worktrees, w => w.RepoId == repoId && PathsEqual(w.Path, tempDir));
}
finally
{
RepoManager.SetBaseDirForTesting(TestSetup.TestBaseDir);
}
}
finally
{
Directory.Delete(tempDir, true);
}
}

[Fact]
public async Task RegisterExternalWorktree_Idempotent()
{
// Adding the same path twice should not create duplicate worktree entries.
var tempDir = Path.Combine(Path.GetTempPath(), $"ext-wt-idem-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
await RunProcess("git", "init", tempDir);
await RunProcess("git", "-C", tempDir, "commit", "--allow-empty", "-m", "init");

var rm = new RepoManager();
RepoManager.SetBaseDirForTesting(Path.Combine(Path.GetTempPath(), $"rmtest2-{Guid.NewGuid():N}"));
try
{
var fakeRepo = new RepositoryInfo { Id = "owner-repo", Name = "repo", Url = "https://github.com/owner/repo.git" };
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)!;
var successField = typeof(RepoManager).GetField("_loadedSuccessfully", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
var state = new RepositoryState { Repositories = [fakeRepo] };
stateField.SetValue(rm, state);
loadedField.SetValue(rm, true);
successField.SetValue(rm, true);

await rm.RegisterExternalWorktreeAsync(fakeRepo, tempDir, default);
await rm.RegisterExternalWorktreeAsync(fakeRepo, tempDir, default); // second call

Assert.Single(rm.Worktrees); // exactly one entry
}
finally
{
RepoManager.SetBaseDirForTesting(TestSetup.TestBaseDir);
}
}
finally
{
Directory.Delete(tempDir, true);
}
}

private static bool PathsEqual(string left, string right)
{
var l = Path.GetFullPath(left).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var r = Path.GetFullPath(right).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return string.Equals(l, r, StringComparison.OrdinalIgnoreCase);
}

#region EnsureGitIgnoreEntry Tests

[Fact]
public void EnsureGitIgnoreEntry_CreatesGitIgnoreIfMissing()
{
var tmpDir = Directory.CreateTempSubdirectory("polypilot-test-").FullName;
try
{
var method = typeof(RepoManager).GetMethod("EnsureGitIgnoreEntry",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!;
method.Invoke(null, [tmpDir, ".polypilot/"]);

var gitignorePath = Path.Combine(tmpDir, ".gitignore");
Assert.True(File.Exists(gitignorePath));
var content = File.ReadAllText(gitignorePath);
Assert.Contains(".polypilot/", content);
}
finally { Directory.Delete(tmpDir, true); }
}

[Fact]
public void EnsureGitIgnoreEntry_AppendsIfNotPresent()
{
var tmpDir = Directory.CreateTempSubdirectory("polypilot-test-").FullName;
try
{
var gitignorePath = Path.Combine(tmpDir, ".gitignore");
File.WriteAllText(gitignorePath, "*.user\nbin/\n");

var method = typeof(RepoManager).GetMethod("EnsureGitIgnoreEntry",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!;
method.Invoke(null, [tmpDir, ".polypilot/"]);

var content = File.ReadAllText(gitignorePath);
Assert.Contains(".polypilot/", content);
Assert.Contains("*.user", content); // existing content preserved
}
finally { Directory.Delete(tmpDir, true); }
}

[Fact]
public void EnsureGitIgnoreEntry_IdempotentIfAlreadyPresent()
{
var tmpDir = Directory.CreateTempSubdirectory("polypilot-test-").FullName;
try
{
var gitignorePath = Path.Combine(tmpDir, ".gitignore");
File.WriteAllText(gitignorePath, ".polypilot/\n");

var method = typeof(RepoManager).GetMethod("EnsureGitIgnoreEntry",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!;
method.Invoke(null, [tmpDir, ".polypilot/"]);
method.Invoke(null, [tmpDir, ".polypilot/"]); // call twice

var lines = File.ReadAllLines(gitignorePath);
Assert.Equal(1, lines.Count(l => l.Trim() == ".polypilot/")); // only one entry
}
finally { Directory.Delete(tmpDir, true); }
}

[Fact]
public void EnsureGitIgnoreEntry_MatchesWithoutTrailingSlash()
{
var tmpDir = Directory.CreateTempSubdirectory("polypilot-test-").FullName;
try
{
var gitignorePath = Path.Combine(tmpDir, ".gitignore");
File.WriteAllText(gitignorePath, ".polypilot\n"); // no trailing slash variant

var method = typeof(RepoManager).GetMethod("EnsureGitIgnoreEntry",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!;
method.Invoke(null, [tmpDir, ".polypilot/"]);

var content = File.ReadAllText(gitignorePath);
// Should NOT add a duplicate (already covered by ".polypilot" line)
Assert.DoesNotContain(".polypilot/", content);
}
finally { Directory.Delete(tmpDir, true); }
}

#endregion

#region Nested Worktree Path Traversal Tests

[Theory]
[InlineData("../../evil")]
[InlineData("../sibling")]
[InlineData("foo/../../escape")]
public void CreateWorktree_PathTraversal_InBranchName_IsRejected(string maliciousBranch)
{
// Simulate what CreateWorktreeAsync does: combine repoWorktreesDir + branchName then GetFullPath
var fakeRepoDir = Path.Combine(Path.GetTempPath(), "fake-repo");
var repoWorktreesDir = Path.Combine(fakeRepoDir, ".polypilot", "worktrees");
var worktreePath = Path.Combine(repoWorktreesDir, maliciousBranch);
var resolved = Path.GetFullPath(worktreePath);
var managedBase = Path.GetFullPath(repoWorktreesDir) + Path.DirectorySeparatorChar;

// The guard condition: resolved must start with managedBase
var wouldEscape = !resolved.StartsWith(managedBase, StringComparison.OrdinalIgnoreCase)
&& !resolved.Equals(Path.GetFullPath(repoWorktreesDir), StringComparison.OrdinalIgnoreCase);

Assert.True(wouldEscape, $"Branch '{maliciousBranch}' should escape the managed dir but guard says it doesn't. Resolved: {resolved}");
}

[Theory]
[InlineData("my-feature")]
[InlineData("feature/login")]
[InlineData("fix.typo")]
public void CreateWorktree_ValidBranchName_StaysInsideDir(string safeBranch)
{
var fakeRepoDir = Path.Combine(Path.GetTempPath(), "fake-repo");
var repoWorktreesDir = Path.Combine(fakeRepoDir, ".polypilot", "worktrees");
var worktreePath = Path.Combine(repoWorktreesDir, safeBranch);
var resolved = Path.GetFullPath(worktreePath);
var managedBase = Path.GetFullPath(repoWorktreesDir) + Path.DirectorySeparatorChar;

var wouldEscape = !resolved.StartsWith(managedBase, StringComparison.OrdinalIgnoreCase)
&& !resolved.Equals(Path.GetFullPath(repoWorktreesDir), StringComparison.OrdinalIgnoreCase);

Assert.False(wouldEscape, $"Branch '{safeBranch}' should NOT escape the managed dir. Resolved: {resolved}");
}

#endregion

private static Task RunProcess(string exe, params string[] args)
{
var tcs = new TaskCompletionSource();
var psi = new System.Diagnostics.ProcessStartInfo(exe)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
foreach (var a in args) psi.ArgumentList.Add(a);
// Set EnableRaisingEvents and subscribe Exited BEFORE Start() to avoid the race
// where a fast process exits between Start() and EnableRaisingEvents = true.
var p = new System.Diagnostics.Process { StartInfo = psi, EnableRaisingEvents = true };
p.Exited += (_, _) =>
{
if (p.ExitCode == 0) tcs.TrySetResult();
else tcs.TrySetException(new Exception($"{exe} exited with {p.ExitCode}"));
};
p.Start();
return tcs.Task;
}

#endregion
}
4 changes: 2 additions & 2 deletions PolyPilot.Tests/WorktreeStrategyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public FakeRepoManager(List<RepositoryInfo> repos)
}

public override Task<WorktreeInfo> CreateWorktreeAsync(string repoId, string branchName,
string? baseBranch = null, bool skipFetch = false, CancellationToken ct = default)
string? baseBranch = null, bool skipFetch = false, string? localPath = null, CancellationToken ct = default)
{
CreateCalls.Add((repoId, branchName, skipFetch));
var id = $"wt-{Interlocked.Increment(ref _worktreeCounter)}";
Expand Down Expand Up @@ -482,7 +482,7 @@ public FailingRepoManager(List<RepositoryInfo> repos)
}

public override Task<WorktreeInfo> CreateWorktreeAsync(string repoId, string branchName,
string? baseBranch = null, bool skipFetch = false, CancellationToken ct = default)
string? baseBranch = null, bool skipFetch = false, string? localPath = null, CancellationToken ct = default)
{
throw new InvalidOperationException("Simulated git failure");
}
Expand Down
19 changes: 16 additions & 3 deletions PolyPilot/Components/Layout/SessionListItem.razor
Original file line number Diff line number Diff line change
Expand Up @@ -283,12 +283,25 @@
await OnCloseMenu.InvokeAsync();
try
{
bool hasWorktree = !string.IsNullOrEmpty(Meta?.WorktreeId);
bool hasWorktree = false;
string branchName = "";
if (hasWorktree)
if (!string.IsNullOrEmpty(Meta?.WorktreeId))
{
var wt = RepoManager.Worktrees.FirstOrDefault(w => w.Id == Meta!.WorktreeId);
branchName = wt?.Branch ?? "";
// Only offer worktree/branch deletion for PolyPilot-managed worktrees.
// External worktrees (added via "Add Existing Folder") are user-owned β€” we must not delete them.
// Managed locations: ~/.polypilot/worktrees/ (centralized) OR {repo}/.polypilot/worktrees/ (nested)
var managedDir = RepoManager.GetWorktreesDir();
var isNested = wt != null && wt.Path.Contains(
Path.DirectorySeparatorChar + ".polypilot" + Path.DirectorySeparatorChar + "worktrees" + Path.DirectorySeparatorChar,
StringComparison.OrdinalIgnoreCase);
bool isManaged = wt != null &&
(wt.Path.StartsWith(managedDir, StringComparison.OrdinalIgnoreCase) || isNested);
if (isManaged)
{
hasWorktree = true;
branchName = wt!.Branch ?? "";
}
}

var result = await JS.InvokeAsync<CloseDialogResult>(
Expand Down
Loading