Skip to content

Commit 09b122c

Browse files
committed
fix(init): align dry-run preview with init behavior
- Preserve here and force semantics in staged previews - Surface core failures and URL resolution limits in preview output
1 parent 3e4cb86 commit 09b122c

3 files changed

Lines changed: 70 additions & 23 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,10 +214,12 @@ def _install_shared_infra_or_exit(
214214
raise typer.Exit(1)
215215

216216

217-
def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None:
217+
def ensure_executable_scripts(
218+
project_path: Path, tracker: StepTracker | None = None
219+
) -> list[str]:
218220
"""Ensure POSIX .sh scripts under .specify/scripts and .specify/extensions (recursively) have execute bits (no-op on Windows)."""
219221
if os.name == "nt":
220-
return # Windows: skip silently
222+
return [] # Windows: skip silently
221223
scan_roots = [
222224
project_path / ".specify" / "scripts",
223225
project_path / ".specify" / "extensions",
@@ -265,6 +267,7 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None =
265267
console.print("[yellow]Some scripts could not be updated:[/yellow]")
266268
for f in failures:
267269
console.print(f" - {f}")
270+
return failures
268271

269272
# ---------------------------------------------------------------------------
270273
# Skills directory helpers

src/specify_cli/commands/init.py

Lines changed: 63 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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

652664
def _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]) ->
680703
def _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

10341065
def 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:

tests/test_init_dry_run.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ def test_non_forced_dry_run_human_preview_lists_conflicting_artifacts(
336336
lines = result.output.splitlines()
337337
assert any(line.startswith("conflict") and line.endswith("target directory exists; applying this plan requires --force") for line in lines)
338338
assert any(
339-
line.startswith("conflict .github/skills/speckit-plan/SKILL.md")
339+
line.startswith("overwrite .github/skills/speckit-plan/SKILL.md")
340340
for line in lines
341341
)
342342
assert any(
@@ -432,6 +432,7 @@ def test_dry_run_leaves_url_extension_unresolved_without_creating_target(
432432
"path": extension_url,
433433
"provenance": "extension",
434434
"source_id": extension_url,
435+
"reason": "URL extensions are not fetched during dry-run",
435436
}
436437
assert not target.exists()
437438

0 commit comments

Comments
 (0)