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 33 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 33 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

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: 3
  • Review effort level: Medium

Comment thread src/specify_cli/extensions/__init__.py
Comment thread src/specify_cli/extensions/__init__.py Outdated
Comment thread src/specify_cli/extensions/__init__.py Outdated
Address three review findings on the reinstall config-rescue path:

- A complete .rescue-complete marker proves only that staging finished,
  not that dest_dir was modified. A crash after staging sync but before
  the rmtree leaves the live kept config intact; if the user edits it
  before retrying, preferring the staged bytes silently overwrote the
  newer config. The two copies are indistinguishable in provenance from
  disk, so detect divergence between a complete staging copy and the live
  config and abort (preserving both) instead of unconditionally choosing
  staging.
- The staging directory embedded the full extension ID in one path
  component. Extension IDs are length-unbounded, so a valid long ID could
  install at dest_dir yet fail every reinstall-after-keep-config with
  ENAMETOOLONG. Derive the staging component from a fixed-length hash via
  a new _rescue_staging_dir() helper.
- The stranded-config restore used the full config filename as a
  NamedTemporaryFile prefix; a name already near the component limit plus
  the random suffix raised ENAMETOOLONG. Use a short fixed prefix.

Updates the retry regression test to the new divergence semantics and
adds conflict-abort, long-ID, and fixed-prefix coverage.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, 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: 3
  • Review effort level: Medium

Comment thread src/specify_cli/extensions/__init__.py Outdated
Comment thread src/specify_cli/extensions/__init__.py Outdated
Comment thread tests/test_extensions.py Outdated
Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

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: 2
  • Review effort level: Medium

Comment thread src/specify_cli/extensions/__init__.py Outdated
Comment thread src/specify_cli/extensions/__init__.py Outdated
Assisted-by: GitHub Copilot (model: GPT-5.6-Sol, autonomous)

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: 1
  • Review effort level: Medium

Comment thread src/specify_cli/extensions/__init__.py Outdated
…ew feedback

- _recognized_config_names() now accepts follow_symlinks=False for live dir
  so symlinked *-config.yml entries are detected and treated as conflicts
  rather than being silently deleted by rmtree.
- Add explanatory comment to bare 'except OSError: pass' in
  _restore_stranded_config_file's finally block.
- Resolve CodeQL dual-import style: use 'from specify_cli import extensions
  as _ext_module' instead of 'import specify_cli.extensions as _ext_module'.

Assisted-by: GitHub Copilot (model: claude-sonnet-4, autonomous)

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: 2
  • Review effort level: Medium

Comment thread src/specify_cli/extensions/__init__.py
Comment thread src/specify_cli/extensions/__init__.py
Add test_staging_failure_aborts_before_dest_dir_removal covering three
failure modes (mkdir, os.open/O_CREAT, fsync with EIO) in the rescue
staging block. Each parametrized case verifies:
- the install aborts before dest_dir is removed
- the preserved config bytes remain authoritative
- any partial staging is cleaned up and not left as complete
- the extension stays unregistered

Addresses review feedback on PRRT_kwDOPiFCnc6R351t.

Assisted-by: GitHub Copilot (model: claude-sonnet-4.5, autonomous)

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: 1
  • Review effort level: Medium

Comment thread src/specify_cli/extensions/__init__.py
Exercises the retry-from-staging branch (if staging_is_complete at
line 1505 of extensions/__init__.py) in a scenario where the live
config is absent — simulating a power loss that interrupted the
rollback before it could write the config back.

When the live copy is gone, the live-dir fallback (elif dest_dir.exists())
finds no stranded configs and the packaged default would be kept. Only the
staging-complete branch can restore the original bytes and mode. This proves
staging (not the fallback) is used on retry.

Addresses review feedback on PRRT_kwDOPiFCnc6SAL3L.

Assisted-by: GitHub Copilot (model: claude-sonnet-4.5, autonomous)

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: 2
  • Review effort level: Medium

Comment thread src/specify_cli/extensions/__init__.py Outdated
Comment thread src/specify_cli/extensions/__init__.py Outdated
… fix live-only conflict message

Thread 64: Remove os.fchmod/chmod from staged files to avoid Windows
read-only attribute that prevents shutil.rmtree from cleaning up.
Original permission bits are now written to a .rescue-modes.json sidecar
in the staging dir and reloaded during retry, with a fall-back to the
staged file's own mode for backwards-compat with pre-sidecar staging dirs.

Thread 65: Split the ValidationError message for staging-vs-live conflicts
into two accurate cases: files that diverged between both locations
("Both copies have been preserved") and live-only files that have no
backup counterpart, which previously incorrectly claimed "Both copies
have been preserved" and offered a restore instruction that was impossible.

Assisted-by: GitHub Copilot (model: claude-sonnet-4.5, autonomous)
Comment thread src/specify_cli/extensions/__init__.py Fixed
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: 1
  • Review effort level: Medium

Comment thread src/specify_cli/extensions/__init__.py Outdated
" then reinstall."
)
raise ValidationError(" ".join(msg_parts))
elif dest_dir.exists() and not self.registry.is_installed(manifest.id):

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.

Fixed in the two commits just pushed (short hashes in the latest push).

remove(..., keep_config=True) now writes a .keep-config provenance marker (empty file, presence-only sentinel) into the extension directory after stranding the config files. install_from_directory guards the rescue elif with a new _has_keep_config_marker() helper that checks is_file() and not is_symlink(), so only directories explicitly left by --keep-config enter that path — a partially-failed install (no marker, no registry entry) no longer has its packaged defaults treated as user-preserved data on a retry.

A regression test (test_failed_install_without_keep_config_does_not_rescue_defaults) pins this: it simulates a post-copytree registration failure, updates the packaged default, retries, and asserts the new default wins rather than the old stale one.

Posted on behalf of @mnriem by GitHub Copilot (model: claude-sonnet-4.5, autonomous).

Copilot AI added 2 commits July 17, 2026 21:58
…partially-failed installs

When `remove --keep-config` strands config files, write a `.keep-config`
marker into the extension directory.  `install_from_directory` now only
enters the rescue path when that marker is present, preventing a partially-
failed install (which also leaves dest_dir with no registry entry but no
marker) from having its packaged default configs treated as user-preserved
data on a retry from an updated package.

Refs: #3449 (comment)

Assisted-by: GitHub Copilot (model: claude-sonnet-4.5, autonomous)
…ontent choice

Assisted-by: GitHub Copilot (model: claude-sonnet-4.5, autonomous)
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