fix(update): stop declined/dry-run apm update from mutating apm_modules/#2050
fix(update): stop declined/dry-run apm update from mutating apm_modules/#2050nadav-y wants to merge 3 commits into
Conversation
…ed/dry-run apm update cannot leave apm_modules/ ahead of apm.lock.yaml download_callback materializes a re-resolved semver dep's new content to apm_modules/ during the resolve phase, before apm update's plan-confirmation gate runs. When that gate does not commit -- the user declines, the shell is non-interactive (no TTY, no --yes), or --dry-run is passed -- the new content was already written to disk while apm.lock.yaml stayed on the old version, silently leaving the project in a torn state. Reproduced live for both registry-sourced and git-sourced dependencies. _purge_cached_semver_paths_for_update (moved to the new update_backup.py, which also keeps install/phases/resolve.py under its LOC budget) now moves a semver dep's existing install path aside instead of deleting it outright, but only when apm update's plan_callback is present -- apm install --update has no decline path and keeps the original delete-and-redownload behavior unchanged. Once the plan-confirmation gate resolves, pipeline.py's new try/finally calls restore_update_backups: on commit, backups for successfully re-downloaded deps are discarded; otherwise every purged dep is reverted to its original content, and any freshly-downloaded dep with no prior backup (a new add swept into the same resolve pass) is removed outright. The finally block runs even when the non-interactive path raises SystemExit, so the rollback cannot be skipped by that abort. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a torn-state bug in apm update where semver re-resolution during the resolve phase could mutate apm_modules/ even when the plan-confirmation gate does not commit (decline, non-interactive abort, or --dry-run). It stages existing installs for rollback and reconciles them after the plan gate.
Changes:
- Add an update backup/restore mechanism to stage existing semver installs during
apm updateand roll them back when the plan is not committed. - Wire the plan-callback signal through
InstallContextand reconcile backups around the plan-confirmation gate. - Add unit tests covering backup/restore behavior and add a changelog entry for the fix.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
tests/unit/install/phases/test_update_backup.py |
Adds unit tests for the stage-and-restore mechanism and rollback edge cases. |
src/apm_cli/install/pipeline.py |
Passes plan_callback into InstallContext and reconciles staged backups in a try/finally around the plan gate. |
src/apm_cli/install/phases/update_backup.py |
Introduces purge-to-backup and restore/discard logic for apm update plan gating. |
src/apm_cli/install/phases/resolve.py |
Switches semver-cache purge to stage installs (when plan gate exists) instead of deleting outright. |
src/apm_cli/install/context.py |
Adds context fields for plan_callback and update_backups. |
CHANGELOG.md |
Adds an Unreleased “Fixed” entry describing the behavior change. |
…ups on resolve-phase failure, fix collision-prone naming
Four real issues from the automated Copilot review, all confirmed by reading
the actual code paths before fixing:
- restore_update_backups was only ever invoked from the plan-confirmation
gate in pipeline.py. If resolver.resolve_dependencies() itself raises
(e.g. a network error on a *different* dep, or a bad transitive manifest)
after purge_cached_semver_paths_for_update already moved some dep aside,
nothing restored it -- the backup was orphaned in .apm-update-backup/
with the dep's install path left missing. Added a try/except around the
resolve phase call in pipeline.py that restores staged backups before
re-raising, with two new pipeline tests covering both the failure path
and the (still correct) no-op when no backups were staged.
- restore_update_backups looked up dep objects via ctx.deps_to_install,
which is only populated after resolve_dependencies() returns
successfully -- exactly the failure window above. Switched to
ctx.all_apm_deps (populated before resolve starts, and guaranteed to
contain every dep purge_cached_semver_paths_for_update could have
purged) merged with ctx.deps_to_install (extends coverage to transitive
adds when resolution did complete).
- _sanitize_backup_name collapsed distinct dep keys to the same backup
directory name (e.g. "owner/repo" and "owner_repo" both became
"owner_repo"), risking one dep's backup silently overwriting another's.
Appended a short hash of the original key. New unit tests assert
distinct keys never collide.
- purge_cached_semver_paths_for_update logged "cleared cached install
path" unconditionally even when the rename/rmtree inside the
suppress(Exception) block actually failed, misleadingly implying
semver re-resolution would occur when it might not. Now only logs on
confirmed success.
Also added type hints (per repo convention) and fixed a stale docstring
reference to the old (now-public, no-underscore) function name.
The isinstance-based coercion this required in restore_update_backups
(rather than a plain `... or {}` fallback) is defensive against any
caller whose ctx doesn't populate these fields as real dicts/lists --
irrelevant in production (InstallContext's dataclass defaults guarantee
this), but it also happens to make the function robust against loosely-
mocked ctx objects in unrelated tests that now incidentally reach this
code path via the new pipeline.py exception handler.
Full unit suite: 18162 passed, 6 pre-existing failures (confirmed
identical on main, unrelated to this change), 0 new failures. ruff clean.
Re-verified the live repro (registry + git-sourced, decline/non-interactive/
dry-run/happy-path) end-to-end after every change in this commit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Mode B detector flags this PR's changes under src/apm_cli/install/ as net-new normative behaviour requiring a spec anchor + manifest row + Appendix C entry + conformance test. It isn't: apm update's plan- confirmation gate already documents (in its own code comment) that confirmation happens "before downloads begin so a 'no' answer cancels cleanly without touching the cache." This PR fixes a bug where that promise wasn't actually kept for semver re-resolution -- it restores the existing contract, it doesn't define a new one. apm-spec-waiver: bug fix restoring existing update-confirm-gate no-side-effect contract
Addresses Copilot review on microsoft#2053: backup_before_overwrite suppressed every exception during staging and returned False, so a rename/mkdir failure on an existing install path let the caller proceed straight to overwriting it with no rollback point staged. A later declined/aborted update would then see that dep in ctx.callback_downloaded but absent from ctx.update_backups -- indistinguishable from a fresh add -- and delete it outright, permanently losing the original content. Now raises once staging is actually required (plan_callback set and install_path exists), instead of swallowing the error. The resolve-phase exception handler already added in pipeline.py (for microsoft#2050's Copilot review) restores any backups staged for other deps this run before re-raising, so the whole apm update aborts cleanly -- recoverable, unlike a silent, undetected loss of the original content. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 1 | 3 | Well-structured extraction. Stage pattern is clean. suppress(Exception) in restore can re-create torn state. |
| CLI Logging Expert | 0 | 1 | 2 | restore_update_backups is completely silent -- no logging on restore, discard, or fresh-add removal. |
| DevX UX Expert | 0 | 2 | 1 | Decline path is correctly a no-op but gives zero user feedback; suppress(Exception) on restore risks torn state. |
| Supply Chain Security | 0 | 1 | 1 | PR improves lockfile integrity; backup dir not gitignored; suppress(Exception) overly broad for restore. |
| OSS Growth Hacker | 0 | 0 | 2 | Strong trust-building fix. CHANGELOG entry is release-notes-ready. Docs already describe the contract. |
| Auth Expert | -- | -- | -- | Inactive: auth-neutral orchestration change only. |
| Doc Writer | 0 | 1 | 3 | CHANGELOG second sentence leaks internal mechanism detail; reference docs already correct. |
| Test Coverage Expert | 0 | 2 | 1 | 16/16 update_backup unit tests pass; 2/2 new pipeline orchestration tests FAIL (confirmed by pytest). |
| Performance Expert | 0 | 1 | 3 | EXDEV cross-filesystem rename silently defeats backup gate; happy-path overhead negligible. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
- [Test Coverage Expert] (blocking-severity) Fix two failing pipeline orchestration tests (mock target mismatch: patch
apm_cli.install.phases.resolve.run, notapm_cli.install.phases.resolve). -- outcome=failed on this commit. The resolve-failure->restore path has zero passing test coverage. Tests are new in this PR, not pre-existing failures. - [Python Architect] (blocking-severity) Replace
suppress(Exception)inrestore_update_backupsand backup creation with narrowOSErrorcatch and logged warning. -- Three panelists independently confirmed: broad suppression can silently re-create the torn state the PR exists to fix. The performance-expert's EXDEV scenario is a concrete real-world trigger. - [DevX UX Expert] Add user-visible feedback on decline/dry-run ('No changes made.') and verbose-mode logging for restore/discard operations. -- Two panelists flagged silent rollback. Safe to defer to a follow-up PR.
- [Supply Chain Security] Add
.apm-update-backup/to default.gitignoretemplate. -- Crash-orphan risk: SIGKILL between backup creation and finally cleanup leaves directory thatgit add .could commit. - [Doc Writer] Trim CHANGELOG entry to user-observable behavior; remove internal staging-and-rollback mechanism detail. -- Keep a Changelog convention: entries describe what changed for users, not how.
Architecture
classDiagram
direction LR
class InstallContext {
<<Dataclass>>
+plan_callback Any
+update_backups dict
+callback_downloaded dict
+all_apm_deps list
+deps_to_install list
+apm_modules_dir Path
+apm_dir Path
}
class DependencyReference {
<<ValueObject>>
+ref_kind str
+is_local bool
+get_unique_key() str
+get_install_path(dir) Path
}
class update_backup {
<<Module>>
+purge_cached_semver_paths_for_update() dict
+restore_update_backups(ctx, keep_new) None
-_sanitize_backup_name(key) str
}
class resolve {
<<Module>>
+run(ctx) None
-_resolve_dependencies(ctx) None
}
class pipeline {
<<Module>>
+run_install_pipeline() InstallResult
}
pipeline *-- InstallContext : creates
pipeline ..> update_backup : try/finally reconcile
resolve ..> update_backup : purge call
update_backup ..> InstallContext : reads/writes backups
update_backup ..> DependencyReference : iterates
resolve ..> DependencyReference : resolves
class update_backup:::touched
class InstallContext:::touched
class pipeline:::touched
class resolve:::touched
classDef touched fill:#fff3b0,stroke:#d47600
flowchart TD
A["apm update CLI entry"] --> B["pipeline.run_install_pipeline()"]
B --> C["InstallContext created\nplan_callback stored on ctx"]
C --> D{"ctx.plan_callback\nis set?"}
D -->|Yes| E["resolve.py: backup_root =\nctx.apm_dir / .apm-update-backup"]
D -->|No| F["resolve.py: backup_root = None"]
E --> G["purge_cached_semver_paths_for_update()\nPath.rename(ip to backup)\nctx.update_backups populated"]
F --> H["purge_cached_semver_paths_for_update()\nrobust_rmtree(ip) -- delete outright"]
G --> I["_resolve_dependencies()\nBFS resolver + download_callback"]
H --> I
I -->|Exception| J{"ctx.update_backups\nnon-empty?"}
J -->|Yes| K["restore_update_backups(ctx, keep_new=False)\nrename backups back to install paths"]
J -->|No| L["re-raise original exception"]
K --> L
I -->|Success| M["build_update_plan()"]
M --> N["committed = False"]
N --> O["plan_callback(plan)"]
O -->|"False / SystemExit"| P["finally: restore_update_backups(ctx, keep_new=False)\nrename backups to install paths\nrmtree freshly-added deps"]
O -->|True| Q["committed = True"]
Q --> R["finally: restore_update_backups(ctx, keep_new=True)\nrmtree backups (discard)"]
R --> S["continue to integrate / lockfile write"]
Recommendation
The core backup/restore design is sound and the PR fixes a real user-facing torn-state bug -- this is exactly the kind of reliability work that builds community trust. However, two issues must be resolved before merge: (1) two new tests confirmed failing on this commit, leaving the most critical new code path (restore on resolve failure) unproven; and (2) suppress(Exception) on the restore path can silently reproduce the exact bug the PR fixes, which three panelists flagged independently. Both fixes are surgical (correct the mock target, narrow the catch to OSError + log). Once those land, this is a clean ship. The remaining findings (silent UX, gitignore, CHANGELOG wording) are safe fast follow-ups.
Full per-persona findings
Python Architect
- [recommended]
suppress(Exception)inrestore_update_backupscan silently re-create the torn state this PR fixes atsrc/apm_cli/install/phases/update_backup.py:157-- If restore fails (permission error, disk full), user ends up with apm_modules/ missing the dep and no backup -- exactly the torn state the PR exists to prevent. Suggested: Replacewith suppress(Exception):in the restore loop with a try/except that logs a warning via ctx.logger. - [nit]
plan_callbackonInstallContextis a boolean signal disguised as a callable -- resolve.py only checks for truthiness to decide backup_root. Pragmatic; the docstring explains it. - [nit] isinstance guards against MagicMock leak (production-test coupling) at
src/apm_cli/install/phases/update_backup.py:141-- Safe; comment is clear. - [nit] No concurrent-run protection for
.apm-update-backupdirectory -- Consistent with existing no-concurrency-guard behavior; not a regression.
CLI Logging Expert
- [recommended]
restore_update_backupsis completely silent -- no logging on restore, discard, or fresh-add removal -- In verbose mode, user sees purge but never sees restore. Suggested: Addlogger: Any = Noneparameter; log atverbose_detaillevel for each restore/discard/removal operation. - [nit] Purge log says 'cleared' when operation was actually 'moved to backup' -- When backup_root is set, use 'backed up' instead of 'cleared'.
DevX UX Expert
- [recommended] Declined/dry-run update gives zero user feedback about rollback -- User presses 'n', gets bare prompt back with no 'No changes made.' confirmation. Suggested: After
restore_update_backups(ctx, keep_new=False), emit a one-liner via existing console helpers. - [recommended]
suppress(Exception)on restore path risks silent corruption -- Failed restore is swallowed; user lands in torn state with no error. Should catchOSErrorspecifically and log a warning. - [nit] Consider adding
.apm-update-backup/mention to gitignore template documentation -- Ephemeral directory should never be checked in.
Supply Chain Security Expert
- [recommended] Backup directory
.apm-update-backup/is not gitignored -- crash-orphan risk -- SIGKILL between backup creation and finally cleanup leaves directory with old content;git add .could accidentally commit it. Suggested: Add to gitignore auto-update helper, or create.gitignoreinside backup root at creation time. - [nit]
suppress(Exception)overly broad for backup and restore operations -- Narrow tosuppress(OSError)and log a warning on catch.
OSS Growth Hacker
- [nit] CHANGELOG entry is long -- consider a one-liner lead then keep technical detail as second paragraph.
- [nit] Docs already correctly described the contract this PR fulfills -- worth a release callout to highlight that documented behavior is now actually implemented.
Auth Expert -- inactive
Auth-neutral orchestration change: no changes to credential resolution, token management, AuthResolver invocation, git credential fill, or HTTP/git transport auth paths. Exception handler re-raises after restoring backups, so auth errors propagate unchanged.
Doc Writer
- [recommended] CHANGELOG second sentence describes internal staging-and-rollback mechanism rather than user-observable behavior -- should be cut. Keep a Changelog convention: entries describe what changed for users, not how.
- [nit] 'dependency content untouched' phrase is ambiguous -- use 'manifest, lockfile, and filesystem unchanged' to mirror reference docs vocabulary.
- [nit]
update.mdreference docs already correctly state the contract -- no update needed; informational. - [nit] Bug feat(deps): resolve semver ranges on git-source dependencies #1496 reference in docstring without context -- Move to parenthetical or remove; the docstring explains the problem fully without it.
Test Coverage Expert
- [recommended] 2 new pipeline orchestration tests FAIL (confirmed by running pytest) --
patch("apm_cli.install.phases.resolve", mock_resolve)fails with AttributeError; fix:patch("apm_cli.install.phases.resolve.run", side_effect=RuntimeError(...)). NOT a pre-existing failure.
Evidence (failed, unit):tests/unit/test_install_pipeline_orchestration.py::TestRunInstallPipelineResolveFailureRestoresBackups::test_resolve_exception_triggers_restore_when_backups_staged-- assertion:mock_restore.assert_called_once_with(mock_ctx, keep_new=False) - [recommended] No integration test exercises the full decline->restore filesystem round-trip through the real pipeline.
Evidence (missing, integration-with-fixtures):tests/integration/test_update_e2e.py::test_update_dry_run_preserves_apm_modules - [nit] 16 update_backup unit tests all pass (real filesystem via tmp_path, 0.08s). Strong unit-tier coverage of the new module.
Performance Expert
- [recommended] Cross-filesystem rename silently defeats backup gate at
src/apm_cli/install/phases/update_backup.py:110-- Path.rename() raises EXDEV on different mount points; suppress(Exception) swallows it; dep not added to backups; resolver short-circuits; --update flag silently defeated for that dep. Suggested: Detect EXDEV and fall back to shutil.move(), or log a warning. - [nit] Happy-path overhead of backup mechanism is negligible vs. network fetch (O(1) rename per dep).
- [nit] robust_rmtree retry overhead acceptable for backup cleanup path.
This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.
sergio-sisternes-epam
left a comment
There was a problem hiding this comment.
The panel found two blocking issues: (1) Two new pipeline orchestration tests in TestRunInstallPipelineResolveFailureRestoresBackups FAIL on this commit -- the mock patches apm_cli.install.phases.resolve instead of apm_cli.install.phases.resolve.run. Fix: change to patch('apm_cli.install.phases.resolve.run', side_effect=RuntimeError(...)). (2) suppress(Exception) in restore_update_backups can silently re-create the torn state this PR exists to fix -- narrow to except OSError and add a logged warning. Both fixes are surgical. The core backup/restore design is sound and this is very close to mergeable.
Summary
apm updatecomputes its plan by re-resolving semver ranges during the resolve phase -- for both git-source and registry-source dependencies, that re-resolution already downloads and writes the new version's content toapm_modules/, before the plan-confirmation gate ever runs. When the gate does not commit -- the user declines the prompt, the shell is non-interactive (no TTY, no--yes), or--dry-runis passed -- the new content stays on disk whileapm.lock.yamlstays on the old version. The project is left in a torn state:apm_modules/ahead of the lockfile, with no error surfaced.Reproduced live against a real registry and a real GitHub repo:
apm updatedeclined (n)apm updatenon-interactive, no--yes(exits 1)apm update --dry-run("no changes applied")apm update -y/ confirmedyapm outdatedapm install --update(no confirm gate)Fix
_purge_cached_semver_paths_for_updateforces re-resolution by deleting a semver dep's cached install path sodownload_callbackcan't short-circuit oninstall_path.exists(). This PR changes it to move the path aside to a backup instead of deleting it -- but only whenapm update'splan_callbackis present (apm install --updatehas no decline path and keeps the original delete-and-redownload behavior byte-for-byte).Once the plan-confirmation gate resolves, a
try/finallyinpipeline.pyreconciles the outcome:--dry-run): restore every backed-up dep's original content, and remove any freshly-downloaded dep that had no prior backup (a new add swept into the same resolve pass).The
finallyruns even when the non-interactive path raisesSystemExit, so the rollback can't be skipped by that abort.The purge/restore logic moved into a new
install/phases/update_backup.py(also keepsresolve.pyunder its 1000-LOC architecture-invariant budget, which my initial in-place patch pushed past).Test plan
tests/unit/install/phases/test_update_backup.py(13 tests) covering: outright-delete parity when no backup root is given, non-semver/local/artifactory deps skipped, backup-and-restore round trip, commit discards backup, decline restores original content, a freshly-added dep with no backup is removed on decline, a purged-but-never-redownloaded dep still restores even when the overall run commits, empty backup dir cleanup, multi-dep isolation.pytest tests/unit/): 18155 passed, 8 pre-existing failures confirmed unrelated (verified identically failing onmainbefore this change), 0 new failures.ruff check/ruff format --check: clean.^1.0.0): confirmed the bug before the fix, confirmed both the fix (declined/non-interactive/dry-run all leave state untouched) and the happy path (-ystill updates correctly) after.microsoft/apmitself, tagsv0.23.0->v0.23.1, semver range^0.23.0): same result -- confirmed for git-sourced deps too, not registry-specific.Spec conformance
This is a bug fix restoring behaviour the plan-confirmation gate already
promised (its own code comment: confirmation happens "before downloads
begin so a 'no' answer cancels cleanly without touching the cache") --
it does not introduce new normative behaviour, so no new spec anchor /
manifest row / Appendix C entry applies.
apm-spec-waiver: bug fix restoring existing update-confirm-gate no-side-effect contract