@@ -90,9 +90,9 @@ def _snapshot_matching_files(root: Path, relative_paths: set[str]) -> dict[str,
9090 return files
9191
9292
93- def _resolve_preview_child_extension (spec : str ) -> str :
94- """Expand home -relative extension specs against the parent home ."""
95- if spec .startswith ("~" ):
93+ def _resolve_preview_child_path (spec : str ) -> str :
94+ """Resolve caller -relative local specs before changing the child cwd ."""
95+ if spec .startswith (( "~" , "./" , "../" , "/" , ". \\ " , ".. \\ " ) ):
9696 return str (Path (spec ).expanduser ().resolve ())
9797 return spec
9898
@@ -125,6 +125,16 @@ def _preview_subprocess_env(staged_home: Path) -> dict[str, str]:
125125 return env
126126
127127
128+ def _seed_preview_home (staged_home : Path , real_home : Path ) -> None :
129+ """Copy the read-only catalog settings the initializer resolves from HOME."""
130+ for filename in ("extension-catalogs.yml" , "preset-catalogs.yml" ):
131+ source = real_home / ".specify" / filename
132+ if source .is_file () and not source .is_symlink ():
133+ destination = staged_home / ".specify" / filename
134+ destination .parent .mkdir (parents = True , exist_ok = True )
135+ shutil .copy2 (source , destination )
136+
137+
128138_INIT_PLAN_ENV = "SPECIFY_INIT_PLAN_PATH"
129139_INIT_STAGING_CONFIRMATION_ENV = "SPECIFY_INIT_STAGING_CONFIRMATION"
130140
@@ -497,10 +507,12 @@ def _emit_dry_run_preview(payload: dict[str, Any], *, json_output: bool) -> None
497507 return
498508
499509 console .print ("\n [bold cyan]Initialization preview[/bold cyan]" )
500- if payload [ "conflict" ] :
510+ if payload . get ( "gate" ) == "force_required" :
501511 console .print (
502512 "[yellow]conflict[/yellow] target directory exists; applying this plan requires --force"
503513 )
514+ elif payload .get ("gate" ) == "confirmation_required" :
515+ console .print ("[yellow]confirmation required[/yellow] target directory is not empty" )
504516 if payload .get ("error" ):
505517 console .print (f"[red]failed[/red] { payload ['error' ]} " )
506518 for failure in payload ["failures" ]:
@@ -651,7 +663,18 @@ def _remap_in_project_symlinks(project_root: Path, staged_root: Path) -> None:
651663
652664def _stage_project_copy (project_path : Path , staged_root : Path ) -> None :
653665 """Copy *project_path* into staging and isolate live symlinks."""
654- shutil .copytree (project_path , staged_root , symlinks = True )
666+ def ignore_special_files (directory : str , names : list [str ]) -> set [str ]:
667+ ignored : set [str ] = set ()
668+ for name in names :
669+ candidate = Path (directory ) / name
670+ try :
671+ if not candidate .is_symlink () and not candidate .is_file () and not candidate .is_dir ():
672+ ignored .add (name )
673+ except OSError :
674+ ignored .add (name )
675+ return ignored
676+
677+ shutil .copytree (project_path , staged_root , symlinks = True , ignore = ignore_special_files )
655678 _remap_in_project_symlinks (project_path .resolve (), staged_root .resolve ())
656679
657680
@@ -680,8 +703,9 @@ def _preview_child_failure_message(result: subprocess.CompletedProcess[str]) ->
680703def _preview_init (
681704 * ,
682705 project_path : Path ,
683- directory_conflict : bool ,
706+ gate : str ,
684707 force : bool ,
708+ here : bool ,
685709 script_type : str ,
686710 selected_integration : str ,
687711 ignore_agent_tools : bool ,
@@ -695,7 +719,8 @@ def _preview_init(
695719 payload : dict [str , Any ] = {
696720 "dry_run" : True ,
697721 "target" : str (project_path ),
698- "conflict" : directory_conflict ,
722+ "conflict" : gate != "none" ,
723+ "gate" : gate ,
699724 "actions" : [],
700725 "failures" : [],
701726 }
@@ -709,6 +734,7 @@ def _preview_init(
709734 staged_root = Path (tmp_dir ) / "project"
710735 staged_home = Path (tmp_dir ) / "home"
711736 staged_home .mkdir ()
737+ _seed_preview_home (staged_home , real_home )
712738 if project_path .exists ():
713739 _stage_project_copy (project_path , staged_root )
714740
@@ -721,25 +747,25 @@ def _preview_init(
721747 "-c" ,
722748 "from specify_cli import main; main()" ,
723749 "init" ,
724- str (staged_root ),
725750 "--non-interactive" ,
726751 "--integration" ,
727752 selected_integration ,
728753 "--script" ,
729754 script_type ,
730755 ]
731- if force :
756+ if here :
757+ command .append ("--here" )
758+ else :
759+ command .append (str (staged_root ))
760+ if force or gate == "force_required" :
732761 command .append ("--force" )
733- if ignore_agent_tools :
734- command .append ("--ignore-agent-tools" )
762+ command .append ("--ignore-agent-tools" )
735763 if integration_options :
736764 command .extend (["--integration-options" , integration_options ])
737765 if preset :
738- command .extend (["--preset" , preset ])
766+ command .extend (["--preset" , _resolve_preview_child_path ( preset ) ])
739767 for extension in staged_extensions :
740- command .extend (
741- ["--extension" , _resolve_preview_child_extension (extension )]
742- )
768+ command .extend (["--extension" , _resolve_preview_child_path (extension )])
743769 if trust_extension_urls :
744770 command .append ("--trust-extension-urls" )
745771
@@ -749,7 +775,7 @@ def _preview_init(
749775 env [_INIT_STAGING_CONFIRMATION_ENV ] = "1"
750776 result = subprocess .run (
751777 command ,
752- cwd = Path .cwd (),
778+ cwd = staged_root if here else Path .cwd (),
753779 capture_output = True ,
754780 text = True ,
755781 encoding = "utf-8" ,
@@ -774,7 +800,7 @@ def _preview_init(
774800 staged_root ,
775801 ownership = project_ownership ,
776802 default_ownership = ("integration" , selected_integration ),
777- directory_conflict = directory_conflict ,
803+ directory_conflict = False ,
778804 )
779805 staged_home_files = _snapshot_files (staged_home )
780806 initial_home_files = _snapshot_matching_files (
@@ -791,7 +817,7 @@ def _preview_init(
791817 path_prefix = "~/" ,
792818 ownership = home_ownership ,
793819 default_ownership = ("integration" , selected_integration ),
794- directory_conflict = directory_conflict ,
820+ directory_conflict = False ,
795821 )
796822 )
797823 payload ["actions" ] = _merge_recorded_plan_actions (
@@ -807,6 +833,7 @@ def _preview_init(
807833 "path" : spec ,
808834 "provenance" : "extension" ,
809835 "source_id" : spec ,
836+ "reason" : "URL extensions are not fetched during dry-run" ,
810837 }
811838 )
812839 payload ["actions" ].sort (key = lambda action : action ["path" ])
@@ -1012,6 +1039,9 @@ def ensure_constitution_from_template(
10121039 if tracker :
10131040 tracker .add ("constitution" , "Constitution setup" )
10141041 tracker .error ("constitution" , "template not found" )
1042+ _record_init_plan_failure (
1043+ "constitution" , "constitution" , "template not found"
1044+ )
10151045 return
10161046 if tracker :
10171047 tracker .add ("constitution" , "Constitution setup" )
@@ -1029,6 +1059,7 @@ def ensure_constitution_from_template(
10291059 console .print (
10301060 f"[yellow]Warning: Could not initialize constitution: { e } [/yellow]"
10311061 )
1062+ _record_init_plan_failure ("constitution" , "constitution" , str (e ))
10321063
10331064
10341065def register (app : typer .Typer ) -> None :
@@ -1420,10 +1451,18 @@ def init(
14201451 console .print (f"[cyan]Selected script type:[/cyan] { selected_script } " )
14211452
14221453 if dry_run :
1454+ gate = (
1455+ "confirmation_required"
1456+ if here and directory_conflict
1457+ else "force_required"
1458+ if directory_conflict
1459+ else "none"
1460+ )
14231461 _preview_init (
14241462 project_path = project_path ,
1425- directory_conflict = directory_conflict ,
1463+ gate = gate ,
14261464 force = force ,
1465+ here = here ,
14271466 script_type = selected_script ,
14281467 selected_integration = selected_ai ,
14291468 ignore_agent_tools = ignore_agent_tools ,
@@ -1627,6 +1666,7 @@ def init(
16271666 tracker .skip ("workflow" , "bundled workflow not found" )
16281667 except Exception as wf_err :
16291668 sanitized_wf = str (wf_err ).replace ("\n " , " " ).strip ()
1669+ _record_init_plan_failure ("workflow" , "speckit" , sanitized_wf )
16301670 tracker .error ("workflow" , f"install failed: { sanitized_wf [:120 ]} " )
16311671
16321672 init_opts = {
@@ -1643,7 +1683,10 @@ def init(
16431683 init_opts ["ai_skills" ] = True
16441684 save_init_options (project_path , init_opts )
16451685
1646- ensure_executable_scripts (project_path , tracker = tracker )
1686+ for chmod_failure in ensure_executable_scripts (
1687+ project_path , tracker = tracker
1688+ ):
1689+ _record_init_plan_failure ("chmod" , chmod_failure , chmod_failure )
16471690
16481691 if preset :
16491692 try :
0 commit comments