Skip to content

Commit 1f01cef

Browse files
mnriemCopilot
andcommitted
Load .extensionignore before deleting dest_dir on reinstall
The .extensionignore loader can raise ValidationError (invalid UTF-8) or OSError. Previously it ran after dest_dir was removed, so such a failure left the kept config only in the hidden staging directory rather than its documented location. Load/validate it before the rmtree so every post-deletion failure path restores the config. Adds a regression test. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent e942583 commit 1f01cef

2 files changed

Lines changed: 48 additions & 2 deletions

File tree

src/specify_cli/extensions/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1580,12 +1580,16 @@ def install_from_directory(
15801580
shutil.rmtree(rescue_staging_dir, ignore_errors=True)
15811581
raise
15821582

1583+
# Load and validate .extensionignore BEFORE deleting dest_dir. The
1584+
# loader can raise ValidationError (invalid UTF-8) or OSError; doing it
1585+
# first means such a failure leaves the kept config untouched in its
1586+
# documented location rather than only in the hidden staging directory.
1587+
ignore_fn = self._load_extensionignore(source_dir)
1588+
15831589
# Install extension (dest_dir computed above during self-install guard)
15841590
if dest_dir.exists():
15851591
shutil.rmtree(dest_dir)
15861592

1587-
ignore_fn = self._load_extensionignore(source_dir)
1588-
15891593
def _restore_stranded_config_file(
15901594
target: Path, content: bytes, preserved_mode: int
15911595
) -> None:

tests/test_extensions.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1354,6 +1354,48 @@ def failing_copytree(src, dst, **kwargs):
13541354
# The extension must remain unregistered after the failed install.
13551355
assert not manager.registry.is_installed("test-ext")
13561356

1357+
def test_extensionignore_load_failure_preserves_kept_config(
1358+
self, extension_dir, project_dir
1359+
):
1360+
"""An .extensionignore load failure must not lose a preserved config.
1361+
1362+
`.extensionignore` is loaded/validated before dest_dir is deleted, so a
1363+
ValidationError raised for invalid UTF-8 must abort the reinstall while
1364+
leaving the kept config untouched in its documented location rather than
1365+
only in the hidden staging directory.
1366+
"""
1367+
manager = ExtensionManager(project_dir)
1368+
1369+
packaged_config = extension_dir / "test-ext-config.yml"
1370+
packaged_config.write_text("model: default-model\n")
1371+
1372+
manager.install_from_directory(
1373+
extension_dir, "0.1.0", register_commands=False
1374+
)
1375+
1376+
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
1377+
config_file = ext_dir / "test-ext-config.yml"
1378+
config_file.write_text("model: custom-model\nmax_iterations: 99\n")
1379+
original_bytes = config_file.read_bytes()
1380+
1381+
manager.remove("test-ext", keep_config=True)
1382+
assert not manager.registry.is_installed("test-ext")
1383+
assert config_file.exists()
1384+
1385+
# Author an .extensionignore that is not valid UTF-8 so the loader
1386+
# raises before dest_dir would be deleted.
1387+
(extension_dir / ".extensionignore").write_bytes(b"\xff\xfe invalid\n")
1388+
1389+
with pytest.raises(ValidationError, match="not valid UTF-8"):
1390+
manager.install_from_directory(
1391+
extension_dir, "0.1.0", register_commands=False
1392+
)
1393+
1394+
# The kept config must remain in its documented location, untouched.
1395+
assert config_file.exists(), "config must survive the ignore-load failure"
1396+
assert config_file.read_bytes() == original_bytes
1397+
assert not manager.registry.is_installed("test-ext")
1398+
13571399
def test_retry_after_staging_backup_restores_stranded_config(
13581400
self, extension_dir, project_dir, monkeypatch
13591401
):

0 commit comments

Comments
 (0)