Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 92 additions & 18 deletions src/specify_cli/shared_infra.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
138 changes: 138 additions & 0 deletions tests/test_shared_infra_commands.py
Original file line number Diff line number Diff line change
@@ -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"