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,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.");
}
}