fix: skip unreachable reparse points in detect_rooms_from_folders (#558)
On Windows, projects containing git-submodule junctions or dev-drive reparse points cause iterdir() to list the entry successfully but Path.is_dir() to raise OSError when it calls stat() internally. Reproducer: any Windows project with a submodule checked out as a junction (e.g. skills/pr-perfect) crashes mempalace init with: OSError: [WinError 448] The path cannot be traversed because it contains an untrusted mount point Fix: wrap every is_dir() call in detect_rooms_from_folders with try/except OSError so the scanner skips inaccessible entries and continues rather than aborting. Covers both the top-level pass and the one-level-deep nested pass. Two new tests mock the OSError on specific paths and verify the function returns correct rooms from the remaining accessible entries.
This commit is contained in:
committed by
GitHub
parent
1056018b52
commit
9c4b7302cc
@@ -9,12 +9,15 @@ Two ways to define rooms without calling any AI:
|
||||
No internet. No API key. Your files stay on your machine.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Common room patterns — detected from folder names and filenames
|
||||
# Format: {folder_keyword: room_name}
|
||||
FOLDER_ROOM_MAP = {
|
||||
@@ -118,7 +121,12 @@ def detect_rooms_from_folders(project_dir: str) -> list:
|
||||
|
||||
# Check top-level directories first (most reliable signal)
|
||||
for item in project_path.iterdir():
|
||||
if item.is_dir() and item.name not in SKIP_DIRS:
|
||||
try:
|
||||
is_dir = item.is_dir() # WinError 448 — reparse point / untrusted mount point
|
||||
except OSError as exc:
|
||||
logger.debug("Skipping %s: %s", item, exc)
|
||||
continue
|
||||
if is_dir and item.name not in SKIP_DIRS:
|
||||
name_lower = item.name.lower().replace("-", "_")
|
||||
if name_lower in FOLDER_ROOM_MAP:
|
||||
room_name = FOLDER_ROOM_MAP[name_lower]
|
||||
@@ -132,9 +140,28 @@ def detect_rooms_from_folders(project_dir: str) -> list:
|
||||
|
||||
# Walk one level deeper for nested patterns
|
||||
for item in project_path.iterdir():
|
||||
if item.is_dir() and item.name not in SKIP_DIRS:
|
||||
for subitem in item.iterdir():
|
||||
if subitem.is_dir() and subitem.name not in SKIP_DIRS:
|
||||
try:
|
||||
item_is_dir = item.is_dir() # WinError 448 — reparse point / untrusted mount point
|
||||
except OSError as exc:
|
||||
logger.debug("Skipping %s: %s", item, exc)
|
||||
continue
|
||||
if item_is_dir and item.name not in SKIP_DIRS:
|
||||
try:
|
||||
subitems = list(
|
||||
item.iterdir()
|
||||
) # WinError 448 — iterdir can also fail on some reparse points
|
||||
except OSError as exc:
|
||||
logger.debug("Skipping contents of %s: %s", item, exc)
|
||||
continue
|
||||
for subitem in subitems:
|
||||
try:
|
||||
subitem_is_dir = (
|
||||
subitem.is_dir()
|
||||
) # WinError 448 — reparse point / untrusted mount point
|
||||
except OSError as exc:
|
||||
logger.debug("Skipping %s: %s", subitem, exc)
|
||||
continue
|
||||
if subitem_is_dir and subitem.name not in SKIP_DIRS:
|
||||
name_lower = subitem.name.lower().replace("-", "_")
|
||||
if name_lower in FOLDER_ROOM_MAP:
|
||||
room_name = FOLDER_ROOM_MAP[name_lower]
|
||||
|
||||
@@ -59,6 +59,82 @@ def test_detect_rooms_from_folders_empty_dir(tmp_path):
|
||||
assert any(r["name"] == "general" for r in rooms)
|
||||
|
||||
|
||||
def test_detect_rooms_from_folders_skips_oserror_at_top_level(tmp_path):
|
||||
"""Windows reparse points (junctions, untrusted mount points) raise OSError
|
||||
when stat()'d. The scanner must skip them and continue — not crash.
|
||||
|
||||
Reproduces WinError 448: "The path cannot be traversed because it contains
|
||||
an untrusted mount point", seen on Windows when a project folder contains
|
||||
a git-submodule junction or a dev-drive reparse point.
|
||||
"""
|
||||
(tmp_path / "frontend").mkdir()
|
||||
bad = tmp_path / "untrusted_junction"
|
||||
bad.mkdir()
|
||||
|
||||
original_is_dir = bad.__class__.is_dir
|
||||
|
||||
def patched_is_dir(self):
|
||||
if self == bad:
|
||||
raise OSError(
|
||||
"[WinError 448] The path cannot be traversed because it contains an untrusted mount point"
|
||||
)
|
||||
return original_is_dir(self)
|
||||
|
||||
with patch.object(bad.__class__, "is_dir", patched_is_dir):
|
||||
rooms = detect_rooms_from_folders(str(tmp_path))
|
||||
|
||||
room_names = {r["name"] for r in rooms}
|
||||
assert "frontend" in room_names
|
||||
|
||||
|
||||
def test_detect_rooms_from_folders_skips_oserror_nested(tmp_path):
|
||||
"""Same WinError 448 guard applies one level deeper (nested iterdir pass)."""
|
||||
skills = tmp_path / "skills"
|
||||
skills.mkdir()
|
||||
(skills / "docs").mkdir()
|
||||
bad = skills / "bad_junction"
|
||||
bad.mkdir()
|
||||
|
||||
original_is_dir = bad.__class__.is_dir
|
||||
|
||||
def patched_is_dir(self):
|
||||
if self == bad:
|
||||
raise OSError(
|
||||
"[WinError 448] The path cannot be traversed because it contains an untrusted mount point"
|
||||
)
|
||||
return original_is_dir(self)
|
||||
|
||||
with patch.object(bad.__class__, "is_dir", patched_is_dir):
|
||||
rooms = detect_rooms_from_folders(str(tmp_path))
|
||||
|
||||
room_names = {r["name"] for r in rooms}
|
||||
assert "documentation" in room_names
|
||||
|
||||
|
||||
def test_detect_rooms_from_folders_skips_iterdir_oserror(tmp_path):
|
||||
"""iterdir() itself can raise OSError on some Windows reparse points even
|
||||
when is_dir() succeeds. The nested pass must guard the iterdir() call too."""
|
||||
outer = tmp_path / "src"
|
||||
outer.mkdir()
|
||||
(tmp_path / "docs").mkdir()
|
||||
|
||||
original_iterdir = outer.__class__.iterdir
|
||||
|
||||
def patched_iterdir(self):
|
||||
if self == outer:
|
||||
raise OSError(
|
||||
"[WinError 448] The path cannot be traversed because it contains an untrusted mount point"
|
||||
)
|
||||
return original_iterdir(self)
|
||||
|
||||
with patch.object(outer.__class__, "iterdir", patched_iterdir):
|
||||
rooms = detect_rooms_from_folders(str(tmp_path))
|
||||
|
||||
room_names = {r["name"] for r in rooms}
|
||||
# docs is accessible; src fails on iterdir — neither should crash
|
||||
assert "documentation" in room_names
|
||||
|
||||
|
||||
def test_detect_rooms_from_folders_skips_git(tmp_path):
|
||||
(tmp_path / ".git").mkdir()
|
||||
(tmp_path / "node_modules").mkdir()
|
||||
|
||||
Reference in New Issue
Block a user