diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index d9ca00804c..0e060efc2a 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -191,6 +191,8 @@ specify bundle init [] Ensures the current directory is a Spec Kit project (initializing it idempotently if needed), then optionally installs the given bundle. Useful as an explicit one-step bootstrap for a new checkout. +Repeating `bundle init` skips components already installed by the bundle and preserves their local edits. It follows the same rules as a normal [`bundle install`](#install-a-bundle): components newly added to the manifest are installed and recorded, but it fails without changing the installed bundle if the manifest changes the recorded bundle version, changes an owned component's version, source, preset priority, or strategy, or removes an owned component. To refresh components or apply such changes, use `bundle update ` for a catalog bundle or `bundle install --refresh` for a local source. + ## Validate a Bundle ```bash diff --git a/src/specify_cli/bundles/command_init.py b/src/specify_cli/bundles/command_init.py index 0b2e8133c1..c61dd53615 100644 --- a/src/specify_cli/bundles/command_init.py +++ b/src/specify_cli/bundles/command_init.py @@ -48,4 +48,9 @@ def bundle_init( f"{_escape_markup(str(project_root))}." ) if bundle: - bundle_install(bundle, integration=integration, offline=offline) + bundle_install( + bundle, + integration=integration, + offline=offline, + refresh=False, + ) diff --git a/tests/specify_cli/bundles/test_command_init.py b/tests/specify_cli/bundles/test_command_init.py index adf60e0035..7d692b23fb 100644 --- a/tests/specify_cli/bundles/test_command_init.py +++ b/tests/specify_cli/bundles/test_command_init.py @@ -1,13 +1,17 @@ from __future__ import annotations from pathlib import Path +from unittest.mock import Mock import pytest +import yaml from typer.testing import CliRunner from specify_cli import app +from tests.specify_cli.bundles.helpers import write_manifest runner = CliRunner() +REPO_ROOT = Path(__file__).parents[3] def _make_project(tmp_path: Path, name: str) -> Path: @@ -16,6 +20,74 @@ def _make_project(tmp_path: Path, name: str) -> Path: return project +@pytest.mark.parametrize("bundle", [None, "bugfix"]) +def test_bundle_init_delegates_without_refresh(project, monkeypatch, bundle): + from specify_cli.bundles import command_init + + install = Mock() + monkeypatch.setattr(command_init, "bundle_install", install) + + command_init.bundle_init(bundle, integration=None, offline=True) + + if bundle is None: + install.assert_not_called() + else: + install.assert_called_once_with( + bundle, integration=None, offline=True, refresh=False, + ) + + +@pytest.mark.parametrize( + ("command", "refresh"), + [("init", False), ("install", False), ("install", True)], +) +def test_bundle_init_preserves_workflow_unless_explicitly_refreshed( + project, command, refresh, +): + bundle = REPO_ROOT / "bundles" / "bugfix" + initial = runner.invoke(app, ["bundle", "init", str(bundle), "--offline"]) + assert initial.exit_code == 0, initial.output + workflow = project / ".specify/workflows/bugfix/workflow.yml" + original = workflow.read_bytes() + edited = original + b"# Local review note: use staging.\r\n" + workflow.write_bytes(edited) + registry = project / ".specify/workflows/workflow-registry.json" + registry_before = registry.read_bytes() + args = ["bundle", command, str(bundle), "--offline"] + if refresh: + args.append("--refresh") + + repeated = runner.invoke(app, args) + + assert repeated.exit_code == 0, repeated.output + assert workflow.read_bytes() == (original if refresh else edited) + if not refresh: + assert registry.read_bytes() == registry_before + assert "2 already present" in repeated.output + assert "refreshed" not in repeated.output + + +def test_bundle_init_rejects_changed_manifest_without_refresh(project): + bundle = REPO_ROOT / "bundles" / "bugfix" + initial = runner.invoke(app, ["bundle", "init", str(bundle), "--offline"]) + assert initial.exit_code == 0, initial.output + records = project / ".specify/bundle-records.json" + records_before = records.read_bytes() + workflow = project / ".specify/workflows/bugfix/workflow.yml" + edited = workflow.read_bytes() + b"# Local review note: use staging.\r\n" + workflow.write_bytes(edited) + data = yaml.safe_load((bundle / "bundle.yml").read_text(encoding="utf-8")) + data["bundle"]["version"] = "999.0.0" + changed = write_manifest(project / "changed-bundle", data) + + result = runner.invoke(app, ["bundle", "init", str(changed), "--offline"]) + + assert result.exit_code == 1, result.output + assert "--refresh" in result.output + assert records.read_bytes() == records_before + assert workflow.read_bytes() == edited + + def test_override_symlinked_specify_errors_bundle_init_no_fallback( tmp_path, monkeypatch ):