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:
Ahmad Othman Ammar Adi.
2026-04-12 01:16:06 +02:00
committed by GitHub
parent 1056018b52
commit 9c4b7302cc
2 changed files with 107 additions and 4 deletions
+31 -4
View File
@@ -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]
+76
View File
@@ -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()