abhigyanpatwari/GitNexus · error · ValueError

workspace snapshot root must be a real directory: {root}

Error message

workspace snapshot root must be a real directory: {root}

What it means

Raised by workspace_snapshot() (runner_artifacts.py:130) when the worktree root is a symlink, not a directory, or is not resolve-stable (root.resolve(strict=True) != root). The snapshot walker refuses to follow links at the root so that a symlinked workspace cannot redirect hashing outside the benchmark boundary. This is the entry guard for the tamper-evident workspace diff used by phase-boundary checks.

Source

Thrown at eval/workflow_bench/runner_artifacts.py:130

def _is_bootstrap_noise(relative: PurePosixPath) -> bool:
    """Report whether a walked entry is harness noise rather than workspace change."""

    parts = relative.parts
    if parts[0] == ".git" or parts[0] in WORKSPACE_SNAPSHOT_BOOTSTRAP_NOISE:
        return True
    return len(parts) >= 2 and parts[-2] == CLAUDE_BOOTSTRAP_DIR and parts[-1] in CLAUDE_BOOTSTRAP_ENTRIES


def workspace_snapshot(worktree: Path) -> dict[str, str]:
    """Hash the workspace without following links, excluding Git internals
    and Claude Code's own sandbox-bootstrap noise (see
    WORKSPACE_SNAPSHOT_BOOTSTRAP_NOISE)."""

    root = worktree.expanduser().absolute()
    mode = root.lstat().st_mode
    if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode) or root.resolve(strict=True) != root:
        raise ValueError(f"workspace snapshot root must be a real directory: {root}")

    snapshot: dict[str, str] = {}
    pending: list[tuple[Path, PurePosixPath]] = [(root, PurePosixPath())]
    entry_count = 0
    path_bytes = 0
    file_bytes = 0
    nofollow = getattr(os, "O_NOFOLLOW", 0)
    while pending:
        directory, relative_dir = pending.pop()
        try:
            children = sorted(os.scandir(directory), key=lambda entry: entry.name, reverse=True)
        except OSError as exc:
            raise ValueError(f"workspace snapshot directory is unreadable: {directory}: {exc}") from exc
        for entry in children:
            relative = relative_dir / entry.name
            if _is_bootstrap_noise(relative):
                continue
            entry_count += 1

View on GitHub (pinned to d540b00184)

Solutions

  1. Pass the real (resolved) absolute path of the worktree: Path(...).resolve() before calling workspace_snapshot or before passing --repo.
  2. Remove any symlink at the worktree root and re-create it as a real directory (make_worktree already does rmdir + clone, so ensure no leftover symlink).
  3. On macOS, avoid /tmp; use a path under /Users/<user>/ or the project dir where realpath is stable.

Example fix

# before
root = Path("~/repos/proj").expanduser().absolute()  # may be a symlink

# after
root = Path("~/repos/proj").expanduser().resolve(strict=True)
Defensive patterns

Strategy: validation

Validate before calling

import os, stat
from pathlib import Path

root = Path(worktree).expanduser().resolve(strict=True)
mode = root.lstat().st_mode
assert not stat.S_ISLNK(mode), f"root is a symlink: {root}"
assert stat.S_ISDIR(mode), f"root is not a directory: {root}"
# resolve-stable: resolve(strict=True) must equal the absolute path
assert root == Path(worktree).expanduser().absolute(), (
    f"root is not resolve-stable: {worktree} -> {root}")
# Pass `root` (resolved) to workspace_snapshot, not the original path.

Type guard

import os, stat
from pathlib import Path

def is_real_directory(path: os.PathLike | str) -> bool:
    p = Path(path).expanduser()
    try:
        st = p.lstat()
    except OSError:
        return False
    if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
        return False
    try:
        return p.resolve(strict=True) == p.absolute()
    except OSError:
        return False

Prevention

When it happens

Trigger: workspace_snapshot(worktree) is called with a worktree that lstat() reports as S_ISLNK, S_ISDIR false (e.g. a regular file or device node), or whose realpath differs from the absolute path (symlink loop, bind-mount whose resolution differs). Happens when make_worktree's clone target was replaced by a symlink, or the caller passed a ~/ symlinked path.

Common situations: User passes --repo ~/repos/project where ~/repos is a symlink to /mnt/repos; a prior failed run left a symlink where the worktree directory should be; running on macOS where /tmp is a symlink to /private/tmp and expanduser().absolute() does not resolve it.

Related errors


AI-assisted analysis of abhigyanpatwari/GitNexus@d540b00184 (2026-08-12). Data as JSON: /api/errors/9b3be0bd0d086b90. Report an issue: GitHub.