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,36 @@
|
||||
namespace ClaudeDo.Worker.Git;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the "sibling" worktree root and guards against a worktree landing inside the
|
||||
/// repository it was created from. A <c>list.WorkingDir</c> ending in a directory separator
|
||||
/// makes <see cref="Path.GetDirectoryName(string)"/> return the same directory instead of its
|
||||
/// parent, which used to place <c>.claudedo-worktrees</c> inside the target repo's own tree.
|
||||
/// </summary>
|
||||
public static class WorktreeRootResolver
|
||||
{
|
||||
/// <summary>The parent directory of <paramref name="workingDir"/>, trailing-separator safe.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Throws if <paramref name="worktreePath"/> is <paramref name="workingDir"/> or lies beneath it.</summary>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Worker.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Startup-only sweep: warns when a list's working directory already contains a
|
||||
/// <c>.claudedo-worktrees</c> folder inside its own tree — the symptom of the sibling-worktree
|
||||
/// path bug (see <see cref="ClaudeDo.Worker.Git.WorktreeRootResolver"/>) from before it was
|
||||
/// fixed. Detection only; moving or ignoring existing folders is left to the user.
|
||||
/// </summary>
|
||||
public sealed class LegacyWorktreeFolderRecovery : IHostedService
|
||||
{
|
||||
private const string LegacyFolderName = ".claudedo-worktrees";
|
||||
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly ILogger<LegacyWorktreeFolderRecovery> _logger;
|
||||
|
||||
public LegacyWorktreeFolderRecovery(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
ILogger<LegacyWorktreeFolderRecovery> 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;
|
||||
|
||||
/// <summary>Names of lists whose working dir already contains a <c>.claudedo-worktrees</c> folder.</summary>
|
||||
public static List<string> FindAffectedListNames(IEnumerable<ListEntity> lists) =>
|
||||
lists
|
||||
.Where(l => !string.IsNullOrWhiteSpace(l.WorkingDir))
|
||||
.Where(l => Directory.Exists(Path.Combine(l.WorkingDir!, LegacyFolderName)))
|
||||
.Select(l => l.Name)
|
||||
.ToList();
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -69,6 +69,7 @@ builder.Services.AddHostedService<StaleTaskRecovery>();
|
||||
builder.Services.AddHostedService<OrphanRecovery>();
|
||||
builder.Services.AddHostedService<AttachmentOrphanRecovery>();
|
||||
builder.Services.AddHostedService<PromptFileRecovery>();
|
||||
builder.Services.AddHostedService<LegacyWorktreeFolderRecovery>();
|
||||
builder.Services.AddSignalR().AddJsonProtocol(options =>
|
||||
{
|
||||
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||
|
||||
@@ -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)!);
|
||||
|
||||
Reference in New Issue
Block a user