fix(worker): stop sibling worktrees from landing inside the repo tree

Path.GetDirectoryName returned workingDir itself instead of its parent
whenever list.WorkingDir ended in a directory separator, placing
.claudedo-worktrees inside the target repo's own working tree. Add
WorktreeRootResolver to normalize the trailing separator before deriving
the sibling root and to guard that the resulting worktree path never
lands inside workingDir, used by both WorktreeManager and
PlanningSessionManager. Add a startup sweep (LegacyWorktreeFolderRecovery)
that warns when a list's working dir already contains a leftover
.claudedo-worktrees folder from before the fix.
This commit is contained in:
mika kuns
2026-08-10 11:58:34 +02:00
parent 6a2a19cc9e
commit 5d362b6973
9 changed files with 297 additions and 3 deletions
@@ -0,0 +1,83 @@
using ClaudeDo.Worker.Git;
namespace ClaudeDo.Worker.Tests.Git;
public class WorktreeRootResolverTests
{
[Fact]
public void ResolveSiblingRoot_NoTrailingSeparator_ReturnsParent()
{
var workingDir = Path.Combine(Path.GetTempPath(), "cd_repo_no_sep");
var root = WorktreeRootResolver.ResolveSiblingRoot(workingDir);
Assert.Equal(Path.GetFullPath(Path.GetTempPath()).TrimEnd(Path.DirectorySeparatorChar), root);
}
[Fact]
public void ResolveSiblingRoot_WithTrailingSeparator_StillReturnsParent()
{
var workingDir = Path.Combine(Path.GetTempPath(), "cd_repo_with_sep") + Path.DirectorySeparatorChar;
var root = WorktreeRootResolver.ResolveSiblingRoot(workingDir);
Assert.Equal(Path.GetFullPath(Path.GetTempPath()).TrimEnd(Path.DirectorySeparatorChar), root);
}
[Fact]
public void ResolveSiblingRoot_WithTrailingAltSeparator_StillReturnsParent()
{
var workingDir = Path.Combine(Path.GetTempPath(), "cd_repo_with_alt_sep") + "/";
var root = WorktreeRootResolver.ResolveSiblingRoot(workingDir);
Assert.Equal(Path.GetFullPath(Path.GetTempPath()).TrimEnd(Path.DirectorySeparatorChar), root);
}
[Fact]
public void ResolveSiblingRoot_DriveRoot_Throws()
{
var driveRoot = Path.GetPathRoot(Path.GetTempPath())!;
Assert.Throws<InvalidOperationException>(() => WorktreeRootResolver.ResolveSiblingRoot(driveRoot));
}
[Fact]
public void EnsureOutsideWorkingDir_PathOutside_DoesNotThrow()
{
var workingDir = Path.Combine(Path.GetTempPath(), "cd_repo");
var worktreePath = Path.Combine(Path.GetTempPath(), ".claudedo-worktrees", "slug", "task1");
WorktreeRootResolver.EnsureOutsideWorkingDir(worktreePath, workingDir);
}
[Fact]
public void EnsureOutsideWorkingDir_PathInsideWorkingDir_Throws()
{
var workingDir = Path.Combine(Path.GetTempPath(), "cd_repo");
var worktreePath = Path.Combine(workingDir, ".claudedo-worktrees", "slug", "task1");
Assert.Throws<InvalidOperationException>(
() => WorktreeRootResolver.EnsureOutsideWorkingDir(worktreePath, workingDir));
}
[Fact]
public void EnsureOutsideWorkingDir_PathEqualsWorkingDir_Throws()
{
var workingDir = Path.Combine(Path.GetTempPath(), "cd_repo");
Assert.Throws<InvalidOperationException>(
() => WorktreeRootResolver.EnsureOutsideWorkingDir(workingDir, workingDir));
}
[Fact]
public void EnsureOutsideWorkingDir_SiblingWithSharedPrefix_DoesNotThrow()
{
// "cd_repo-other" is NOT under "cd_repo" even though it shares a string prefix;
// a naive StartsWith(workingDir) check would incorrectly reject this.
var workingDir = Path.Combine(Path.GetTempPath(), "cd_repo");
var worktreePath = Path.Combine(Path.GetTempPath(), "cd_repo-other", "task1");
WorktreeRootResolver.EnsureOutsideWorkingDir(worktreePath, workingDir);
}
}