Skip to content

Commit 0f366c0

Browse files
authored
fix: durable staging for stranded configs and import style fix
- Stage stranded config files to a durable rescue_staging_dir (extensions_dir/.rescue-staging-<id>) before rmtree so original bytes survive partial rmtree, copytree failure, or partial restore on retry. On retry the staging dir is detected and its content reused instead of whatever mix of packaged defaults and partial restores remains on disk. The staging dir is cleaned up only after every restore succeeds. - Fix CodeQL: change `import specify_cli.extensions as _ext_module` to `from specify_cli import extensions as _ext_module` in test file. Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
1 parent 4b1058e commit 0f366c0

2 files changed

Lines changed: 46 additions & 4 deletions

File tree

src/specify_cli/extensions/__init__.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1402,10 +1402,27 @@ def install_from_directory(
14021402
# the registry but its config files remain in dest_dir. A subsequent
14031403
# plain (non-force) install would delete that directory unconditionally,
14041404
# silently discarding the preserved config. We read those files into
1405-
# memory now and write them back after copytree so the user's values
1406-
# always win over the packaged defaults.
1405+
# memory and also write a durable staging copy outside dest_dir so
1406+
# that a partial rmtree, failed copytree, or partial restore cannot
1407+
# permanently discard the user's original bytes on a retry. The
1408+
# staging dir is removed only after every config has been successfully
1409+
# restored.
14071410
stranded_configs: dict[str, tuple[bytes, int]] = {}
1408-
if dest_dir.exists() and not self.registry.is_installed(manifest.id):
1411+
rescue_staging_dir = self.extensions_dir / f".rescue-staging-{manifest.id}"
1412+
1413+
if rescue_staging_dir.is_dir() and not self.registry.is_installed(manifest.id):
1414+
# A previous install attempt staged the configs but never
1415+
# completed cleanly. Reload from the durable backup so the
1416+
# original bytes are used on retry rather than whatever
1417+
# mixture of packaged defaults and partial restores remains
1418+
# on disk.
1419+
for staged_file in sorted(rescue_staging_dir.iterdir()):
1420+
if staged_file.is_file():
1421+
stranded_configs[staged_file.name] = (
1422+
staged_file.read_bytes(),
1423+
staged_file.stat().st_mode,
1424+
)
1425+
elif dest_dir.exists() and not self.registry.is_installed(manifest.id):
14091426
for cfg_file in (
14101427
list(dest_dir.glob("*-config.yml"))
14111428
+ list(dest_dir.glob("*-config.local.yml"))
@@ -1416,6 +1433,26 @@ def install_from_directory(
14161433
cfg_file.stat().st_mode,
14171434
)
14181435

1436+
if stranded_configs and not rescue_staging_dir.is_dir():
1437+
# Write a durable backup outside dest_dir before any
1438+
# destructive operation so the original bytes survive a
1439+
# crash or partial failure at any later step. The staging
1440+
# dir is cleaned up only after every restore succeeds.
1441+
try:
1442+
rescue_staging_dir.mkdir(parents=True, exist_ok=True)
1443+
for filename, (content, mode) in stranded_configs.items():
1444+
staged = rescue_staging_dir / filename
1445+
staged.write_bytes(content)
1446+
try:
1447+
staged.chmod(mode)
1448+
except (NotImplementedError, OSError):
1449+
pass
1450+
except BaseException:
1451+
# Staging failed — clean up any partial marker dir and
1452+
# fall back to in-memory protection only (same as the
1453+
# pre-staging behaviour).
1454+
shutil.rmtree(rescue_staging_dir, ignore_errors=True)
1455+
14191456
# Install extension (dest_dir computed above during self-install guard)
14201457
if dest_dir.exists():
14211458
shutil.rmtree(dest_dir)
@@ -1460,6 +1497,11 @@ def _restore_stranded_config_file(
14601497
target = dest_dir / filename
14611498
_restore_stranded_config_file(target, content, mode)
14621499

1500+
# Every stranded config has been restored successfully — the
1501+
# durable staging backup is no longer needed.
1502+
if rescue_staging_dir.is_dir():
1503+
shutil.rmtree(rescue_staging_dir, ignore_errors=True)
1504+
14631505
# Register commands with AI agents
14641506
registered_commands = {}
14651507
if register_commands:

tests/test_extensions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from pathlib import Path
2121
from datetime import datetime, timezone
2222
from unittest.mock import MagicMock
23-
import specify_cli.extensions as _ext_module
23+
from specify_cli import extensions as _ext_module
2424

2525
from tests.conftest import strip_ansi
2626
from specify_cli.extensions import (

0 commit comments

Comments
 (0)