Skip to content
Draft
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
20 changes: 20 additions & 0 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1397,6 +1397,22 @@ def install_from_directory(
backup_config_dir.unlink()
did_remove = self.remove(manifest.id)

# Rescue any config files left behind by a prior `remove --keep-config`.
# When an extension is removed with --keep-config, it is no longer in
# the registry but its config files remain in dest_dir. A subsequent
# plain (non-force) install would delete that directory unconditionally,
# silently discarding the preserved config. We read those files into
# memory now and write them back after copytree so the user's values
# always win over the packaged defaults.
stranded_configs: dict[str, bytes] = {}
if dest_dir.exists() and not self.registry.is_installed(manifest.id):
for cfg_file in (
list(dest_dir.glob("*-config.yml"))
+ list(dest_dir.glob("*-config.local.yml"))
):
if cfg_file.is_file() and not cfg_file.is_symlink():
stranded_configs[cfg_file.name] = cfg_file.read_bytes()

# Install extension (dest_dir computed above during self-install guard)
if dest_dir.exists():
shutil.rmtree(dest_dir)
Expand Down Expand Up @@ -1427,6 +1443,10 @@ def install_from_directory(
hook_executor = HookExecutor(self.project_root)
hook_executor.register_hooks(manifest)

# Restore stranded configs rescued before the rmtree above.
for filename, content in stranded_configs.items():
(dest_dir / filename).write_bytes(content)

# Restore config files from backup when --force triggered a removal.
# Only restore *.yml config files to match what remove() backs up,
# so unexpected artifacts in .backup/ are not resurrected.
Expand Down
58 changes: 57 additions & 1 deletion tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1226,7 +1226,63 @@ def test_install_force_config_preserved(self, extension_dir, project_dir):
assert new_config.exists()
assert new_config.read_text() == "test: config"

def test_install_force_without_existing(self, extension_dir, project_dir):
def test_reinstall_after_keep_config_preserves_config(
self, extension_dir, project_dir
):
"""Reinstalling after `remove --keep-config` must not overwrite preserved config."""
manager = ExtensionManager(project_dir)

# Install once
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)

# Customize the config file
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
config_file = ext_dir / "test-ext-config.yml"
config_file.write_text("model: custom-model\nmax_iterations: 99\n")

# Remove while preserving config
manager.remove("test-ext", keep_config=True)
assert not manager.registry.is_installed("test-ext")
assert config_file.exists()
assert "custom-model" in config_file.read_text()

# Plain reinstall (no --force)
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)

# Preserved config must survive the reinstall
assert config_file.exists()
assert "custom-model" in config_file.read_text()
assert "99" in config_file.read_text()

def test_reinstall_after_keep_config_preserves_local_config(
self, extension_dir, project_dir
):
"""Local config override files (*-config.local.yml) are also rescued on reinstall."""
manager = ExtensionManager(project_dir)

manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)

ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
local_cfg = ext_dir / "test-ext-config.local.yml"
local_cfg.write_text("local_override: true\n")

manager.remove("test-ext", keep_config=True)
assert local_cfg.exists()

manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)

assert local_cfg.exists()
assert "local_override: true" in local_cfg.read_text()


"""Test force-install when extension is NOT already installed (works normally)."""
manager = ExtensionManager(project_dir)

Expand Down