abhigyanpatwari/GitNexus · critical · ValueError
overlay destination parent must not be a symlink: {target}
Error message
overlay destination parent must not be a symlink: {target} What it means
All errors below are raised by internal helpers of `eval/workflow_bench/promotion_apply.py` and propagate to the caller of the public entry points: `apply_promoted_overlay(overlay, repo_root, *, expected_digest, expected_target_bases)`, `destination_base_digests(overlay, repo_root)`, `committed_destination_base_digests(overlay, repo_root, *, ref)` and `freeze_overlay(overlay, destination)`. The module applies promoted skill overlays across the canonical skill tree plus its shipped mirrors (`gitnexus/skills`, `gitnexus-claude-plugin/skills`) in a TOCTOU-hardened, symlink-rejecting, descriptor-bound transaction. During the parent walk, an intermediate component is a symlink (`stat.S_ISLNK(metadata.st_mode)`) or not a directory. The walk refuses to descend through symlinks or non-directory entries — every component of the target's parent chain must be a real directory.
Source
Thrown at eval/workflow_bench/promotion_apply.py:203
except BaseException:
os.close(descriptor)
raise
return root, descriptor
def _open_target_parent(root_descriptor: int, target: PurePosixPath) -> int:
if target.is_absolute() or not target.parts or ".." in target.parts:
raise ValueError(f"overlay destination escapes repository: {target}")
flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
current = os.dup(root_descriptor)
try:
for part in target.parts[:-1]:
try:
metadata = os.stat(part, dir_fd=current, follow_symlinks=False)
except OSError as exc:
raise ValueError(f"overlay destination parent is unavailable: {target}") from exc
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
raise ValueError(f"overlay destination parent must not be a symlink: {target}")
try:
child = os.open(part, flags, dir_fd=current)
except OSError as exc:
raise ValueError(f"overlay destination parent changed while opening: {target}") from exc
opened = os.fstat(child)
if (
opened.st_dev,
opened.st_ino,
stat.S_IFMT(opened.st_mode),
) != (
metadata.st_dev,
metadata.st_ino,
stat.S_IFMT(metadata.st_mode),
):
os.close(child)
raise ValueError(f"overlay destination parent changed while opening: {target}")
os.close(current)
current = childView on GitHub (pinned to d540b00184)
Solutions
- Locate the offending component: walk `target.parts[:-1]` from root, `lstat` each.
- Replace the symlink with the real directory it should name (or remove it).
- Re-run promotion once the parent chain is all real directories.
Example fix
// before
# root/.claude/skills/foo -> /shared/foo (symlink)
apply_promoted_overlay(overlay, repo_root=root)
// after
bad = root / '.claude' / 'skills' / 'foo'
if bad.is_symlink():
bad.unlink(); bad.mkdir()
apply_promoted_overlay(overlay, repo_root=root) Defensive patterns
Strategy: validation
Validate before calling
def assert_parents_are_real_dirs(root, payload):
for rel, _ in payload:
for t in mirror_targets(rel):
cur = root
for part in t.parts[:-1]:
cur = cur / part
st = cur.lstat()
if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
raise ValueError(f'parent component not a real dir: {cur}') Type guard
import stat
def parents_are_real_dirs(root, payload) -> bool:
for rel, _ in payload:
for t in mirror_targets(rel):
cur = root
for part in t.parts[:-1]:
cur = cur / part
try:
st = cur.lstat()
except OSError:
return False
if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
return False
return True Try / catch
except ValueError as exc:
if 'parent must not be a symlink' in str(exc):
raise SystemExit(f'refuse to descend through symlink in target chain: {exc}') Prevention
- Never symlink any directory in the skill mirror tree; the walk forbids it.
- Validate the parent chain is all real dirs before applying.
- Treat a symlink in the chain as a tamper signal and refuse, not auto-fix.
When it happens
Trigger: An intermediate directory in a target's parent chain is a symlink, or has been replaced by a regular file/socket/etc.
Common situations: Someone symlinked a skill category dir for sharing across repos; a broken merge left a file where a directory was expected; a packaging tool wrote a symlink member.
Related errors
- candidate destination must be a regular non-symlink file: {r
- candidate overlay cannot traverse symlinks: {overlay}
- repository root must be a real directory: {root}
- repository root must not traverse symlinks: {root}
- {label} must be a real directory: {lexical}: {exc}
AI-assisted analysis of abhigyanpatwari/GitNexus@d540b00184 (2026-08-12).
Data as JSON: /api/errors/d97a4d23fa0ee8d0.
Report an issue: GitHub.