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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Lifecycle;
|
||||
|
||||
public sealed class LegacyWorktreeFolderRecoveryTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly List<string> _tempDirs = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_db.Dispose();
|
||||
foreach (var d in _tempDirs)
|
||||
{
|
||||
try { Directory.Delete(d, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private string MakeWorkingDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), $"claudedo_legacy_wt_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(dir);
|
||||
_tempDirs.Add(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindAffectedListNames_FolderInsideWorkingDir_ReturnsListName()
|
||||
{
|
||||
var wd = MakeWorkingDir();
|
||||
Directory.CreateDirectory(Path.Combine(wd, ".claudedo-worktrees"));
|
||||
var list = new ListEntity { Id = "1", Name = "Bandel.Hub", WorkingDir = wd, CreatedAt = DateTime.UtcNow };
|
||||
|
||||
var affected = LegacyWorktreeFolderRecovery.FindAffectedListNames(new[] { list });
|
||||
|
||||
Assert.Equal(new[] { "Bandel.Hub" }, affected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindAffectedListNames_NoLegacyFolder_ReturnsEmpty()
|
||||
{
|
||||
var wd = MakeWorkingDir();
|
||||
var list = new ListEntity { Id = "1", Name = "Clean", WorkingDir = wd, CreatedAt = DateTime.UtcNow };
|
||||
|
||||
var affected = LegacyWorktreeFolderRecovery.FindAffectedListNames(new[] { list });
|
||||
|
||||
Assert.Empty(affected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindAffectedListNames_NullWorkingDir_IsSkipped()
|
||||
{
|
||||
var list = new ListEntity { Id = "1", Name = "NoRepo", WorkingDir = null, CreatedAt = DateTime.UtcNow };
|
||||
|
||||
var affected = LegacyWorktreeFolderRecovery.FindAffectedListNames(new[] { list });
|
||||
|
||||
Assert.Empty(affected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_NoLists_DoesNotThrow()
|
||||
{
|
||||
var sut = new LegacyWorktreeFolderRecovery(_db.CreateFactory(), NullLogger<LegacyWorktreeFolderRecovery>.Instance);
|
||||
|
||||
await sut.StartAsync(CancellationToken.None);
|
||||
await sut.StopAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,30 @@ public sealed class PlanningSessionManagerTests : IDisposable
|
||||
Assert.NotNull(loaded.PlanningSessionToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_TrailingSeparatorOnWorkingDir_PlacesWorktreeOutsideRepo()
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}");
|
||||
GitRepoFixture.InitRepoWithInitialCommit(wd);
|
||||
await _lists.AddAsync(new ListEntity
|
||||
{
|
||||
Id = listId,
|
||||
Name = "Test",
|
||||
WorkingDir = wd + Path.DirectorySeparatorChar,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
var parent = await SeedManualTaskAsync(listId);
|
||||
|
||||
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||
|
||||
var wdFull = Path.GetFullPath(wd);
|
||||
Assert.False(
|
||||
ctx.WorktreePath.StartsWith(wdFull + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase),
|
||||
$"worktree path {ctx.WorktreePath} should not be nested inside the repo dir {wdFull}");
|
||||
Assert.True(Directory.Exists(ctx.WorktreePath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_TaskNotManual_Throws()
|
||||
{
|
||||
|
||||
@@ -189,6 +189,26 @@ public class WorktreeManagerTests : IDisposable
|
||||
Assert.DoesNotContain(task.Id, worktreeList);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_TrailingSeparatorOnWorkingDir_PlacesWorktreeOutsideRepo()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = CreateRepo();
|
||||
var workingDirWithSeparator = repo.RepoDir + Path.DirectorySeparatorChar;
|
||||
var (task, list) = MakeEntities(workingDirWithSeparator);
|
||||
var (mgr, db) = await CreateManagerAsync(task, list);
|
||||
|
||||
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
||||
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
||||
|
||||
var repoDirFull = Path.GetFullPath(repo.RepoDir);
|
||||
Assert.False(
|
||||
ctx.WorktreePath.StartsWith(repoDirFull + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase),
|
||||
$"worktree path {ctx.WorktreePath} should not be nested inside the repo dir {repoDirFull}");
|
||||
Assert.True(Directory.Exists(ctx.WorktreePath));
|
||||
}
|
||||
|
||||
private static (TaskEntity task, ListEntity list) MakeEntities(string workingDir)
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
|
||||
Reference in New Issue
Block a user