diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py index 0685b6c9b..581abfb51 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -328,7 +328,10 @@ def refresh_shared_templates( _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: + 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 @@ -344,7 +347,7 @@ def refresh_shared_templates( if skipped_files: console.print( - f"[yellow]⚠[/yellow] {len(skipped_files)} modified or untracked shared template file(s) were not updated:" + f"[yellow]⚠[/yellow] {len(skipped_files)} modified, untracked, or preserved (recovered) shared template file(s) were not updated:" ) for rel in skipped_files: console.print(f" {rel}") diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index ed978cbb5..cb2d136e6 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -2073,3 +2073,44 @@ class TestIntegrationCatalogDiscoveryCLI: assert listing.exit_code == 0, listing.output assert "default" in listing.output assert "community" in listing.output + + +def test_refresh_shared_templates_preserves_recovered_user_file(tmp_path): + """refresh_shared_templates must not overwrite a recovered (pre-existing + user) template without --force, matching install_shared_infra's gate (#2918). + """ + from specify_cli.shared_infra import ( + load_speckit_manifest, + refresh_shared_templates, + ) + + project = tmp_path / "proj" + templates_dir = project / ".specify" / "templates" + templates_dir.mkdir(parents=True) + user_file = templates_dir / "spec-template.md" + user_file.write_text("# USER CUSTOM CONTENT\n", encoding="utf-8") + + # Record the pre-existing file as recovered (its hash was adopted, not written). + manifest = load_speckit_manifest(project, version="test", console=_NoopConsole()) + rel = ".specify/templates/spec-template.md" + manifest.record_existing(rel, recovered=True) + manifest.save() + + # Bundled source ships a different body for the same template. + core_pack = tmp_path / "core-pack" + src = core_pack / "templates" + src.mkdir(parents=True) + (src / "spec-template.md").write_text("# BUNDLED CONTENT v2\n", encoding="utf-8") + + refresh_shared_templates( + project, + version="test", + core_pack=core_pack, + repo_root=tmp_path / "unused", + console=_NoopConsole(), + invoke_separator=".", + force=False, + ) + + # Recovered user content must survive (fail-before: replaced by bundled body). + assert user_file.read_text(encoding="utf-8") == "# USER CUSTOM CONTENT\n"