@@ -825,25 +825,119 @@ def _remap_in_project_symlinks(project_root: Path, staged_root: Path) -> None:
825825 raise RuntimeError ("staged symlink isolation did not converge" )
826826
827827
828- def _ignore_special_files (directory : str , names : list [str ]) -> set [str ]:
829- """Skip sockets, devices, and other non-file tree entries during staging."""
828+ def _windows_path_is_junction (path : Path ) -> bool :
829+ """Detect a Windows junction using Python 3.11-compatible reparse data."""
830+ try :
831+ path_stat = path .lstat ()
832+ except FileNotFoundError :
833+ return False
834+ except OSError as exc :
835+ raise ValueError (
836+ f"Cannot determine whether preview path is a junction: { path } : { exc } "
837+ ) from exc
838+
839+ reparse_tag = getattr (path_stat , "st_reparse_tag" , None )
840+ if reparse_tag is None :
841+ raise ValueError (
842+ f"Cannot determine whether preview path is a junction: { path } "
843+ )
844+ mount_point_tag = getattr (stat , "IO_REPARSE_TAG_MOUNT_POINT" , 0xA0000003 )
845+ return reparse_tag == mount_point_tag
846+
847+
848+ def _path_is_junction (path : Path ) -> bool :
849+ """Return whether *path* is a Windows directory junction."""
850+ checker = getattr (path , "is_junction" , None )
851+ if callable (checker ):
852+ try :
853+ return checker ()
854+ except OSError as exc :
855+ raise ValueError (
856+ f"Cannot determine whether preview path is a junction: { path } : { exc } "
857+ ) from exc
858+ if os .name == "nt" :
859+ return _windows_path_is_junction (path )
860+ return False
861+
862+
863+ def _preview_path_is_managed (path : Path , copy_root : Path ) -> bool :
864+ """Return whether an unreadable path may affect initialization behavior."""
865+ try :
866+ relative = path .relative_to (copy_root )
867+ except ValueError :
868+ return True
869+ parts = (copy_root .name , * relative .parts )
870+ if copy_root .name == ".specify" and relative .parts :
871+ return relative .parts [0 ] in {
872+ ".gitignore" ,
873+ "extensions" ,
874+ "extensions.yml" ,
875+ "init-options.json" ,
876+ "integration.json" ,
877+ "integrations" ,
878+ "memory" ,
879+ "presets" ,
880+ "scripts" ,
881+ "templates" ,
882+ "workflows" ,
883+ }
884+ return any (part .startswith (("speckit-" , "speckit." )) for part in parts )
885+
886+
887+ def _path_is_readable_for_staging (path : Path ) -> bool :
888+ """Probe whether copytree can read a regular file or enumerate a directory."""
889+ try :
890+ if path .is_file ():
891+ with path .open ("rb" ):
892+ pass
893+ elif path .is_dir ():
894+ with os .scandir (path ):
895+ pass
896+ return True
897+ except OSError :
898+ return False
899+
900+
901+ def _ignore_special_files (
902+ directory : str ,
903+ names : list [str ],
904+ * ,
905+ copy_root : Path | None = None ,
906+ ) -> set [str ]:
907+ """Skip inaccessible unrelated entries and unsupported filesystem nodes."""
830908 ignored : set [str ] = set ()
909+ root = copy_root or Path (directory )
831910 for name in names :
832911 candidate = Path (directory ) / name
833912 try :
913+ if _path_is_junction (candidate ):
914+ raise ValueError (
915+ f"Preview staging refuses Windows directory junction: { candidate } "
916+ )
834917 if (
835918 not candidate .is_symlink ()
836919 and not candidate .is_file ()
837920 and not candidate .is_dir ()
838921 ):
839922 ignored .add (name )
923+ continue
924+ if (
925+ not candidate .is_symlink ()
926+ and not _path_is_readable_for_staging (candidate )
927+ and not _preview_path_is_managed (candidate , root )
928+ ):
929+ ignored .add (name )
840930 except OSError :
841931 ignored .add (name )
842932 return ignored
843933
844934
845935def _copy_staged_path (source : Path , destination : Path ) -> None :
846936 """Copy one selected path without following symlinks."""
937+ if _path_is_junction (source ):
938+ raise ValueError (
939+ f"Preview staging refuses Windows directory junction: { source } "
940+ )
847941 if source .is_symlink ():
848942 if os .path .lexists (destination ):
849943 return
@@ -857,7 +951,9 @@ def _copy_staged_path(source: Path, destination: Path) -> None:
857951 destination ,
858952 symlinks = True ,
859953 dirs_exist_ok = True ,
860- ignore = _ignore_special_files ,
954+ ignore = lambda directory , names : _ignore_special_files (
955+ directory , names , copy_root = source
956+ ),
861957 )
862958 elif source .is_file ():
863959 destination .parent .mkdir (parents = True , exist_ok = True )
@@ -875,6 +971,10 @@ def _copy_selected_staged_path(
875971 current /= part
876972 source = project_path / current
877973 destination = staged_root / current
974+ if _path_is_junction (source ):
975+ raise ValueError (
976+ f"Preview staging refuses Windows directory junction: { source } "
977+ )
878978 if source .is_symlink ():
879979 _copy_staged_path (source , destination )
880980 return
@@ -925,7 +1025,9 @@ def _stage_project_copy(
9251025 project_path ,
9261026 staged_root ,
9271027 symlinks = True ,
928- ignore = _ignore_special_files ,
1028+ ignore = lambda directory , names : _ignore_special_files (
1029+ directory , names , copy_root = project_path
1030+ ),
9291031 )
9301032 else :
9311033 staged_root .mkdir (parents = True , exist_ok = True )
@@ -1069,21 +1171,26 @@ def _preview_init(
10691171 staged_root = Path (tmp_dir ) / "project"
10701172 staged_home = Path (tmp_dir ) / "home"
10711173 staged_home .mkdir ()
1072- _seed_preview_home (staged_home , real_home )
1073- if home_seed_paths :
1074- _stage_project_copy (real_home , staged_home , home_seed_paths )
1075- if project_path .exists ():
1076- _stage_project_copy (
1077- project_path ,
1078- staged_root ,
1079- _preview_seed_paths (
1080- selected_integration ,
1081- integration_options ,
1082- script_type ,
1083- ),
1084- )
1085- else :
1086- staged_root .mkdir ()
1174+ try :
1175+ _seed_preview_home (staged_home , real_home )
1176+ if home_seed_paths :
1177+ _stage_project_copy (real_home , staged_home , home_seed_paths )
1178+ if project_path .exists ():
1179+ _stage_project_copy (
1180+ project_path ,
1181+ staged_root ,
1182+ _preview_seed_paths (
1183+ selected_integration ,
1184+ integration_options ,
1185+ script_type ,
1186+ ),
1187+ )
1188+ else :
1189+ staged_root .mkdir ()
1190+ except (OSError , RuntimeError , ValueError , shutil .Error ) as exc :
1191+ payload ["error" ] = f"failed to stage preview inputs: { exc } "
1192+ _emit_dry_run_preview (payload , json_output = json_output )
1193+ raise typer .Exit (1 ) from None
10871194
10881195 initial_project_files = _snapshot_files (staged_root )
10891196 initial_home_files = _snapshot_files (staged_home )
0 commit comments