perf(resolver): tiered git ref resolver collapses redundant clones (#1369)#1376
Conversation
…1369) Issue #1369: `apm update -y -v` ran the integrate loop serially, calling `downloader.resolve_git_reference(dep_ref)` per-dep. Each call did `mkdtemp` + shallow `git clone --depth=1` with ZERO memoization. A 9-dep manifest pointing at 3 unique (repo, ref) tuples produced 9 clones. On Windows + Defender + a slow ADO endpoint that scaled to 1583s of integrate time. Fix: introduce `TieredRefResolver` with a four-tier waterfall: L0 PerRunCache - in-memory {(url, ref): sha}, zero I/O L1 CommitsAPI - GitHub commits API, ~1 RTT, Accept: vnd.github.sha L2 BareRevParse - rev-parse against an existing GitCache bare, zero net L3 LegacyClone - delegates to existing GitReferenceResolver.resolve Wired into `InstallContext` so every command path that builds a downloader (install, update, outdated, publish) picks it up via `downloader._tiered_resolver`. Coalesce lock around (url, ref) ensures concurrent resolves of the same key block on one in-flight resolution (safe for outdated's ThreadPoolExecutor and any future parallel integrate). Reuses every existing perf primitive APM already ships (HttpCache, GitCache, host_backends, _resilient_get, AuthResolver). Adds only the orchestrator + per-run cache. Feature flag: `APM_TIERED_RESOLVER=0` disables the stack as an emergency rollback. Default ON. Validation: - 33 unit tests (per-tier isolation + orchestrator behaviour) - 3 integration tests (9-dep/3-unique workload asserts <=3 underlying clones) - 3 benchmarks (asserts >=2.5x speedup on synthetic workload) - 940 install + deps unit tests pass with no regressions - lint clean (ruff check + format check) Closes #1369 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 3 | 1 | Well-structured Chain of Responsibility with correct coalescing; monkey-patched attachment and duplicated factory wiring are the main architectural debts. |
| CLI Logging Expert | 0 | 2 | 1 | All new output is debug-only; no user-visible noise introduced but the perf win is invisible -- add a verbose-mode stats summary. |
| DevX UX Expert | 0 | 1 | 2 | Perf win is UX-positive; env flag APM_TIERED_RESOLVER lacks discoverability in environment-variables.md and CHANGELOG. |
| Supply Chain Security | 0 | 3 | 1 | No security regressions; L0 cache key uses raw repo_url (safe -- misses, never poisons); recommend normalizing key and auditing ref_type heuristic. |
| OSS Growth Hacker | 0 | 1 | 2 | Strong perf-win story; CHANGELOG drift is the only blocker to amplification. |
| Auth Expert | 0 | 0 | 1 | All 7 auth questions SAFE. No new auth surface; only dead constructor params on L1CommitsAPI. |
| Doc Writer | 0 | 3 | 2 | Doc drift: no CHANGELOG entry for a major perf win, and the new APM_TIERED_RESOLVER flag is missing from the canonical env-vars reference. |
| Test Coverage Expert | 0 | 1 | 1 | Core regression trap (9 to 3 collapse) and facade/env-flag coverage are solid; outdated.py wiring path has no test exercising the new 27-line block. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
- [Doc Writer] Add
CHANGELOG.mdUnreleased entry for perf(resolver): tiered git ref resolver collapses redundant clones (#1369) #1376/[BUG] apm update very slow — long "integrate" phase #1369 and documentAPM_TIERED_RESOLVERinenvironment-variables.md-- Three-persona convergence (devx-ux, oss-growth, doc-writer). The env-vars page promises to be single source of truth; shipping a user-observable flag without it violates that contract. CHANGELOG is the adoption amplification surface. - [Test Coverage Expert] Add integration test for
outdated.pytiered-resolver wiring (27 LOC untested, bareexcept) -- Evidence: outcome=missing on a user-observable integration path. The install wiring is covered by e2e; the outdated wiring is not. Silent regression risk on a path with a bareexcept. - [Supply Chain Security] Fix TAG-vs-BRANCH
ref_typeheuristic in_build_resultto preserve integrate-phase cache eligibility for tag-pinned deps -- Correctness issue: tag-pinned deps lose integrate-phase caching (ref_typealways BRANCH). No security regression (SHA gated), but subtle perf regression and TOCTOU window expansion for tag workflows. - [Python Architect] Extract shared factory wiring into
wire_tiered_resolver()and declare_tiered_resolverattribute onGitHubPackageDownloader-- 28 LOC duplicated verbatim acrossresolve.pyandoutdated.py; monkey-patched attribute without declaration. Standard APM pattern: extract at 2+ call sites. - [CLI Logging Expert] Surface resolver stats in verbose mode:
Ref resolution: N lookups (X cached, Y API, Z legacy)-- The perf win is invisible to users.resolver.statsis populated but never rendered. Users debugging regressions or verifying the fix cannot confirm tiered resolver is helping.
Architecture
classDiagram
direction LR
class RefResolutionTier {
<<Protocol>>
+name str
+try_resolve(dep_ref, ref) str | None
}
class PerRunRefCache {
<<ValueObject>>
-_store dict
-_lock Lock
+get(url, ref) str | None
+put(url, ref, sha) None
}
class L0PerRunCache
class L1CommitsAPI
class L2BareRevParse
class L3LegacyClone
class TieredRefResolver {
<<Orchestrator>>
-_tiers list~RefResolutionTier~
-_cache PerRunRefCache
-_legacy L3LegacyClone
-_coalesce dict
-_coalesce_lock Lock
+stats dict
+resolve(repo_ref) ResolvedReference
}
class GitHubPackageDownloader
class InstallContext {
+ref_resolver Any
}
class GitReferenceResolver
RefResolutionTier <|.. L0PerRunCache
RefResolutionTier <|.. L1CommitsAPI
RefResolutionTier <|.. L2BareRevParse
RefResolutionTier <|.. L3LegacyClone
TieredRefResolver *-- PerRunRefCache : owns
TieredRefResolver *-- L3LegacyClone : fallback
TieredRefResolver o-- RefResolutionTier : dispatches
L1CommitsAPI ..> GitReferenceResolver : delegates
L3LegacyClone ..> GitReferenceResolver : wraps
GitHubPackageDownloader ..> TieredRefResolver : facade
InstallContext ..> TieredRefResolver : holds ref
class TieredRefResolver:::touched
class PerRunRefCache:::touched
class L0PerRunCache:::touched
class L1CommitsAPI:::touched
class L2BareRevParse:::touched
class L3LegacyClone:::touched
classDef touched fill:#fff3b0,stroke:#d47600
flowchart TD
A["apm install/update/outdated"] --> B{"resolve.py or outdated.py"}
B --> C["build_tiered_ref_resolver(downloader, git_cache, auth)"]
C --> D{"APM_TIERED_RESOLVER enabled?"}
D -- No --> E["Return None; legacy path"]
D -- Yes --> F["Construct TieredRefResolver L0-L3"]
F --> G["Assign to downloader._tiered_resolver"]
G --> H["downloader.resolve_git_reference(dep_ref)"]
H --> K["TieredRefResolver.resolve(dep_ref)"]
K --> N{"L0 cache hit?"}
N -- Hit --> O["Return cached SHA -- zero IO"]
N -- Miss --> P{"Coalesce: leader or follower?"}
P -- Follower --> Q["Event.wait then read cache"]
P -- Leader --> S["L1 CommitsAPI -- GET /commits/ref"]
S -- SHA --> T["Cache + return"]
S -- None --> U["L2 BareRevParse -- git rev-parse"]
U -- SHA --> T
U -- None --> V["L3 LegacyClone -- shallow clone"]
V --> T
T --> W["Event.set -- unblock followers"]
sequenceDiagram
participant CLI as apm update
participant DL as Downloader
participant TR as TieredRefResolver
participant L0 as L0 PerRunCache
participant L1 as L1 CommitsAPI
participant L3 as L3 LegacyClone
loop For each dep (9 deps, 3 unique)
CLI->>DL: resolve_git_reference(dep)
DL->>TR: resolve(dep)
TR->>L0: try_resolve(dep, ref)
alt Cache HIT (6 of 9)
L0-->>TR: cached SHA
else Cache MISS (3 of 9)
L0-->>TR: None
TR->>L1: try_resolve(dep, ref)
alt API success
L1-->>TR: SHA
else API miss
TR->>L3: try_resolve via shallow clone
L3-->>TR: SHA
end
TR->>L0: cache.put(url, ref, sha)
end
DL-->>CLI: ResolvedReference
end
Recommendation
Merge after adding the CHANGELOG entry and env-vars doc row (in-PR, not post-merge -- this is the adoption amplification surface and the reporter handshake). File follow-up issues for the outdated.py wiring test and the TAG-vs-BRANCH heuristic before the next release. The core architecture is sound, tests are green, and the env-flag escape hatch makes rollback a one-liner.
Full per-persona findings
Python Architect
- [recommended] Undeclared
_tiered_resolverattribute monkey-patched ontoGitHubPackageDownloaderatsrc/apm_cli/deps/github_downloader.py:634
Reads viagetattrbut never declared on the class.shared_clone_cachedemonstrates the correct pattern (declare in__init__).
Suggested: Addself._tiered_resolver: TieredRefResolver | None = Nonein__init__with TYPE_CHECKING import. - [recommended] Factory wiring duplicated verbatim between
resolve.pyandoutdated.py(28 LOC each) atsrc/apm_cli/commands/outdated.py:331
APM pattern: extract when shared (2+ call sites). If a third command needs the wiring or the factory changes, this diverges silently.
Suggested: Extractwire_tiered_resolver(downloader, git_cache=None, auth_resolver=None)intiered_ref_resolver.py. - [recommended] L1 and L2 tiers reach into private internals (
_refs,_db_root) atsrc/apm_cli/deps/tiered_ref_resolver.py:253
L1 accesseshost._refs; L2 accessesgit_cache._db_root. Collaborator renames break tiers silently.
Suggested: L1: acceptlegacy_resolverin constructor. L2: add publicGitCache.bare_clone_path(url)method. - [nit]
ctx.ref_resolvertyped asAnyinstead of concrete type atsrc/apm_cli/install/context.py:77
TYPE_CHECKING import would preserve type safety without circular imports.
CLI Logging Expert
- [recommended] Perf win is invisible to users -- no verbose summary surfaces cache/tier stats at
src/apm_cli/install/phases/finalize.py
resolver.statsis populated but nothing reads it for CLI output.apm install -vcannot tell whether the tiered resolver helped.
Suggested: Addverbose_detailwith 'Ref resolution: N lookups (X cached, Y API, Z legacy)' after existing verbose blocks. - [recommended]
APM_TIERED_RESOLVER=0disable produces no user-visible signal atsrc/apm_cli/deps/tiered_ref_resolver.py:477
Only a_log.debugline; users debugging regressions cannot confirm legacy mode is active.
Suggested: Emit verbose-level message at wiring site when flag is off. - [nit] Debug line includes
repo_url; defense-in-depth sanitize atsrc/apm_cli/deps/tiered_ref_resolver.py:441
DependencyReference.repo_urltypically clean, but asanitize_url()pass on token-redaction is good defense.
DevX UX Expert
- [recommended]
APM_TIERED_RESOLVERenv flag not documented inenvironment-variables.mdatdocs/src/content/docs/reference/environment-variables.md
APM_NO_CACHEis documented;APM_TIERED_RESOLVERis the emergency rollback for a major behavioral change yet undiscoverable from docs.
Suggested: Add row under "Experimental and internal" table. - [nit]
CHANGELOGshould mention env flag as rollback escape hatch atCHANGELOG.md
Users scanning release notes for breaking-change mitigations look at CHANGELOG. - [nit] No user-visible progress hint under verbose mode during resolution at
src/apm_cli/deps/tiered_ref_resolver.py
All tier logging is DEBUG; if L3 legacy kicks in on slow network, user sees the same silent pause as before.
Supply Chain Security Expert
- [recommended] L0
PerRunRefCachekey uses rawdep_ref.repo_urlwithout URL normalization atsrc/apm_cli/deps/tiered_ref_resolver.py:135
Unlike L2 (cache_shard_key), L0 treats case-different URLs as distinct entries. Safe (misses, no poisoning) but future evolution risk.
Suggested: Usecache_shard_keyfor L0 keys in bothget()andput(). - [recommended]
_build_resultalways returns BRANCH for non-SHA refs -- loses TAG distinction atsrc/apm_cli/deps/tiered_ref_resolver.py:432
integrate.py:77caches downloads only whenref_typeis TAG or COMMIT. Tag-pinned deps become non-cacheable in integrate phase. Performance regression + TOCTOU window expansion (lockfile SHA still gates integrity).
Suggested: Check ref against tag-like pattern or have L2 return which candidate matched (refs/headsvsrefs/tags). - [recommended] Exception messages logged at debug may include auth tokens if upstream HTTP errors stringify request headers at
src/apm_cli/deps/tiered_ref_resolver.py:262
Some HTTP libraries include URL/headers in error reprs. Header-based Bearer auth makes this unlikely but debug logs with-vare user-facing.
Suggested: Log exception type only:type(exc).__name__. - [nit] Document in
security.mdthat tiered resolver is perf-only; integrity still enforced by lockfile SHA atdocs/src/content/docs/enterprise/security.md
Defaults ON; brief note prevents future auditors from re-deriving this.
OSS Growth Hacker
- [recommended]
CHANGELOG.mdUnreleased section has zero mention of this PR atCHANGELOG.md
A 1583s -> seconds Windows+ADO fix is a tweetable perf win. Without a changelog entry, no release-notes generator or social card surfaces it.
Suggested: Add entry under[Unreleased] -> ### Fixedfor [BUG] apm update very slow — long "integrate" phase #1369/perf(resolver): tiered git ref resolver collapses redundant clones (#1369) #1376 with rollback flag mention. - [nit] Story angle: "apm install/update is now uv-fast" is the natural hook
Tying to uv's well-known perf positioning maps to a known mental model for npm/pip users evaluating apm. - [nit] Competitive framing: collapse 9 clones to 3 underlying resolves is a one-liner worth keeping
This is the kind of concrete number that lands in blog posts and conf talks.
Auth Expert
- [nit] Dead
_auth_resolverand_http_cacheconstructor params onL1CommitsAPIatsrc/apm_cli/deps/tiered_ref_resolver.py:164
Constructor accepts these but the body never reads them (delegates to underlyingGitReferenceResolverwhich owns auth). Cosmetic cleanup; remove or wire.
Doc Writer
- [recommended] Missing
CHANGELOG.mdUnreleased entry for perf(resolver): tiered git ref resolver collapses redundant clones (#1369) #1376 / [BUG] apm update very slow — long "integrate" phase #1369 perf fix atCHANGELOG.md
changelog.instructions.mdis explicit: every merged PR must have a changelog entry. Silent perf changes also make rollback harder.
Suggested: Add a one-line entry under[Unreleased] -> ### Fixedincluding theAPM_TIERED_RESOLVER=0rollback mention. - [recommended]
APM_TIERED_RESOLVERmissing fromdocs/src/content/docs/reference/environment-variables.md(claims to be single source of truth) atdocs/src/content/docs/reference/environment-variables.md:82
The page's opening line is "Single source of truth for every environment variable APM reads." Shipping a new user-observable flag without adding it violates the page's own contract.
Suggested: Add row to "Experimental and internal" table forAPM_TIERED_RESOLVER. - [recommended]
packages/apm-guide/.apm/skills/apm-usage/troubleshooting.mdshould gain a row for slow-update Windows+ADO failure mode atpackages/apm-guide/.apm/skills/apm-usage/troubleshooting.md
copilot-instructions.mdRule 4: changes that affect CLI behaviour should update the apm-usage skill so any APM-using agent can teach the new behaviour. - [nit]
install-failures.mdsection 4 could cross-linkAPM_TIERED_RESOLVERalongsideAPM_NO_CACHEatdocs/src/content/docs/troubleshooting/install-failures.md
Polish, not a gap; canonical home is env-vars reference. - [nit]
README.mdunaffected; no edit needed
Only related phrase is generic "in seconds" positioning, not contradicted.
Test Coverage Expert
- [recommended]
outdated.pytiered-resolver wiring block (27 LOC) has no test atsrc/apm_cli/commands/outdated.py:332
Cross-module integration path with bareexcept Exception: pass. Installresolve.pywiring is exercised by e2e tests; outdated has no equivalent.
Suggested: Addtests/integration/marketplace/test_outdated_integration.pytest assertingdownloader._tiered_resolver is not Noneafter wiring.
Proof (missing at):tests/integration/marketplace/test_outdated_integration.py::test_outdated_wires_tiered_resolver_when_enabled-- proves: apm outdated benefits from tiered resolver and wiring does not silently regress [devx]
assert hasattr(downloader, '_tiered_resolver') and downloader._tiered_resolver is not None - [nit] Coalesce-lock test uses 2 threads; no ThreadPoolExecutor-scale parametrized test at
tests/unit/deps/test_tiered_ref_resolver.py:233
2-thread test passes; outdated uses N-worker pool. Standardthreading.Eventpattern -- unit tier acceptable.
Proof (passed):tests/unit/deps/test_tiered_ref_resolver.py::test_orchestrator_collapses_concurrent_resolves-- proves: Concurrent resolves coalesce into one underlying tier call [devx]
assert call_count[0] == 1; assert resolver.stats['coalesced'] >= 1
This panel is advisory. It does not block merge. Re-apply the
panel-review label after addressing feedback to re-run.
There was a problem hiding this comment.
Pull request overview
This PR introduces a new tiered git ref resolution stack to eliminate redundant git clone --depth=1 calls when multiple dependencies share the same (repo, ref) during a single run (issue #1369), and wires it into the downloader so install, update, and outdated can benefit.
Changes:
- Add
TieredRefResolverwith L0 per-run memoization and additional tiers (commits API, barerev-parse, legacy clone fallback). - Wire the tiered resolver into the install resolve phase and the
outdatedcommand; routeGitHubPackageDownloader.resolve_git_reference()through it when attached. - Add unit, integration, and benchmark coverage validating call-count collapse and speedup.
Show a summary per file
| File | Description |
|---|---|
src/apm_cli/deps/tiered_ref_resolver.py |
New tiered resolver implementation with per-run cache, tier protocol, and factory/feature flag. |
src/apm_cli/install/phases/resolve.py |
Attaches the tiered resolver after persistent git cache setup. |
src/apm_cli/deps/github_downloader.py |
Routes resolve_git_reference() through the tiered resolver when available. |
src/apm_cli/commands/outdated.py |
Wires tiered resolver for outdated, including optional persistent git cache. |
src/apm_cli/install/context.py |
Adds ref_resolver field to keep resolver on the install context. |
tests/unit/deps/test_tiered_ref_resolver.py |
Unit tests for tiers, orchestrator coalescing/stats, factory, and feature flag. |
tests/integration/test_tiered_resolver_integration.py |
Integration-level assertion that 9 deps / 3 unique refs collapses to 3 underlying resolves. |
tests/benchmarks/test_tiered_resolver_benchmarks.py |
Benchmark-style tests asserting deterministic call-count reduction and speedup ratio. |
Copilot's findings
Comments suppressed due to low confidence (2)
src/apm_cli/deps/tiered_ref_resolver.py:292
- Missing type hints for new public code: legacy_resolver is untyped here. Please annotate this parameter (and consider a narrow Protocol/type alias for the legacy resolver surface) to match the repo's typed style expectations for new modules.
name = "legacy_clone"
def __init__(self, legacy_resolver) -> None:
self._legacy = legacy_resolver
src/apm_cli/deps/tiered_ref_resolver.py:466
- build_tiered_ref_resolver(): the downloader parameter is untyped, which makes the factory harder to use safely and weakens type checking for the resolver wiring. Consider typing it (even as a Protocol capturing the required attributes like _refs and auth_resolver) to keep this new API surface fully annotated.
def build_tiered_ref_resolver(
*,
downloader,
http_cache: HttpCache | None = None,
git_cache: GitCache | None = None,
auth_resolver: AuthResolver | None = None,
- Files reviewed: 8/8 changed files
- Comments generated: 5
| Reuses :meth:`GitReferenceResolver.resolve_commit_sha_for_ref` -- the | ||
| cheap-path helper that already does the dispatch through | ||
| ``host_backends.build_commits_api_url`` and ``host._resilient_get``. | ||
| Adds an :class:`HttpCache` ETag pass when the request is | ||
| UNauthenticated (per ``http_cache`` auth-scoping rule, line 16-19: | ||
| cached responses must not leak across auth identities). | ||
| """ |
| @staticmethod | ||
| def _rev_parse(bare_dir, ref: str) -> str | None: | ||
| import subprocess |
| except Exception: # pragma: no cover - defensive: never block resolve phase | ||
| pass |
| except Exception: # pragma: no cover - never block outdated on resolver wiring | ||
| pass |
| def is_tiered_resolver_enabled() -> bool: | ||
| """Read the ``APM_TIERED_RESOLVER`` env flag. Default ON. | ||
|
|
||
| Set to ``0``/``false``/``no`` to disable the tiered stack and force | ||
| every resolution through the legacy clone path. Useful as an | ||
| emergency rollback without redeploying. | ||
| """ | ||
| val = os.environ.get("APM_TIERED_RESOLVER", "1").strip().lower() | ||
| return val not in ("0", "false", "no", "off", "") |
Address worthwhile review comments from Copilot reviewer and the APM review panel (folded in-PR per CEO ship_with_followups guidance): - CHANGELOG: add [Unreleased] -> Fixed entry for #1369 (CEO mandate; doc-writer + devx-ux + oss-growth converged on the gap). - docs/reference/environment-variables.md: document APM_TIERED_RESOLVER in the 'Experimental and internal' table so the emergency rollback flag is discoverable. - tiered_ref_resolver.py: drop dead http_cache / auth_resolver constructor params on L1CommitsAPI (and the matching kwargs on build_tiered_ref_resolver). The L1 tier delegates to the legacy resolve_commit_sha_for_ref helper which owns its own auth; the fields were stored but never read. Docstring updated to match. - tiered_ref_resolver.py: add missing type hints (L2BareRevParse._rev_parse bare_dir: Path, L3LegacyClone legacy_resolver: GitReferenceResolver, factory downloader: GitHubPackageDownloader) per Copilot reviewer. - github_downloader.py: declare self._tiered_resolver = None in __init__ instead of relying on a monkey-patched attribute set externally; resolves the python-architect 'undeclared attribute' finding while keeping the downloader -> resolver dependency optional. - resolve.py / outdated.py: replace 'except Exception: pass' with debug-level logging of exception type + message so resolver wiring failures are diagnosable under --verbose without changing the never-block-resolve contract. All 36 tiered-resolver tests still pass. Lint clean (ruff check + format --check both silent). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- One concise line per PR answering 'so what?' for end users - Add 5 missing entries: #1376 (perf resolver), #1373 (shared/apm.md matrix secret-stripping), #1246 (install.ps1 GHES env vars), #1255 (warn missing apm.yml), #1248 (extends:org unmanaged_files) - Drop internal/CI/test-infra entries (#1270, #1271, #1272, #1274, #1276, #1291, #1360 refactor) - Consolidate three #605 lines and four #1317 lines into one entry per PR where appropriate - Promote MCP Registry v0.1 to a dedicated Breaking section Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* chore: cut 0.14.0 Renames the [Unreleased] block in CHANGELOG.md to [0.14.0] - 2026-05-18 and bumps the package version from 0.13.0 to 0.14.0 in pyproject.toml (and uv.lock by regeneration). 0.14.0 ships the producer-experience epic (#1348) on the CLI side -- notably: - apm pack --check-versions / --check-clean (#1365), the release gates consumed by apm-action mode: release. - apm plugin init (#1370), the noun-verb successor to apm init --plugin. - apm pack multi-format outputs (--marketplace, --marketplace-path, --json, marketplace.outputs map form) (#1317). - New producer docs corpus (repo-shapes / releasing-from-any-ci / versioning-strategies) (#1370). - Breaking: MCP registry client adopts the official v0.1 spec; self- hosted registries must serve /v0.1/ paths (#1337). Plus the deprecations and fixes already listed in the moved block. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(changelog): tighten v0.14.0 entries; add post-cut PRs - One concise line per PR answering 'so what?' for end users - Add 5 missing entries: #1376 (perf resolver), #1373 (shared/apm.md matrix secret-stripping), #1246 (install.ps1 GHES env vars), #1255 (warn missing apm.yml), #1248 (extends:org unmanaged_files) - Drop internal/CI/test-infra entries (#1270, #1271, #1272, #1274, #1276, #1291, #1360 refactor) - Consolidate three #605 lines and four #1317 lines into one entry per PR where appropriate - Promote MCP Registry v0.1 to a dedicated Breaking section Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(changelog): add #1377 Bitbucket DC tilde fix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Daniel Meppiel <copilot-rework@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…1369) (#1376) * perf(resolver): tiered git ref resolver collapses redundant clones (#1369) Issue #1369: `apm update -y -v` ran the integrate loop serially, calling `downloader.resolve_git_reference(dep_ref)` per-dep. Each call did `mkdtemp` + shallow `git clone --depth=1` with ZERO memoization. A 9-dep manifest pointing at 3 unique (repo, ref) tuples produced 9 clones. On Windows + Defender + a slow ADO endpoint that scaled to 1583s of integrate time. Fix: introduce `TieredRefResolver` with a four-tier waterfall: L0 PerRunCache - in-memory {(url, ref): sha}, zero I/O L1 CommitsAPI - GitHub commits API, ~1 RTT, Accept: vnd.github.sha L2 BareRevParse - rev-parse against an existing GitCache bare, zero net L3 LegacyClone - delegates to existing GitReferenceResolver.resolve Wired into `InstallContext` so every command path that builds a downloader (install, update, outdated, publish) picks it up via `downloader._tiered_resolver`. Coalesce lock around (url, ref) ensures concurrent resolves of the same key block on one in-flight resolution (safe for outdated's ThreadPoolExecutor and any future parallel integrate). Reuses every existing perf primitive APM already ships (HttpCache, GitCache, host_backends, _resilient_get, AuthResolver). Adds only the orchestrator + per-run cache. Feature flag: `APM_TIERED_RESOLVER=0` disables the stack as an emergency rollback. Default ON. Validation: - 33 unit tests (per-tier isolation + orchestrator behaviour) - 3 integration tests (9-dep/3-unique workload asserts <=3 underlying clones) - 3 benchmarks (asserts >=2.5x speedup on synthetic workload) - 940 install + deps unit tests pass with no regressions - lint clean (ruff check + format check) Closes #1369 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(resolver): fold review feedback (#1369) Address worthwhile review comments from Copilot reviewer and the APM review panel (folded in-PR per CEO ship_with_followups guidance): - CHANGELOG: add [Unreleased] -> Fixed entry for #1369 (CEO mandate; doc-writer + devx-ux + oss-growth converged on the gap). - docs/reference/environment-variables.md: document APM_TIERED_RESOLVER in the 'Experimental and internal' table so the emergency rollback flag is discoverable. - tiered_ref_resolver.py: drop dead http_cache / auth_resolver constructor params on L1CommitsAPI (and the matching kwargs on build_tiered_ref_resolver). The L1 tier delegates to the legacy resolve_commit_sha_for_ref helper which owns its own auth; the fields were stored but never read. Docstring updated to match. - tiered_ref_resolver.py: add missing type hints (L2BareRevParse._rev_parse bare_dir: Path, L3LegacyClone legacy_resolver: GitReferenceResolver, factory downloader: GitHubPackageDownloader) per Copilot reviewer. - github_downloader.py: declare self._tiered_resolver = None in __init__ instead of relying on a monkey-patched attribute set externally; resolves the python-architect 'undeclared attribute' finding while keeping the downloader -> resolver dependency optional. - resolve.py / outdated.py: replace 'except Exception: pass' with debug-level logging of exception type + message so resolver wiring failures are diagnosable under --verbose without changing the never-block-resolve contract. All 36 tiered-resolver tests still pass. Lint clean (ruff check + format --check both silent). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Daniel Meppiel <copilot-rework@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* chore: cut 0.14.0 Renames the [Unreleased] block in CHANGELOG.md to [0.14.0] - 2026-05-18 and bumps the package version from 0.13.0 to 0.14.0 in pyproject.toml (and uv.lock by regeneration). 0.14.0 ships the producer-experience epic (#1348) on the CLI side -- notably: - apm pack --check-versions / --check-clean (#1365), the release gates consumed by apm-action mode: release. - apm plugin init (#1370), the noun-verb successor to apm init --plugin. - apm pack multi-format outputs (--marketplace, --marketplace-path, --json, marketplace.outputs map form) (#1317). - New producer docs corpus (repo-shapes / releasing-from-any-ci / versioning-strategies) (#1370). - Breaking: MCP registry client adopts the official v0.1 spec; self- hosted registries must serve /v0.1/ paths (#1337). Plus the deprecations and fixes already listed in the moved block. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(changelog): tighten v0.14.0 entries; add post-cut PRs - One concise line per PR answering 'so what?' for end users - Add 5 missing entries: #1376 (perf resolver), #1373 (shared/apm.md matrix secret-stripping), #1246 (install.ps1 GHES env vars), #1255 (warn missing apm.yml), #1248 (extends:org unmanaged_files) - Drop internal/CI/test-infra entries (#1270, #1271, #1272, #1274, #1276, #1291, #1360 refactor) - Consolidate three #605 lines and four #1317 lines into one entry per PR where appropriate - Promote MCP Registry v0.1 to a dedicated Breaking section Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(changelog): add #1377 Bitbucket DC tilde fix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Daniel Meppiel <copilot-rework@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ules During the rebase from main, two pieces of logic landed in stubs rather than the decomposed modules: 1. TieredRefResolver delegation (#1369/#1376): the _tiered_resolver attribute and fast-path in resolve_git_reference() was added to the old monolithic github_downloader.py on main. Relocate to the new split: class_.py (__init__ declaration) and transport_plan.py (delegation in resolve_git_reference). 2. Shared validation helper (#1360): download_package_ops.py still had the inline validation block instead of delegating to deps/_shared.py::_validate_and_load_package (the de-duplication from main's ced3ef6). Both fixes are behavioural no-ops (all 8741 unit tests pass). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
TL;DR
apm updateran the integrate loop serially, callingdownloader.resolve_git_reference()per dep with zero memoization. A 9-dep manifest pointing at 3 unique(repo, ref)tuples produced 9 shallowgit clone --depth=1calls; on Windows + Defender + a slow ADO endpoint that scaled to 1583s. This PR introduces a four-tierTieredRefResolver(L0 in-memory cache → L1 GitHub commits API → L2 barerev-parse→ L3 legacy clone) wired throughInstallContextso install, update, outdated, and publish all benefit. Closes #1369.Problem (WHY)
downloader.resolve_git_reference(dep_ref)didmkdtemp+git clone --depth=1into a fresh temp dir. Duplicate(repo, ref)tuples were re-cloned from scratch.HttpCache(ETag),GitCache(bare repos),host_backends.build_commits_api_url,_resilient_get(HTTP + retries), andAuthResolver— none were reachable from the resolve path.mkdtemp+ Defender scan + ADO RTT made the per-call cost two orders of magnitude above a single commits-API request.Approach (WHAT)
Four-tier waterfall executed by a single new
TieredRefResolver. Each tier returns the SHA orNoneto fall through. Reuses every existing perf primitive APM already ships; adds only the orchestrator + per-run cache.{(url, ref): sha}GET /repos/.../commits/{ref}withAccept: application/vnd.github.sha, reusesGitReferenceResolver.resolve_commit_sha_for_refgit rev-parse refs/heads/REFagainst an existingGitCachebareGitReferenceResolver.resolve()Note
Feature flag
APM_TIERED_RESOLVER=0disables the entire stack as an emergency rollback. Default ON.Implementation (HOW)
src/apm_cli/deps/tiered_ref_resolver.pyPerRunRefCache,RefResolutionTierProtocol, four tier classes,TieredRefResolverorchestrator (coalesce lock + stats),build_tiered_ref_resolverfactory,is_tiered_resolver_enabledenv flag.src/apm_cli/install/context.pyref_resolver: Any = Nonefield for resolve-phase ownership.src/apm_cli/install/phases/resolve.pypersistent_git_cachesetup; assigns to bothctx.ref_resolveranddownloader._tiered_resolver.src/apm_cli/deps/github_downloader.pyresolve_git_reference()checks_tiered_resolverfirst; falls through to legacy_refs.resolve()if unset.src/apm_cli/commands/outdated.pyThreadPoolExecutorruns (ownGitCachesince outdated doesn't pass through resolve phase).tests/unit/deps/test_tiered_ref_resolver.pytests/integration/test_tiered_resolver_integration.pytests/benchmarks/test_tiered_resolver_benchmarks.pyConcurrency
TieredRefResolverholdsdict[(url, ref) -> threading.Event]. Concurrentresolve()calls for the same key: leader runs the tier waterfall + populates L0 + signals event; waiters block then read from L0. Safe foroutdated's ThreadPoolExecutor and any future parallel integrate.Diagrams
Component view — where the new module plugs into existing infra (dotted = reuse, no changes):
flowchart TB subgraph "Install pipeline" RP[resolve phase] -->|owns lifecycle| CTX[InstallContext] CTX --> DL[GitHubPackageDownloader] INT[integrate phase] --> DL end subgraph "Existing infra (reused)" HC[(HttpCache ETag)] GC[(GitCache bare)] HB[host_backends] AR[AuthResolver] end subgraph "NEW: tiered resolver" TRR[TieredRefResolver] L0[L0 PerRunCache] L1[L1 CommitsAPI] L2[L2 BareRevParse] L3[L3 LegacyClone] TRR --> L0 TRR --> L1 TRR --> L2 TRR --> L3 end L1 -.-> HC L1 -.-> HB L1 -.-> AR L2 -.-> GC L3 -.delegates.-> LGR[GitReferenceResolver existing] RP -->|factory| TRR DL -."optional _tiered_resolver".-> TRRCold + warm path in the same run — the 9-clone problem collapses to 1 cheap API call plus 8 zero-I/O cache hits:
sequenceDiagram participant INT as integrate loop participant DL as GitHubPackageDownloader participant TRR as TieredRefResolver participant L0 as L0 cache participant L1 as L1 commits API participant API as GitHub commits API Note over INT: dep1: awesome/copilot@main INT->>DL: resolve_git_reference(dep1) DL->>TRR: resolve(dep1) TRR->>L0: get(url, "main") L0-->>TRR: None TRR->>L1: try_resolve(...) L1->>API: GET /repos/.../commits/main API-->>L1: 200 "abc123..." L1-->>TRR: "abc123..." TRR->>L0: put(url, "main", "abc123...") TRR-->>DL: ResolvedReference Note over INT: dep2..dep5: same repo+ref loop 4 more deps INT->>DL: resolve_git_reference(depN) DL->>TRR: resolve(depN) TRR->>L0: get(url, "main") L0-->>TRR: "abc123..." (HIT, zero I/O) TRR-->>DL: ResolvedReference endMulti-command reuse — every command path that builds a downloader picks up the resolver:
flowchart LR CMD_I[apm install] --> FAC[resolve.py factory] CMD_U[apm update] --> FAC CMD_O[apm outdated] --> FAC2[outdated.py factory] CMD_P[apm publish] -.uses downloader.-> FAC2 FAC --> TRR[TieredRefResolver] FAC2 --> TRRTrade-offs
ref_typeheuristic. Cheap-API tiers cannot distinguish branch vs tag (one SHA-returning call can't tell you which container the ref lives in). We pickCOMMITwhen input matches SHA regex, elseBRANCH. Behaviour-safe becauseintegrate.py:77cache eligibility treats branch and tag identically when the SHA matches the lockfile. Tag/branch precision would require a second call per ref — rejected as not worth the latency.http_cacheauth-scoping rule, cached responses must not leak across auth identities. The cheap-API helper already handles this; L1 inherits the constraint. Adding an auth-scoped ETag store is a follow-up.build_commits_api_urlreturnsNonefor ADO today; L1 gracefully falls through to L2/L3. ADO users still get L0 dedup and L2 bare-rev-parse on second runs.Benefits
HttpCache,GitCache,host_backends,_resilient_get,AuthResolver. The architectural reuse pattern (single factory wired intoInstallContext) means future commands inherit the speedup for free.outdated's threadpool and any future parallel integrate.APM_TIERED_RESOLVER=0disables the entire stack; legacy path runs unchanged.Validation
Lint clean (CI-mirror):
Test suites:
Benchmark detail
The
test_speedup_ratio_meets_three_x_targettest sleeps 30ms per simulated clone (the per-call cost asymptote on slow ADO is far higher) and assertslegacy_elapsed / tiered_elapsed >= 2.5. On real #1369 workloads — 1583s → low-RTT cheap API calls — the ratio is orders of magnitude larger.Scenario Evidence
apm updateon a manifest with duplicate(repo, ref)deps must not re-clonetests/integration/test_tiered_resolver_integration.py::test_nine_deps_three_unique_collapses_to_three_clonestests/integration/test_tiered_resolver_integration.py::test_tiered_resolver_attached_via_downloader_facadetests/integration/test_tiered_resolver_integration.py::test_tiered_resolver_disabled_via_envtests/benchmarks/test_tiered_resolver_benchmarks.py::test_speedup_ratio_meets_three_x_targettests/unit/deps/test_tiered_ref_resolver.py(33 tests)How to test
uv run --extra dev pytest tests/unit/deps/test_tiered_ref_resolver.py tests/integration/test_tiered_resolver_integration.py— 36 passuv run --extra dev pytest tests/benchmarks/test_tiered_resolver_benchmarks.py -m benchmark— 3 pass, speedup gate holdsuv run --extra dev ruff check src/ tests/ && uv run --extra dev ruff format --check src/ tests/— silent(repo, ref)deps, runapm update -vand observe a single resolve per unique tuple in debug logs (TieredRefResolver: ... -> ... (via per_run_cache)lines for duplicates)APM_TIERED_RESOLVER=0and re-run; observe legacy path runs unchangedCo-authored-by: Copilot 223556219+Copilot@users.noreply.github.com