Skip to content
Open
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
7 changes: 7 additions & 0 deletions extensions/git/extension.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
19 changes: 18 additions & 1 deletion src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1277,6 +1277,8 @@ def init(

ensure_constitution_from_template(project_path, tracker=tracker)

_git_ext_freshly_installed = False
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this live in extensions.py somehow? I want extensions to be handled by just one point in the code. If not possible, please explain why.

_git_ext_install_notice: str | None = None
if not no_git:
tracker.start("git")
git_messages = []
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
127 changes: 127 additions & 0 deletions tests/extensions/git/test_git_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading