diff --git a/extensions/git/extension.yml b/extensions/git/extension.yml index 13c1977ea..6edb65ddc 100644 --- a/extensions/git/extension.yml +++ b/extensions/git/extension.yml @@ -8,6 +8,13 @@ extension: author: spec-kit-core repository: https://github.com/github/spec-kit license: MIT + install_notice: | + The git extension is currently enabled by default, but starting with + specify-cli v1.0.0 it will require explicit opt-in. + + To opt in after specify-cli v1.0.0: + • specify init --extension git + • specify extension add git (post-init) requires: speckit_version: ">=0.2.0" diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 8c6fd02b9..f771a8078 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1277,6 +1277,8 @@ def init( ensure_constitution_from_template(project_path, tracker=tracker) + _git_ext_freshly_installed = False + _git_ext_install_notice: str | None = None if not no_git: tracker.start("git") git_messages = [] @@ -1307,10 +1309,12 @@ def init( if manager.registry.is_installed("git"): git_messages.append("extension already installed") else: - manager.install_from_directory( + ext_manifest = manager.install_from_directory( bundled_path, get_speckit_version() ) git_messages.append("extension installed") + _git_ext_freshly_installed = True + _git_ext_install_notice = ext_manifest.install_notice else: git_has_error = True git_messages.append("bundled extension not found") @@ -1454,6 +1458,19 @@ def init( console.print(tracker.render()) console.print("\n[bold green]Project ready.[/bold green]") + if _git_ext_freshly_installed and isinstance(_git_ext_install_notice, str): + _git_ext_notice_text = _git_ext_install_notice.strip() + if _git_ext_notice_text: + console.print() + console.print( + Panel( + _git_ext_notice_text, + title="[yellow]⚠ Deprecation notice: git extension[/yellow]", + border_style="yellow", + padding=(1, 2), + ) + ) + # Agent folder security notice agent_config = AGENT_CONFIG.get(selected_ai) if agent_config: diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index d5543cd0b..dfc631a53 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -346,6 +346,20 @@ def hooks(self) -> Dict[str, Any]: """Get hook definitions.""" return self.data.get("hooks", {}) + @property + def install_notice(self) -> str | None: + """Get optional install notice message. + + Extensions can specify an 'install_notice' field to display + important information to users when the extension is first installed. + """ + notice = self.data.get("extension", {}).get("install_notice") + if notice is None: + return None + if isinstance(notice, str): + return notice + return str(notice) + def get_hash(self) -> str: """Calculate SHA256 hash of manifest file.""" with open(self.path, 'rb') as f: diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index c4f986d17..ec15f54f6 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -837,3 +837,130 @@ def test_test_feature_branch_accepts_single_prefix(self, tmp_path: Path): text=True, ) assert result.returncode == 0 + + +# ── Deprecation Notice Tests ────────────────────────────────────────────────── + + +class TestGitExtDeprecationNotice: + """Tests for the v1.0.0 deprecation notice shown during specify init.""" + + def test_deprecation_notice_shown_on_fresh_install(self, tmp_path: Path): + """specify init shows the git extension deprecation notice on first install.""" + from typer.testing import CliRunner + from unittest.mock import patch, MagicMock + from specify_cli import app + from tests.conftest import strip_ansi + + project_dir = tmp_path / "test-project" + runner = CliRunner() + + mock_manifest = MagicMock() + mock_manifest.install_notice = ( + "The git extension is currently enabled by default, but starting with\n" + "specify-cli v1.0.0 it will require explicit opt-in.\n\n" + "To opt in after specify-cli v1.0.0:\n" + " • specify init --extension git\n" + " • specify extension add git (post-init)" + ) + + mock_registry = MagicMock() + mock_registry.is_installed.return_value = False + + mock_manager = MagicMock() + mock_manager.registry = mock_registry + mock_manager.install_from_directory.return_value = mock_manifest + + # Patch _locate_bundled_extension to ensure deterministic behavior + with patch("specify_cli.extensions.ExtensionManager", return_value=mock_manager), \ + patch("specify_cli._locate_bundled_extension", return_value=tmp_path): + result = runner.invoke( + app, + ["init", str(project_dir), "--ai", "claude", "--ignore-agent-tools", "--script", "sh"], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) + assert "Deprecation notice: git extension" in plain + assert "v1.0.0" in plain + assert "specify extension add git" in plain + + def test_deprecation_notice_not_shown_when_already_installed(self, tmp_path: Path): + """specify init does NOT show the deprecation notice when git extension is already installed.""" + from typer.testing import CliRunner + from unittest.mock import patch, MagicMock + from specify_cli import app + from tests.conftest import strip_ansi + + project_dir = tmp_path / "test-project" + runner = CliRunner() + + mock_registry = MagicMock() + mock_registry.is_installed.return_value = True + + mock_manager = MagicMock() + mock_manager.registry = mock_registry + + with patch("specify_cli.extensions.ExtensionManager", return_value=mock_manager), \ + patch("specify_cli._locate_bundled_extension", return_value=tmp_path): + result = runner.invoke( + app, + ["init", str(project_dir), "--ai", "claude", "--ignore-agent-tools", "--script", "sh"], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) + assert "Deprecation notice: git extension" not in plain + + def test_deprecation_notice_not_shown_with_no_git_flag(self, tmp_path: Path): + """specify init does NOT show the deprecation notice when --no-git is passed.""" + from typer.testing import CliRunner + from specify_cli import app + from tests.conftest import strip_ansi + + project_dir = tmp_path / "test-project" + runner = CliRunner() + + result = runner.invoke( + app, + ["init", str(project_dir), "--ai", "claude", "--ignore-agent-tools", "--no-git", "--script", "sh"], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) + assert "Deprecation notice: git extension" not in plain + + def test_deprecation_notice_not_shown_when_no_install_notice(self, tmp_path: Path): + """specify init does NOT show the deprecation notice if extension has no install_notice.""" + from typer.testing import CliRunner + from unittest.mock import patch, MagicMock + from specify_cli import app + from tests.conftest import strip_ansi + + project_dir = tmp_path / "test-project" + runner = CliRunner() + + mock_manifest = MagicMock() + mock_manifest.install_notice = None # No notice defined + + mock_registry = MagicMock() + mock_registry.is_installed.return_value = False + + mock_manager = MagicMock() + mock_manager.registry = mock_registry + mock_manager.install_from_directory.return_value = mock_manifest + + with patch("specify_cli.extensions.ExtensionManager", return_value=mock_manager), \ + patch("specify_cli._locate_bundled_extension", return_value=tmp_path): + result = runner.invoke( + app, + ["init", str(project_dir), "--ai", "claude", "--ignore-agent-tools", "--script", "sh"], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) + assert "Deprecation notice: git extension" not in plain