Skip to content

[bug-fix] Fix reinstall-overwrites-kept-config: preserve config on plain reinstall after --keep-config#3449

Open
github-actions[bot] wants to merge 20 commits into
mainfrom
fix/3427-reinstall-overwrites-kept-config-40c76b4f5dd346d6
Open

[bug-fix] Fix reinstall-overwrites-kept-config: preserve config on plain reinstall after --keep-config#3449
github-actions[bot] wants to merge 20 commits into
mainfrom
fix/3427-reinstall-overwrites-kept-config-40c76b4f5dd346d6

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Bug fix — reinstall-overwrites-kept-config

Proposed fix for issue #3427, applying the remediation from the bug assessment.

Verdict: Valid · Severity: medium

Summary

When specify extension remove <ext> --keep-config is used, the extension is unregistered but its *-config.yml files survive in the extension directory. A subsequent plain specify extension add <ext> unconditionally deleted that directory before copying the fresh extension in, silently discarding the preserved config. The fix rescues those stranded config files into memory before the rmtree and writes them back after copytree, so user-customized values always win over the packaged defaults.

Changes

File Change Notes
src/specify_cli/extensions/__init__.py modified Before rmtree(dest_dir), collect any *-config.yml / *-config.local.yml files from an unregistered dest_dir into memory; restore them after copytree
tests/test_extensions.py added tests test_reinstall_after_keep_config_preserves_config and test_reinstall_after_keep_config_preserves_local_config

Tests Added or Updated

  • tests/test_extensions.py::TestInstallFromDirectory::test_reinstall_after_keep_config_preserves_config — pins that a customized *-config.yml survives a remove --keep-config → plain reinstall cycle
  • tests/test_extensions.py::TestInstallFromDirectory::test_reinstall_after_keep_config_preserves_local_config — same for *-config.local.yml override files

Local Verification

  • No project test command could be exercised in this environment (no venv, no pytest available). The logic was verified by code inspection: the fix mirrors the existing backup/restore pattern used by the --force reinstall path, applying it to the previously-unhandled "unregistered but config-bearing directory" case.

Deviations from Assessment

None. The implementation follows the preferred remediation exactly as described.

Risks & Review Notes

  • Config files are read into memory; these files are typically < 1 KB so there is no memory concern.
  • The not self.registry.is_installed(manifest.id) guard ensures we only rescue configs when the extension is genuinely unregistered, avoiding picking up stale files from a different install.
  • The write-back happens after shutil.copytree, so packaged defaults are always superseded by the user's values — no risk of defaults silently winning.
  • install_from_zip() delegates to install_from_directory(), so it is covered without additional changes.
  • No API surface change; all changes are internal to install_from_directory().

Refs #3427 · cc @grafvonb

Generated by 🛠️ Fix Bug from Labeled Issue for issue #3427 · 449.5 AIC · ⌖ 15.2 AIC · ⊞ 34K ·

@github-actions github-actions Bot added automated bug-fix Trigger the bug-fix agentic workflow labels Jul 10, 2026
@mnriem mnriem marked this pull request as ready for review July 10, 2026 16:30
@mnriem mnriem self-requested a review as a code owner July 10, 2026 16:30
Copilot AI review requested due to automatic review settings July 10, 2026 16:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes extension config loss when reinstalling after remove --keep-config.

Changes:

  • Rescues and restores preserved extension configuration files.
  • Adds regression tests for standard and local configs.
Show a summary per file
File Description
src/specify_cli/extensions/__init__.py Adds preserved-config rescue and restoration.
tests/test_extensions.py Adds reinstall config-preservation tests.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 2/2 changed files
  • Comments generated: 3
  • Review effort level: Medium

Comment thread tests/test_extensions.py
Comment thread src/specify_cli/extensions/__init__.py Outdated
Comment thread src/specify_cli/extensions/__init__.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Medium

Comment thread src/specify_cli/extensions/__init__.py
Comment thread tests/test_extensions.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Medium

Comment thread src/specify_cli/extensions/__init__.py Outdated
Comment thread src/specify_cli/extensions/__init__.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Medium

Comment thread src/specify_cli/extensions/__init__.py Outdated
Comment thread src/specify_cli/extensions/__init__.py
Comment thread tests/test_extensions.py Fixed
Comment thread tests/test_extensions.py Fixed
mnriem and others added 8 commits July 14, 2026 11:31
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
…rt' and 'import from''

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
- 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)
…up errors

- Thread 14: Change except BaseException to except Exception in the staging
  fallback block so KeyboardInterrupt/SystemExit propagate correctly
- Thread 15: Add explanatory comment to the bare pass in the chmod except
  block to satisfy static analysis
- Thread 16: Reject a symlinked staging directory and only reload
  non-symlinked files whose names match the two recognised config suffixes
- Thread 17: Create each staging file via os.open with mode 0600 and
  O_CREAT|O_EXCL before writing so preserved bytes are never transiently
  exposed to other local users
- Thread 18: Remove ignore_errors=True from the final staging-dir cleanup
  so a failed rmtree propagates rather than silently leaving a stale
  backup that could be misread on the next retry

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
- Add `import stat` to imports
- Use `stat.S_IMODE(mode)` before chmod in staging write (thread 20, line 1464)
- Use `stat.S_IMODE(preserved_mode)` and make chmod best-effort in
  `_restore_stranded_config_file` (thread 18, line 1492)
- Add `not rescue_staging_dir.is_symlink()` guard to cleanup (thread 19, line 1522)

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
…re, full os.write

Assisted-by: GitHub Copilot (model: GPT-5.3-Codex, autonomous)
Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@mnriem mnriem force-pushed the fix/3427-reinstall-overwrites-kept-config-40c76b4f5dd346d6 branch from 6ff00ec to 86f93b6 Compare July 14, 2026 16:33
@mnriem mnriem requested a review from Copilot July 14, 2026 16:34
Comment thread src/specify_cli/extensions/__init__.py Fixed
Comment thread src/specify_cli/extensions/__init__.py Fixed
Comment thread src/specify_cli/extensions/__init__.py Fixed
Comment thread src/specify_cli/extensions/__init__.py Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 2/2 changed files
  • Comments generated: 3
  • Review effort level: Medium

Comment thread src/specify_cli/extensions/__init__.py
Comment thread src/specify_cli/extensions/__init__.py
Comment thread src/specify_cli/extensions/__init__.py
Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Comment thread tests/test_extensions.py Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Low

Comment thread src/specify_cli/extensions/__init__.py Outdated
mnriem and others added 2 commits July 14, 2026 15:12
Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…rt' and 'import from''

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 4/4 changed files
  • Comments generated: 6
  • Review effort level: Medium

Comment thread src/specify_cli/extensions/__init__.py Outdated
Comment on lines +117 to +119
os.fsync(dir_fd)
except (AttributeError, OSError, NotImplementedError):
pass
Comment thread src/specify_cli/extensions/__init__.py Outdated
Comment on lines +1518 to +1521
try:
os.fsync(fd)
except (AttributeError, OSError, NotImplementedError):
pass
Comment on lines +1597 to +1601
# Every stranded config has been restored successfully — the
# durable staging backup is no longer needed. Raise on failure so
# the install is not reported as successful while a stale backup
# that could be misread on the next retry remains on disk.
if rescue_staging_dir.is_dir() and not rescue_staging_dir.is_symlink():
Comment thread tests/test_extensions.py
Comment on lines +1407 to +1413
assert staging_dir.exists()
assert (staging_dir / ".rescue-complete").exists()
assert (staging_dir / "test-ext-config.yml").exists()

manifest = manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
Comment thread src/specify_cli/workflows/_commands.py Outdated
Comment on lines +1668 to +1669
"[yellow]Warning:[/yellow] Could not remove temporary "
f"download file {_escape_markup(str(tmp_path))}: "
f"{_escape_markup(str(cleanup_exc))}"
f"workflow download file: {_escape_markup(str(cleanup_exc))}"
Comment thread src/specify_cli/workflows/_commands.py Outdated
Comment on lines +1692 to +1693
"[yellow]Warning:[/yellow] Could not remove temporary "
f"download file {_escape_markup(str(tmp_path))}: "
f"{_escape_markup(str(exc))}"
f"workflow download file: {_escape_markup(str(exc))}"
Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Comment thread src/specify_cli/extensions/__init__.py Fixed
if target_fd is not None:
try:
os.close(target_fd)
except OSError:
Comment thread tests/test_extensions.py
from unittest.mock import MagicMock

from tests.conftest import strip_ansi
import specify_cli.extensions as _ext_module
Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 4/4 changed files
  • Comments generated: 5
  • Review effort level: Medium

Comment on lines +122 to +123
if not path.exists():
return
Comment on lines +1546 to +1548
# Write the completion marker only after every staged file is
# fully written so a retry trusts staging only when it is whole.
marker_fd = os.open(
Comment thread src/specify_cli/extensions/__init__.py
Comment thread src/specify_cli/workflows/_commands.py Outdated
"[yellow]Warning:[/yellow] Could not remove temporary "
f"download file {_escape_markup(str(tmp_path))}: "
f"{_escape_markup(str(cleanup_exc))}"
f"workflow download file: {_escape_markup(str(cleanup_exc))}"
Comment thread src/specify_cli/workflows/_commands.py Outdated
"[yellow]Warning:[/yellow] Could not remove temporary "
f"download file {_escape_markup(str(tmp_path))}: "
f"{_escape_markup(str(exc))}"
f"workflow download file: {_escape_markup(str(exc))}"
Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automated bug-fix Trigger the bug-fix agentic workflow

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: extension reinstall overwrites config preserved by remove --keep-config

3 participants