diff --git a/CHANGELOG.md b/CHANGELOG.md index 688e289ac..0d4f7b9f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- `apm install --dry-run` no longer lists the project's own `includes: auto` + self-managed files under "Files that would be removed"; the orphan preview + now excludes the synthesized lockfile self-entry, matching the real install + which never removes them. — by @mia106dev (#2069) + ## [0.24.0] - 2026-07-05 ### Added diff --git a/src/apm_cli/drift.py b/src/apm_cli/drift.py index cc8462598..dc239877e 100644 --- a/src/apm_cli/drift.py +++ b/src/apm_cli/drift.py @@ -202,10 +202,19 @@ def detect_orphans( are no longer in the manifest. The caller is responsible for actually removing the files. """ + from apm_cli.deps.lockfile import _SELF_KEY + orphaned: builtins.set = builtins.set() if only_packages or not existing_lockfile: return orphaned for dep_key, dep in existing_lockfile.dependencies.items(): + # The synthesized self-entry (key ".") holds the project's own + # includes:auto content, which the real install always re-deploys + # (never removes). It can never appear in the manifest-derived + # intended set, so skip it to avoid over-counting self-includes as + # removals in the dry-run preview. + if dep_key == _SELF_KEY: + continue if dep_key not in intended_dep_keys: orphaned.update(dep.deployed_files) return orphaned diff --git a/tests/unit/test_drift_detection.py b/tests/unit/test_drift_detection.py index 4a8dfe02b..3fd81dafb 100644 --- a/tests/unit/test_drift_detection.py +++ b/tests/unit/test_drift_detection.py @@ -214,6 +214,55 @@ def test_none_only_packages_treated_as_empty(self): result = detect_orphans(lf, set(), only_packages=None) self.assertEqual(result, {"a.md"}) + def test_self_entry_never_orphaned(self): + """The synthesized self-entry (key ".") must never be reported as orphan. + + from_yaml() injects a virtual ``_SELF_KEY`` dependency carrying the + project's own includes:auto content. That key can never appear in the + manifest-derived intended set, and the real install re-deploys it + (drift.py replay guards ``local_path == _SELF_KEY``). Reporting it here + over-counts self-includes as removals in the dry-run preview. + """ + from apm_cli.deps.lockfile import _SELF_KEY + + self_dep = _LockedDep( + repo_url="", + source="local", + local_path=_SELF_KEY, + deployed_files=["self_a.md", "self_b.md"], + ) + orphan = _LockedDep(repo_url="owner/gone", deployed_files=["gone.md"]) + lf = _LockFile(dependencies={_SELF_KEY: self_dep, "owner/gone": orphan}) + result = detect_orphans(lf, set(), only_packages=[]) + # Only the genuinely dropped package is an orphan; self-includes are not. + self.assertEqual(result, {"gone.md"}) + + def test_self_include_survives_real_from_yaml_roundtrip(self): + """End-to-end guard using the real LockFile.from_yaml synthesis. + + The stub-based test above could drift from how from_yaml actually + synthesizes the self-entry. This exercises the real parse -> detect + path so a change to either side (self-key value, synthesis shape) + that reintroduces the dry-run false alarm fails here. from_yaml is a + pure string parse -- no I/O. + """ + from apm_cli.deps.lockfile import LockFile + + lock = LockFile.from_yaml( + "lockfile_version: '2'\n" + "generated_at: ''\n" + "dependencies:\n" + " - repo_url: owner/kept\n" + " source: git\n" + " deployed_files: ['.apm/x.md']\n" + "local_deployed_files:\n" + " - .claude/skills/self/SKILL.md\n" + ) + # Manifest now empty -> only the real dropped package is an orphan; + # the synthesized self-entry's includes are never reported. + result = detect_orphans(lock, set(), only_packages=[]) + self.assertEqual(result, {".apm/x.md"}) + # --------------------------------------------------------------------------- # detect_config_drift