diff --git a/src/ClaudeDo.Worker/Git/WorktreeRootResolver.cs b/src/ClaudeDo.Worker/Git/WorktreeRootResolver.cs new file mode 100644 index 00000000..9ff3f6c2 --- /dev/null +++ b/src/ClaudeDo.Worker/Git/WorktreeRootResolver.cs @@ -0,0 +1,36 @@ +namespace ClaudeDo.Worker.Git; + +/// +/// Resolves the "sibling" worktree root and guards against a worktree landing inside the +/// repository it was created from. A list.WorkingDir ending in a directory separator +/// makes return the same directory instead of its +/// parent, which used to place .claudedo-worktrees inside the target repo's own tree. +/// +public static class WorktreeRootResolver +{ + /// The parent directory of , trailing-separator safe. + public static string ResolveSiblingRoot(string workingDir) + { + var full = Path.TrimEndingDirectorySeparator(Path.GetFullPath(workingDir)); + var parent = Path.GetDirectoryName(full); + if (string.IsNullOrEmpty(parent)) + throw new InvalidOperationException( + $"Cannot place a sibling worktree next to '{workingDir}': it has no parent directory."); + return parent; + } + + /// Throws if is or lies beneath it. + public static void EnsureOutsideWorkingDir(string worktreePath, string workingDir) + { + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var worktreeFull = Path.GetFullPath(worktreePath); + var workingDirFull = Path.TrimEndingDirectorySeparator(Path.GetFullPath(workingDir)); + + var isInside = worktreeFull.Equals(workingDirFull, comparison) + || worktreeFull.StartsWith(workingDirFull + Path.DirectorySeparatorChar, comparison); + if (isInside) + throw new InvalidOperationException( + $"Refusing to create worktree '{worktreeFull}' inside the target repository's working " + + $"tree '{workingDirFull}'. Check list.WorkingDir for a trailing separator or a misconfigured worktree root."); + } +} diff --git a/src/ClaudeDo.Worker/Lifecycle/LegacyWorktreeFolderRecovery.cs b/src/ClaudeDo.Worker/Lifecycle/LegacyWorktreeFolderRecovery.cs new file mode 100644 index 00000000..6b685f3f --- /dev/null +++ b/src/ClaudeDo.Worker/Lifecycle/LegacyWorktreeFolderRecovery.cs @@ -0,0 +1,54 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Worker.Lifecycle; + +/// +/// Startup-only sweep: warns when a list's working directory already contains a +/// .claudedo-worktrees folder inside its own tree — the symptom of the sibling-worktree +/// path bug (see ) from before it was +/// fixed. Detection only; moving or ignoring existing folders is left to the user. +/// +public sealed class LegacyWorktreeFolderRecovery : IHostedService +{ + private const string LegacyFolderName = ".claudedo-worktrees"; + + private readonly IDbContextFactory _dbFactory; + private readonly ILogger _logger; + + public LegacyWorktreeFolderRecovery( + IDbContextFactory dbFactory, + ILogger logger) + { + _dbFactory = dbFactory; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); + var lists = await new ListRepository(ctx).GetAllAsync(cancellationToken); + + var affected = FindAffectedListNames(lists); + + if (affected.Count > 0) + _logger.LogWarning( + "Legacy worktree folder recovery: found '{Folder}' inside the working tree of {Count} list(s): {Lists}. " + + "This was previously caused by a trailing separator on the list's working directory; existing worktrees were not moved.", + LegacyFolderName, affected.Count, string.Join(", ", affected)); + else + _logger.LogInformation("Legacy worktree folder recovery: no lists affected"); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// Names of lists whose working dir already contains a .claudedo-worktrees folder. + public static List FindAffectedListNames(IEnumerable lists) => + lists + .Where(l => !string.IsNullOrWhiteSpace(l.WorkingDir)) + .Where(l => Directory.Exists(Path.Combine(l.WorkingDir!, LegacyFolderName))) + .Select(l => l.Name) + .ToList(); +} diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs index 9e8358b6..dd51f714 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs @@ -5,6 +5,7 @@ using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Git; using ClaudeDo.Worker.State; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -366,9 +367,11 @@ public sealed class PlanningSessionManager var raw = strategy.Equals("central", StringComparison.OrdinalIgnoreCase) ? Path.Combine(centralRoot, "planning", taskId) - : Path.Combine(Path.GetDirectoryName(listWorkingDir)!, ".claudedo-worktrees", "planning", taskId); + : Path.Combine(WorktreeRootResolver.ResolveSiblingRoot(listWorkingDir), ".claudedo-worktrees", "planning", taskId); - return Path.GetFullPath(raw); + var worktreePath = Path.GetFullPath(raw); + WorktreeRootResolver.EnsureOutsideWorkingDir(worktreePath, listWorkingDir); + return worktreePath; } private static string TokenFilePathFor(string sessionDir) => diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 2b8d2166..5dec1a2b 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -69,6 +69,7 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddSignalR().AddJsonProtocol(options => { options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); diff --git a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs index 98d795bc..398309e1 100644 --- a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs +++ b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs @@ -3,6 +3,7 @@ using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Git; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Worker.Runner; @@ -52,9 +53,10 @@ public sealed class WorktreeManager var worktreePath = strategy.Equals("central", StringComparison.OrdinalIgnoreCase) ? Path.Combine(centralRoot ?? _cfg.CentralWorktreeRoot, slug, task.Id) - : Path.Combine(Path.GetDirectoryName(workingDir)!, ".claudedo-worktrees", slug, task.Id); + : Path.Combine(WorktreeRootResolver.ResolveSiblingRoot(workingDir), ".claudedo-worktrees", slug, task.Id); worktreePath = Path.GetFullPath(worktreePath); + WorktreeRootResolver.EnsureOutsideWorkingDir(worktreePath, workingDir); // Ensure parent directory exists. Directory.CreateDirectory(Path.GetDirectoryName(worktreePath)!); diff --git a/tests/ClaudeDo.Worker.Tests/Git/WorktreeRootResolverTests.cs b/tests/ClaudeDo.Worker.Tests/Git/WorktreeRootResolverTests.cs new file mode 100644 index 00000000..209ad34c --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Git/WorktreeRootResolverTests.cs @@ -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(() => 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( + () => WorktreeRootResolver.EnsureOutsideWorkingDir(worktreePath, workingDir)); + } + + [Fact] + public void EnsureOutsideWorkingDir_PathEqualsWorkingDir_Throws() + { + var workingDir = Path.Combine(Path.GetTempPath(), "cd_repo"); + + Assert.Throws( + () => 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); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Lifecycle/LegacyWorktreeFolderRecoveryTests.cs b/tests/ClaudeDo.Worker.Tests/Lifecycle/LegacyWorktreeFolderRecoveryTests.cs new file mode 100644 index 00000000..1248dd1c --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Lifecycle/LegacyWorktreeFolderRecoveryTests.cs @@ -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 _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.Instance); + + await sut.StartAsync(CancellationToken.None); + await sut.StopAsync(CancellationToken.None); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs index 1e273113..4b96e8e6 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs @@ -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() { diff --git a/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs index ff72d81c..fac749d0 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs @@ -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();