Skip to content

Commit bd85d33

Browse files
fix: honor explicit global install refs (closes #1555) (#1559)
* fix: honor explicit install refs for existing deps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: clarify install ref update feedback Fold panel follow-ups for the explicit-ref install fix by making validation output distinguish an updated manifest entry from a no-op, using generic manifest persistence wording, and documenting the fix in the changelog. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: danielmeppiel <danielmeppiel@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 622cd75 commit bd85d33

7 files changed

Lines changed: 133 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12+
- `apm install -g <package>#<ref>` now updates an existing unpinned global dependency entry instead of leaving the manifest floating. (#1559)
1213
- `apm install` now honors manifest `targets:` without falling back to the legacy Copilot target when singular `target:` is absent. (#1560)
1314

1415
## [0.16.0] - 2026-05-28

src/apm_cli/commands/install.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,9 @@
4848
from apm_cli.install.package_resolution import (
4949
GIT_PARENT_USER_SCOPE_ERROR,
5050
dependency_reference_to_yaml_entry,
51-
merge_structured_entry_into_current_deps,
5251
persist_dependency_list_if_changed,
5352
resolve_parsed_dependency_reference,
53+
update_existing_dependency_entry_if_needed,
5454
user_scope_rejection_reason,
5555
)
5656

@@ -536,24 +536,24 @@ def warning_handler(msg):
536536
logger=logger,
537537
dep_ref=dep_ref,
538538
):
539+
updates_existing_entry = update_existing_dependency_entry_if_needed(
540+
current_deps,
541+
already_in_deps=already_in_deps,
542+
apm_yml_entries=_apm_yml_entries,
543+
canonical=canonical,
544+
dep_ref=dep_ref,
545+
identity=identity,
546+
dependency_reference_cls=DependencyReference,
547+
logger=logger,
548+
)
539549
valid_outcomes.append((canonical, already_in_deps))
540550
if logger:
541-
logger.validation_pass(canonical, already_present=already_in_deps)
551+
logger.validation_pass(canonical, already_in_deps, updates_existing_entry)
542552

543553
if not already_in_deps:
544554
validated_packages.append(canonical)
545555
existing_identities.add(identity) # prevent duplicates within batch
546-
elif canonical in _apm_yml_entries:
547-
structured_entry = _apm_yml_entries[canonical]
548-
merge_structured_entry_into_current_deps(
549-
current_deps,
550-
structured_entry,
551-
identity,
552-
canonical,
553-
dependency_reference_cls=DependencyReference,
554-
logger=logger,
555-
)
556-
dependencies_changed = True
556+
dependencies_changed = dependencies_changed or updates_existing_entry
557557
if marketplace_provenance:
558558
_marketplace_provenance[identity] = marketplace_provenance
559559
else:

src/apm_cli/core/command_logger.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,11 @@ def validation_start(self, count: int):
218218
noun = "package" if count == 1 else "packages"
219219
_rich_info(f"Validating {count} {noun}...", symbol="gear")
220220

221-
def validation_pass(self, canonical: str, already_present: bool):
221+
def validation_pass(self, canonical: str, already_present: bool, updated: bool = False):
222222
"""Log a package that passed validation."""
223-
if already_present:
223+
if updated:
224+
_rich_echo(f"{canonical} (updated ref in apm.yml)", color="dim", symbol="check")
225+
elif already_present:
224226
_rich_echo(f"{canonical} (already in apm.yml)", color="dim", symbol="check")
225227
else:
226228
_rich_success(canonical, symbol="check")

src/apm_cli/install/package_resolution.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,65 @@ def user_scope_rejection_reason(dep_ref: Any, scope: Any) -> str | None:
129129
return None
130130

131131

132+
def manifest_has_different_entry_for_identity(
133+
current_deps: builtins.list,
134+
identity: str,
135+
canonical: str,
136+
*,
137+
dependency_reference_cls: Any,
138+
) -> bool:
139+
"""Return True when apm.yml already has *identity* but not *canonical*."""
140+
for dep_entry in current_deps:
141+
try:
142+
if isinstance(dep_entry, builtins.str):
143+
existing_ref = dependency_reference_cls.parse(dep_entry)
144+
elif isinstance(dep_entry, builtins.dict):
145+
existing_ref = dependency_reference_cls.parse_from_dict(dep_entry)
146+
else:
147+
continue
148+
except (ValueError, TypeError, AttributeError, KeyError):
149+
continue
150+
if existing_ref.get_identity() == identity:
151+
return existing_ref.to_canonical() != canonical
152+
return False
153+
154+
155+
def update_existing_dependency_entry_if_needed(
156+
current_deps: builtins.list,
157+
*,
158+
already_in_deps: bool,
159+
apm_yml_entries: dict,
160+
canonical: str,
161+
dep_ref: Any,
162+
identity: str,
163+
dependency_reference_cls: Any,
164+
logger: Any = None,
165+
) -> bool:
166+
"""Rewrite an existing manifest dep when the requested ref changed."""
167+
should_update = already_in_deps and (
168+
canonical in apm_yml_entries
169+
or (
170+
dep_ref.reference
171+
and manifest_has_different_entry_for_identity(
172+
current_deps,
173+
identity,
174+
canonical,
175+
dependency_reference_cls=dependency_reference_cls,
176+
)
177+
)
178+
)
179+
if should_update:
180+
merge_structured_entry_into_current_deps(
181+
current_deps,
182+
apm_yml_entries.get(canonical, dep_ref.to_apm_yml_entry()),
183+
identity,
184+
canonical,
185+
dependency_reference_cls=dependency_reference_cls,
186+
logger=logger,
187+
)
188+
return should_update
189+
190+
132191
def merge_structured_entry_into_current_deps(
133192
current_deps: builtins.list,
134193
structured_entry: dict,
@@ -183,7 +242,7 @@ def persist_dependency_list_if_changed(
183242

184243
dump_yaml(data, apm_yml_path)
185244
if logger:
186-
logger.success(f"Updated {apm_yml_filename} to preserve marketplace subdirectory entry")
245+
logger.success(f"Updated {apm_yml_filename} dependency entries")
187246
except Exception as e:
188247
if logger:
189248
logger.error(f"Failed to write {apm_yml_filename}: {e}")

tests/unit/commands/test_install_resolve_refs.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from unittest.mock import MagicMock, patch
1616

1717
# The function under test lives in the commands module.
18-
from apm_cli.commands.install import _resolve_package_references
18+
from apm_cli.commands.install import _check_package_conflicts, _resolve_package_references
1919
from apm_cli.models.apm_package import APMPackage, clear_apm_yml_cache
2020
from apm_cli.models.dependency.reference import DependencyReference
2121

@@ -165,6 +165,24 @@ def test_mixed_new_and_preexisting(self, mock_dep_cls, mock_validate):
165165
assert "github.com/owner/new-pkg" in existing
166166
assert len(existing) == 2
167167

168+
@patch("apm_cli.commands.install._validate_package_exists", return_value=True)
169+
def test_existing_unpinned_dependency_is_updated_when_cli_supplies_ref(self, mock_validate):
170+
"""An explicit CLI ref for an existing dep must replace the unpinned manifest entry."""
171+
current_deps = ["danielmeppiel/genesis"]
172+
existing = _check_package_conflicts(current_deps)
173+
174+
valid, invalid, validated, _mkt, _entries, changed = _resolve_package_references(
175+
["danielmeppiel/genesis#v0.4.0"],
176+
current_deps,
177+
existing,
178+
)
179+
180+
assert invalid == []
181+
assert valid == [("danielmeppiel/genesis#v0.4.0", True)]
182+
assert validated == []
183+
assert changed is True
184+
assert current_deps == ["danielmeppiel/genesis#v0.4.0"]
185+
168186

169187
class TestResolvePackageReferencesInvalidInput:
170188
"""Invalid packages must not mutate the identity set."""
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Tests for dependency-list persistence messaging."""
2+
3+
from __future__ import annotations
4+
5+
from unittest.mock import Mock, patch
6+
7+
from apm_cli.install.package_resolution import persist_dependency_list_if_changed
8+
9+
10+
def test_persist_dependency_list_reports_generic_manifest_update():
11+
"""Manifest rewrites should not claim every change is marketplace-specific."""
12+
logger = Mock()
13+
data = {"dependencies": {"apm": []}}
14+
current_deps = ["danielmeppiel/genesis#v0.4.0"]
15+
16+
with patch("apm_cli.utils.yaml_io.dump_yaml") as dump_yaml:
17+
persist_dependency_list_if_changed(
18+
dependencies_changed=True,
19+
data=data,
20+
dep_section="dependencies",
21+
current_deps=current_deps,
22+
apm_yml_path="apm.yml",
23+
apm_yml_filename="apm.yml",
24+
logger=logger,
25+
rich_error=Mock(),
26+
sys_exit=Mock(),
27+
)
28+
29+
dump_yaml.assert_called_once_with(data, "apm.yml")
30+
logger.success.assert_called_once_with("Updated apm.yml dependency entries")

tests/unit/test_command_logger.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,12 @@ def test_validation_pass_existing(self, mock_echo):
197197
logger.validation_pass("microsoft/repo", already_present=True)
198198
assert "already in apm.yml" in mock_echo.call_args[0][0]
199199

200+
@patch("apm_cli.core.command_logger._rich_echo")
201+
def test_validation_pass_existing_updated(self, mock_echo):
202+
logger = InstallLogger()
203+
logger.validation_pass("microsoft/repo#v1", already_present=True, updated=True)
204+
assert "updated ref in apm.yml" in mock_echo.call_args[0][0]
205+
200206
@patch("apm_cli.core.command_logger._rich_error")
201207
def test_validation_fail(self, mock_error):
202208
logger = InstallLogger()

0 commit comments

Comments
 (0)