Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- `apm update` no longer reports a spurious update on every run for git-source
semver dependencies already at their locked tag. The git-semver resolver
rewrites the dep reference to the concrete tag but does not attach the
resolved SHA to `resolved_reference`, so the update plan compared the locked
commit against `None` and never converged. `build_update_plan` now borrows the
locked commit for cached, immutable-tag deps whose ref is unchanged, mirroring
the registry fix in #1908 (branch deps are unaffected -- their tips can still
advance under a stable ref name). (closes microsoft/apm#2163)

## [0.25.0] - 2026-07-12

### Added
Expand Down
18 changes: 18 additions & 0 deletions src/apm_cli/install/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,24 @@ def build_update_plan(
):
new_ref = old.resolved_ref

# Cached git-semver dep already at its locked tag: the resolver rewrote
# ``dep.reference`` to the concrete tag but stashed the resolved SHA in
# ``ctx.git_semver_resolutions`` without attaching it to
# ``dep.resolved_reference``, so ``new_commit`` is None here. Borrow the
# locked commit when the ref is unchanged AND the locked entry pins an
# immutable tag (``resolved_tag`` set) -- otherwise every ``apm update``
# would compare the locked SHA against None and emit a spurious UPDATE
# that never converges (git-source parity with the registry fix in
# #1908). Scoped to tags: a branch tip can advance under a stable ref
# name, so branch deps (no ``resolved_tag``) must still surface updates.
if (
new_commit is None
and old is not None
and getattr(old, "resolved_tag", None)
and new_ref == old.resolved_ref
):
new_commit = old.resolved_commit

if old is None:
plan_entries.append(
PlanEntry(
Expand Down
73 changes: 73 additions & 0 deletions tests/unit/install/test_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,79 @@ def test_source_transition_to_registry_is_not_masked_as_unchanged(self):
assert entry.new_resolved_ref != "v1.0.0"
assert entry.action == "update"

def test_git_semver_tag_dep_unchanged_when_cached_and_ref_matches(self):
"""A cached git-semver dep already at its locked tag must stay 'unchanged'.

Regression (git-source parity with #1908's registry fix): on ``apm update``
the git-semver resolver rewrites ``dep.reference`` to the concrete tag and
computes its SHA, but that SHA is stashed in ``ctx.git_semver_resolutions``
and never attached back to ``dep.resolved_reference``. So at plan time the
dep carries ``reference='eli5--v2.0.0'`` with ``resolved_reference=None``.
Without the locked-commit fallback, ``build_update_plan`` compares the
locked SHA against ``None`` and emits a spurious UPDATE on every run -- the
lockfile then rewrites the identical SHA, so ``apm update`` never converges.

The locked entry carries a concrete ``resolved_tag`` (immutable), so the
matching-ref case is safe to treat as unchanged.
"""
lock = _new_lockfile()
lock.add_dependency(
LockedDependency(
repo_url="srobroek/agentic-packages",
resolved_ref="eli5--v2.0.0",
resolved_commit="9" * 40,
depth=1,
is_virtual=True,
virtual_path="packages/eli5",
constraint=">=2.0.0 <3.0.0",
resolved_tag="eli5--v2.0.0",
)
)
# Cached git-semver dep: ref rewritten to the concrete tag by the resolver,
# but resolved_reference never populated (SHA not plumbed to the plan).
dep = DependencyReference(
repo_url="srobroek/agentic-packages",
reference="eli5--v2.0.0",
is_virtual=True,
virtual_path="packages/eli5",
)
assert getattr(dep, "resolved_reference", None) is None

plan = build_update_plan(lock, [dep])

assert plan.has_changes is False
entry = plan.entries[0]
assert entry.action == "unchanged"
assert entry.new_resolved_ref == "eli5--v2.0.0"
# The locked commit is borrowed so the display shows a real SHA, not '-'.
assert entry.new_resolved_commit == "9" * 40

def test_git_branch_dep_tip_advance_still_shows_update(self):
"""A branch dep whose tip advanced must NOT be masked as unchanged.

Guards the locked-commit fallback: it applies only to immutable tag refs
(locked entry carries ``resolved_tag``). A branch dep has no ``resolved_tag``
and its tip can move under a stable ref name, so a freshly-resolved commit
that differs from the lockfile must still surface as an update.
"""
lock = _new_lockfile()
lock.add_dependency(
LockedDependency(
repo_url="https://github.com/o/r",
resolved_ref="main",
resolved_commit="a" * 40,
depth=1,
)
)
# Branch dep, freshly resolved to a new tip SHA (no resolved_tag on lock).
deps = [_resolved_dep("https://github.com/o/r", "main", "b" * 40)]

plan = build_update_plan(lock, deps)

assert plan.has_changes is True
assert plan.entries[0].action == "update"
assert plan.entries[0].new_resolved_commit == "b" * 40


# -----------------------------------------------------------------------------
# render_plan_text
Expand Down