From 1c7350d02b96b82c74542da6095952f0a906f896 Mon Sep 17 00:00:00 2001 From: Aaron Sun Date: Sun, 12 Apr 2026 15:26:07 -0700 Subject: [PATCH 1/6] Added warning message about git extension no longer being enabled by default --- src/specify_cli/__init__.py | 17 +++++ tests/extensions/git/test_git_extension.py | 76 ++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 0bbf42ad5a..e360812c40 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1211,6 +1211,7 @@ def init( ensure_constitution_from_template(project_path, tracker=tracker) + _git_ext_freshly_installed = False if not no_git: tracker.start("git") git_messages = [] @@ -1245,6 +1246,7 @@ def init( bundled_path, get_speckit_version() ) git_messages.append("extension installed") + _git_ext_freshly_installed = True else: git_has_error = True git_messages.append("bundled extension not found") @@ -1356,6 +1358,21 @@ def init( console.print(tracker.render()) console.print("\n[bold green]Project ready.[/bold green]") + if _git_ext_freshly_installed: + console.print() + console.print( + Panel( + "The [bold]git[/bold] extension is currently enabled by default, " + "but starting with [bold]v1.0.0[/bold] it will require explicit opt-in.\n\n" + "To opt in after v1.0.0:\n" + " • [cyan]specify init --extension git[/cyan]\n" + " • [cyan]specify extension add git[/cyan] (post-init)", + title="[yellow]⚠ Upcoming Change: 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/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 098caf53b7..f82b17fb06 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -587,3 +587,79 @@ def test_check_feature_branch_rejects_malformed_timestamp(self, tmp_path: Path): capture_output=True, 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 + + project_dir = tmp_path / "test-project" + runner = CliRunner() + + mock_registry = MagicMock() + mock_registry.is_installed.return_value = False + + mock_manager = MagicMock() + mock_manager.registry = mock_registry + + with patch("specify_cli.extensions.ExtensionManager", return_value=mock_manager): + 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 + assert "Upcoming Change: git Extension" in result.output + assert "v1.0.0" in result.output + assert "specify init --extension git" in result.output + + 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 + + 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): + 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 + assert "Upcoming Change: git Extension" not in result.output + + 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 + + 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 + assert "Upcoming Change: git Extension" not in result.output From 68edf49027f3f2cbf232e973d0bbe18f296d84d0 Mon Sep 17 00:00:00 2001 From: Aaron Sun Date: Sat, 18 Apr 2026 11:48:57 -0700 Subject: [PATCH 2/6] Display deprication warning during git extension setup --- extensions/git/extension.yml | 7 ++++ src/specify_cli/__init__.py | 14 +++---- src/specify_cli/extensions.py | 9 ++++ tests/extensions/git/test_git_extension.py | 49 +++++++++++++++++++--- 4 files changed, 66 insertions(+), 13 deletions(-) diff --git a/extensions/git/extension.yml b/extensions/git/extension.yml index 13c1977ea1..67cb9ab49d 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 + v1.0.0 it will require explicit opt-in. + + To opt in after 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 bce1f8a0df..d9ba05ab69 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1278,6 +1278,7 @@ 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 = [] @@ -1308,11 +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") @@ -1456,16 +1458,12 @@ def init( console.print(tracker.render()) console.print("\n[bold green]Project ready.[/bold green]") - if _git_ext_freshly_installed: + if _git_ext_freshly_installed and _git_ext_install_notice: console.print() console.print( Panel( - "The [bold]git[/bold] extension is currently enabled by default, " - "but starting with [bold]v1.0.0[/bold] it will require explicit opt-in.\n\n" - "To opt in after v1.0.0:\n" - " • [cyan]specify init --extension git[/cyan]\n" - " • [cyan]specify extension add git[/cyan] (post-init)", - title="[yellow]⚠ Upcoming Change: git Extension[/yellow]", + _git_ext_install_notice.strip(), + title="[yellow]⚠ Deprecation notice: git Extension[/yellow]", border_style="yellow", padding=(1, 2), ) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index d5543cd0b4..e00d68ffb1 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -346,6 +346,15 @@ 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. + """ + return self.data.get("extension", {}).get("install_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 beba4f428a..a02726695a 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -800,7 +800,7 @@ def test_check_feature_branch_rejects_malformed_timestamp(self, tmp_path: Path): capture_output=True, text=True, ) assert result.returncode != 0 - + def test_check_feature_branch_accepts_single_prefix(self, tmp_path: Path): """git-common check_feature_branch matches core: one optional path prefix.""" project = _setup_project(tmp_path) @@ -854,11 +854,21 @@ def test_deprecation_notice_shown_on_fresh_install(self, tmp_path: Path): 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" + "v1.0.0 it will require explicit opt-in.\n\n" + "To opt in after 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 with patch("specify_cli.extensions.ExtensionManager", return_value=mock_manager): result = runner.invoke( @@ -868,9 +878,9 @@ def test_deprecation_notice_shown_on_fresh_install(self, tmp_path: Path): ) assert result.exit_code == 0, result.output - assert "Upcoming Change: git Extension" in result.output + assert "Deprecation notice: git Extension" in result.output assert "v1.0.0" in result.output - assert "specify init --extension git" in result.output + assert "specify extension add git" in result.output 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.""" @@ -895,7 +905,7 @@ def test_deprecation_notice_not_shown_when_already_installed(self, tmp_path: Pat ) assert result.exit_code == 0, result.output - assert "Upcoming Change: git Extension" not in result.output + assert "Deprecation notice: git Extension" not in result.output 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.""" @@ -912,4 +922,33 @@ def test_deprecation_notice_not_shown_with_no_git_flag(self, tmp_path: Path): ) assert result.exit_code == 0, result.output - assert "Upcoming Change: git Extension" not in result.output + assert "Deprecation notice: git Extension" not in result.output + + 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 + + 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): + 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 + assert "Deprecation notice: git Extension" not in result.output From 54d3723f74c42fb673ada40b4ac4dbf5ecd98f77 Mon Sep 17 00:00:00 2001 From: Aaron Sun Date: Sat, 18 Apr 2026 13:11:54 -0700 Subject: [PATCH 3/6] Explicitly stated that specify-cli needs to be opted in Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extensions/git/extension.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/git/extension.yml b/extensions/git/extension.yml index 67cb9ab49d..6edb65ddcb 100644 --- a/extensions/git/extension.yml +++ b/extensions/git/extension.yml @@ -10,9 +10,9 @@ extension: license: MIT install_notice: | The git extension is currently enabled by default, but starting with - v1.0.0 it will require explicit opt-in. + specify-cli v1.0.0 it will require explicit opt-in. - To opt in after v1.0.0: + To opt in after specify-cli v1.0.0: • specify init --extension git • specify extension add git (post-init) From 9cbb82fecbf9e49fb34ac6fc458ac0ab14c819da Mon Sep 17 00:00:00 2001 From: Aaron Sun Date: Sat, 18 Apr 2026 13:12:27 -0700 Subject: [PATCH 4/6] nit: fixed naming convention Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d9ba05ab69..91f8310baa 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1463,7 +1463,7 @@ def init( console.print( Panel( _git_ext_install_notice.strip(), - title="[yellow]⚠ Deprecation notice: git Extension[/yellow]", + title="[yellow]⚠ Deprecation notice: git extension[/yellow]", border_style="yellow", padding=(1, 2), ) From 68dacfaaaa97920e4b2cc30358a37b691cef7f5b Mon Sep 17 00:00:00 2001 From: Aaron Sun Date: Sat, 18 Apr 2026 13:15:38 -0700 Subject: [PATCH 5/6] Updated unit tests --- tests/extensions/git/test_git_extension.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index a02726695a..2e7269d4bd 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -857,8 +857,8 @@ def test_deprecation_notice_shown_on_fresh_install(self, tmp_path: Path): mock_manifest = MagicMock() mock_manifest.install_notice = ( "The git extension is currently enabled by default, but starting with\n" - "v1.0.0 it will require explicit opt-in.\n\n" - "To opt in after v1.0.0:\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)" ) @@ -878,7 +878,7 @@ def test_deprecation_notice_shown_on_fresh_install(self, tmp_path: Path): ) assert result.exit_code == 0, result.output - assert "Deprecation notice: git Extension" in result.output + assert "Deprecation notice: git extension" in result.output assert "v1.0.0" in result.output assert "specify extension add git" in result.output @@ -905,7 +905,7 @@ def test_deprecation_notice_not_shown_when_already_installed(self, tmp_path: Pat ) assert result.exit_code == 0, result.output - assert "Deprecation notice: git Extension" not in result.output + assert "Deprecation notice: git extension" not in result.output 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.""" @@ -922,7 +922,7 @@ def test_deprecation_notice_not_shown_with_no_git_flag(self, tmp_path: Path): ) assert result.exit_code == 0, result.output - assert "Deprecation notice: git Extension" not in result.output + assert "Deprecation notice: git extension" not in result.output 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.""" @@ -951,4 +951,4 @@ def test_deprecation_notice_not_shown_when_no_install_notice(self, tmp_path: Pat ) assert result.exit_code == 0, result.output - assert "Deprecation notice: git Extension" not in result.output + assert "Deprecation notice: git extension" not in result.output From 6e481c602538d86d46dcf3129e727d4f7bf5e426 Mon Sep 17 00:00:00 2001 From: Aaron Sun Date: Sat, 18 Apr 2026 13:23:08 -0700 Subject: [PATCH 6/6] fix: address PR review feedback for install_notice - Add type validation in install_notice property (coerce to str) - Add isinstance guard before calling .strip() in init - Patch _locate_bundled_extension in tests for deterministic behavior - Use strip_ansi() on output before assertions to handle ANSI codes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/__init__.py | 20 ++++++++------- src/specify_cli/extensions.py | 7 ++++- tests/extensions/git/test_git_extension.py | 30 +++++++++++++++------- 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 91f8310baa..f771a8078e 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1458,16 +1458,18 @@ def init( console.print(tracker.render()) console.print("\n[bold green]Project ready.[/bold green]") - if _git_ext_freshly_installed and _git_ext_install_notice: - console.print() - console.print( - Panel( - _git_ext_install_notice.strip(), - title="[yellow]⚠ Deprecation notice: git extension[/yellow]", - border_style="yellow", - padding=(1, 2), + 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) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index e00d68ffb1..dfc631a532 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -353,7 +353,12 @@ def install_notice(self) -> str | None: Extensions can specify an 'install_notice' field to display important information to users when the extension is first installed. """ - return self.data.get("extension", {}).get("install_notice") + 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.""" diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 2e7269d4bd..ec15f54f64 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -850,6 +850,7 @@ def test_deprecation_notice_shown_on_fresh_install(self, tmp_path: Path): 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() @@ -870,7 +871,9 @@ def test_deprecation_notice_shown_on_fresh_install(self, tmp_path: Path): 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 _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"], @@ -878,15 +881,17 @@ def test_deprecation_notice_shown_on_fresh_install(self, tmp_path: Path): ) assert result.exit_code == 0, result.output - assert "Deprecation notice: git extension" in result.output - assert "v1.0.0" in result.output - assert "specify extension add git" in 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() @@ -897,7 +902,8 @@ def test_deprecation_notice_not_shown_when_already_installed(self, tmp_path: Pat mock_manager = MagicMock() mock_manager.registry = mock_registry - with patch("specify_cli.extensions.ExtensionManager", return_value=mock_manager): + 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"], @@ -905,12 +911,14 @@ def test_deprecation_notice_not_shown_when_already_installed(self, tmp_path: Pat ) assert result.exit_code == 0, result.output - assert "Deprecation notice: git extension" not in 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() @@ -922,13 +930,15 @@ def test_deprecation_notice_not_shown_with_no_git_flag(self, tmp_path: Path): ) assert result.exit_code == 0, result.output - assert "Deprecation notice: git extension" not in 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() @@ -943,7 +953,8 @@ def test_deprecation_notice_not_shown_when_no_install_notice(self, tmp_path: Pat mock_manager.registry = mock_registry mock_manager.install_from_directory.return_value = mock_manifest - with patch("specify_cli.extensions.ExtensionManager", return_value=mock_manager): + 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"], @@ -951,4 +962,5 @@ def test_deprecation_notice_not_shown_when_no_install_notice(self, tmp_path: Pat ) assert result.exit_code == 0, result.output - assert "Deprecation notice: git extension" not in result.output + plain = strip_ansi(result.output) + assert "Deprecation notice: git extension" not in plain