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.
|
No internet. No API key. Your files stay on your machine.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import yaml
|
import yaml
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Common room patterns — detected from folder names and filenames
|
# Common room patterns — detected from folder names and filenames
|
||||||
# Format: {folder_keyword: room_name}
|
# Format: {folder_keyword: room_name}
|
||||||
FOLDER_ROOM_MAP = {
|
FOLDER_ROOM_MAP = {
|
||||||
@@ -118,7 +121,12 @@ def detect_rooms_from_folders(project_dir: str) -> list:
|
|||||||
|
|
||||||
# Check top-level directories first (most reliable signal)
|
# Check top-level directories first (most reliable signal)
|
||||||
for item in project_path.iterdir():
|
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("-", "_")
|
name_lower = item.name.lower().replace("-", "_")
|
||||||
if name_lower in FOLDER_ROOM_MAP:
|
if name_lower in FOLDER_ROOM_MAP:
|
||||||
room_name = FOLDER_ROOM_MAP[name_lower]
|
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
|
# Walk one level deeper for nested patterns
|
||||||
for item in project_path.iterdir():
|
for item in project_path.iterdir():
|
||||||
if item.is_dir() and item.name not in SKIP_DIRS:
|
try:
|
||||||
for subitem in item.iterdir():
|
item_is_dir = item.is_dir() # WinError 448 — reparse point / untrusted mount point
|
||||||
if subitem.is_dir() and subitem.name not in SKIP_DIRS:
|
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("-", "_")
|
name_lower = subitem.name.lower().replace("-", "_")
|
||||||
if name_lower in FOLDER_ROOM_MAP:
|
if name_lower in FOLDER_ROOM_MAP:
|
||||||
room_name = FOLDER_ROOM_MAP[name_lower]
|
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)
|
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):
|
def test_detect_rooms_from_folders_skips_git(tmp_path):
|
||||||
(tmp_path / ".git").mkdir()
|
(tmp_path / ".git").mkdir()
|
||||||
(tmp_path / "node_modules").mkdir()
|
(tmp_path / "node_modules").mkdir()
|
||||||
|
|||||||
Reference in New Issue
Block a user