diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py index 3aff73ae49..8ab812f1d1 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -145,6 +145,28 @@ def shared_templates_source( return repo_root / "templates" +def shared_commands_source( + *, + core_pack: Path | None, + repo_root: Path, +) -> Path: + """Return the bundled/source core command templates directory. + + Wheel installs force-include ``templates/commands`` as + ``core_pack/commands`` (not nested under ``core_pack/templates``), so + ``shared_templates_source()`` cannot see that tree. Source checkouts + keep the files at ``repo_root/templates/commands``. + """ + if core_pack: + wheel_commands = core_pack / "commands" + if wheel_commands.is_dir(): + return wheel_commands + nested = core_pack / "templates" / "commands" + if nested.is_dir(): + return nested + return repo_root / "templates" / "commands" + + def shared_scripts_source( *, core_pack: Path | None, @@ -354,7 +376,8 @@ def refresh_shared_templates( ) -> None: """Refresh default-sensitive shared templates without touching scripts.""" templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root) - if not templates_src.is_dir(): + commands_src = shared_commands_source(core_pack=core_pack, repo_root=repo_root) + if not templates_src.is_dir() and not commands_src.is_dir(): return manifest = load_speckit_manifest(project_path, version=version, console=console) @@ -365,26 +388,38 @@ def refresh_shared_templates( dest_templates = project_path / ".specify" / "templates" _ensure_safe_shared_directory(project_path, dest_templates) - for src in templates_src.iterdir(): - if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."): - continue - dst = dest_templates / src.name - _ensure_safe_shared_destination(project_path, dst) - rel = dst.relative_to(project_path).as_posix() - if dst.exists() and not force: - if rel not in tracked_files or rel in modified or manifest.is_recovered(rel): - # Never overwrite a recovered (pre-existing user) file without - # --force, matching install_shared_infra's is_recovered gate - # (#2918). Without this, refresh clobbers user content. - skipped_files.append(rel) + def _plan_refresh_markdown(src_dir: Path, dest_dir: Path, *, skip_names: set[str]) -> None: + if not src_dir.is_dir(): + return + _ensure_safe_shared_directory(project_path, dest_dir) + for src in src_dir.iterdir(): + if not src.is_file() or src.name in skip_names or src.name.startswith("."): continue - content = src.read_text(encoding="utf-8") - content = IntegrationBase.resolve_command_refs( - content, invoke_separator, invoke_prefix - ) - planned_updates.append((dst, rel, content)) + dst = dest_dir / src.name + _ensure_safe_shared_destination(project_path, dst) + rel = dst.relative_to(project_path).as_posix() + if dst.exists() and not force: + if rel not in tracked_files or rel in modified or manifest.is_recovered(rel): + # Never overwrite a recovered (pre-existing user) file without + # --force, matching install_shared_infra's is_recovered gate + # (#2918). Without this, refresh clobbers user content. + skipped_files.append(rel) + continue + + content = src.read_text(encoding="utf-8") + content = IntegrationBase.resolve_command_refs( + content, invoke_separator, invoke_prefix + ) + planned_updates.append((dst, rel, content)) + + _plan_refresh_markdown( + templates_src, dest_templates, skip_names={"vscode-settings.json"} + ) + _plan_refresh_markdown( + commands_src, dest_templates / "commands", skip_names=set() + ) for dst, rel, content in planned_updates: _write_shared_text(project_path, dst, content) @@ -623,6 +658,45 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: ) planned_templates.append((dst, rel, content)) + # Core command templates live in ``templates/commands/`` (source) or + # ``core_pack/commands`` (wheel). The loop above only copies top-level + # files, so wrap composition had no base layer after ``specify init`` + # (#3086). Copy them into ``.specify/templates/commands/`` with the + # same overwrite / manifest policy as the other shared templates. + commands_src = shared_commands_source(core_pack=core_pack, repo_root=repo_root) + if commands_src.is_dir(): + dest_commands = project_path / ".specify" / "templates" / "commands" + if _ensure_or_bucket_dir(dest_commands): + for src in commands_src.iterdir(): + if not src.is_file() or src.name.startswith("."): + continue + + dst = dest_commands / src.name + rel = dst.relative_to(project_path).as_posix() + seen_rels.add(rel) + if not _safe_dest_or_bucket(dst, rel): + continue + write, bucket = _decide_overwrite(rel, dst) + if not write: + if bucket == "preserved": + preserved_user_files.append(rel) + else: + skipped_files.append(rel) + if dst.is_file() and rel not in prior_hashes: + try: + manifest.record_existing(rel, recovered=True) + except (OSError, ValueError) as exc: + console.print( + f"[yellow]⚠[/yellow] could not record {rel} in manifest: {exc}" + ) + continue + + content = src.read_text(encoding="utf-8") + content = IntegrationBase.resolve_command_refs( + content, invoke_separator, invoke_prefix + ) + planned_templates.append((dst, rel, content)) + # Managed ``.specify/.gitignore`` — keeps machine-local state (the # ``feature.json`` pointer and per-machine ``local-config.yml`` overrides) # out of git while leaving everything else shareable. Routed through the diff --git a/tests/test_shared_infra_commands.py b/tests/test_shared_infra_commands.py new file mode 100644 index 0000000000..b4f779268e --- /dev/null +++ b/tests/test_shared_infra_commands.py @@ -0,0 +1,138 @@ +"""specify init must copy core command templates (#3086). + +``install_shared_infra`` used to iterate only top-level files under +``templates/``, so ``templates/commands/`` never landed in +``.specify/templates/commands/``. Wrap presets then had no core base layer. +""" + +from __future__ import annotations + +from pathlib import Path + +from rich.console import Console + +from specify_cli.shared_infra import ( + install_shared_infra, + refresh_shared_templates, + shared_commands_source, +) + + +def _console() -> Console: + return Console(quiet=True) + + +def test_shared_commands_source_prefers_wheel_core_pack(tmp_path: Path) -> None: + core_pack = tmp_path / "core_pack" + (core_pack / "commands").mkdir(parents=True) + (core_pack / "commands" / "implement.md").write_text("# impl\n", encoding="utf-8") + repo_root = tmp_path / "repo" + (repo_root / "templates" / "commands").mkdir(parents=True) + + source = shared_commands_source(core_pack=core_pack, repo_root=repo_root) + assert source == core_pack / "commands" + + +def test_shared_commands_source_falls_back_to_repo_templates(tmp_path: Path) -> None: + repo_root = tmp_path / "repo" + commands = repo_root / "templates" / "commands" + commands.mkdir(parents=True) + (commands / "specify.md").write_text("# spec\n", encoding="utf-8") + + source = shared_commands_source(core_pack=None, repo_root=repo_root) + assert source == commands + + +def test_install_shared_infra_copies_command_templates(tmp_path: Path) -> None: + repo_root = tmp_path / "repo" + commands_src = repo_root / "templates" / "commands" + commands_src.mkdir(parents=True) + (commands_src / "implement.md").write_text("# implement\n", encoding="utf-8") + (commands_src / "specify.md").write_text("# specify\n", encoding="utf-8") + (repo_root / "templates").mkdir(parents=True, exist_ok=True) + (repo_root / "templates" / "plan-template.md").write_text("# plan\n", encoding="utf-8") + scripts = repo_root / "scripts" / "bash" + scripts.mkdir(parents=True) + (scripts / "check-prerequisites.sh").write_text("#!/bin/sh\n", encoding="utf-8") + + project = tmp_path / "proj" + project.mkdir() + install_shared_infra( + project, + "sh", + version="test", + core_pack=None, + repo_root=repo_root, + console=_console(), + force=True, + ) + + dest = project / ".specify" / "templates" / "commands" + assert (dest / "implement.md").is_file() + assert (dest / "specify.md").is_file() + assert (dest / "implement.md").read_text(encoding="utf-8") == "# implement\n" + + +def test_install_shared_infra_copies_wheel_core_pack_commands(tmp_path: Path) -> None: + core_pack = tmp_path / "core_pack" + (core_pack / "commands").mkdir(parents=True) + (core_pack / "commands" / "plan.md").write_text("# plan cmd\n", encoding="utf-8") + (core_pack / "templates").mkdir(parents=True) + (core_pack / "templates" / "spec-template.md").write_text("# spec tmpl\n", encoding="utf-8") + (core_pack / "scripts" / "bash").mkdir(parents=True) + (core_pack / "scripts" / "bash" / "check-prerequisites.sh").write_text( + "#!/bin/sh\n", encoding="utf-8" + ) + + project = tmp_path / "proj" + project.mkdir() + install_shared_infra( + project, + "sh", + version="test", + core_pack=core_pack, + repo_root=tmp_path / "unused", + console=_console(), + force=True, + ) + + dest = project / ".specify" / "templates" / "commands" / "plan.md" + assert dest.is_file() + assert dest.read_text(encoding="utf-8") == "# plan cmd\n" + + +def test_refresh_shared_templates_updates_commands(tmp_path: Path) -> None: + repo_root = tmp_path / "repo" + commands_src = repo_root / "templates" / "commands" + commands_src.mkdir(parents=True) + (commands_src / "clarify.md").write_text("# old\n", encoding="utf-8") + (repo_root / "templates" / "plan-template.md").write_text("# plan\n", encoding="utf-8") + scripts = repo_root / "scripts" / "bash" + scripts.mkdir(parents=True) + (scripts / "check-prerequisites.sh").write_text("#!/bin/sh\n", encoding="utf-8") + + project = tmp_path / "proj" + project.mkdir() + install_shared_infra( + project, + "sh", + version="test", + core_pack=None, + repo_root=repo_root, + console=_console(), + force=True, + ) + (commands_src / "clarify.md").write_text("# new\n", encoding="utf-8") + + refresh_shared_templates( + project, + version="test", + core_pack=None, + repo_root=repo_root, + console=_console(), + invoke_separator=".", + force=True, + ) + + dest = project / ".specify" / "templates" / "commands" / "clarify.md" + assert dest.read_text(encoding="utf-8") == "# new\n"