From eab4ef641635dc5a285e358fe68aa641f781393d Mon Sep 17 00:00:00 2001 From: Wenqing Gu Date: Wed, 22 Apr 2026 14:50:03 -0400 Subject: [PATCH 1/3] fix(install): scope local content scan to ~/.apm/ at user scope (#830) At user scope (`--global`), `project_root` is `Path.home()`. `integrate_local_content()` passed this directly as `install_path`, causing `discover_primitives()` to recursive-glob the entire home directory. This caused multi-minute hangs and triggered macOS privacy dialogs (Downloads, Desktop, etc.). Root cause: feature #626 (local `.apm/` auto-discovery, v0.8.12) was not tested with `-g` where `project_root = ~/` (feature #452, v0.8.6). Fix: when `scope is InstallScope.USER`, set `install_path` to `project_root / ".apm"` so only `~/.apm/` is scanned. Before: `apm install -g` hangs indefinitely After: `apm install -g` completes in ~4 seconds Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/install/services.py | 18 ++++++++++++++++-- tests/unit/test_local_content_install.py | 17 ++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/apm_cli/install/services.py b/src/apm_cli/install/services.py index 13211b154..5e6e062b6 100644 --- a/src/apm_cli/install/services.py +++ b/src/apm_cli/install/services.py @@ -193,15 +193,29 @@ def integrate_local_content( """ from ..models.apm_package import APMPackage, PackageInfo, PackageType + # For user-scope installs, project_root is Path.home(). The local + # package should only scan ~/.apm/ (the actual primitives directory), + # NOT the entire home directory -- otherwise discover_primitives() + # does a recursive glob of ~/ which hangs and triggers macOS privacy + # dialogs. See #626 + #452 interaction bug. + if scope is not None: + from ..core.scope import InstallScope + if scope is InstallScope.USER: + apm_dir = project_root / ".apm" + else: + apm_dir = project_root + else: + apm_dir = project_root + local_pkg = APMPackage( name="_local", version="0.0.0", - package_path=project_root, + package_path=apm_dir, source="local", ) local_info = PackageInfo( package=local_pkg, - install_path=project_root, + install_path=apm_dir, package_type=PackageType.APM_PACKAGE, ) diff --git a/tests/unit/test_local_content_install.py b/tests/unit/test_local_content_install.py index bf1ba4148..7e89cfa04 100644 --- a/tests/unit/test_local_content_install.py +++ b/tests/unit/test_local_content_install.py @@ -174,7 +174,7 @@ def test_skips_root_skill_md(self, mock_integrate, tmp_path): @patch("apm_cli.install.services.integrate_package_primitives") def test_package_info_install_path_is_project_root(self, mock_integrate, tmp_path): - """The synthetic PackageInfo must point to project_root, not .apm/.""" + """The synthetic PackageInfo must point to project_root at project scope.""" mock_integrate.return_value = _zero_counters() _integrate_local_content(tmp_path, **_make_integrators()) @@ -182,6 +182,21 @@ def test_package_info_install_path_is_project_root(self, mock_integrate, tmp_pat package_info = mock_integrate.call_args[0][0] assert package_info.install_path == tmp_path + @patch("apm_cli.install.services.integrate_package_primitives") + def test_user_scope_install_path_is_apm_dir_not_home(self, mock_integrate, tmp_path): + """At user scope, install_path must be /.apm/, not + project_root itself (which is $HOME). Scanning $HOME recursively + causes hangs and macOS privacy dialogs. Fixes #830.""" + from apm_cli.core.scope import InstallScope + + mock_integrate.return_value = _zero_counters() + (tmp_path / ".apm").mkdir(exist_ok=True) + + _integrate_local_content(tmp_path, **_make_integrators(), scope=InstallScope.USER) + + package_info = mock_integrate.call_args[0][0] + assert package_info.install_path == tmp_path / ".apm" + @patch("apm_cli.install.services.integrate_package_primitives") def test_returns_zero_counters_when_nothing_deployed(self, mock_integrate, tmp_path): """When nothing is deployed the result counters are all zero.""" From de46eb2c01d755fbe5af8f4c9e06b61c14661faf Mon Sep 17 00:00:00 2001 From: Wenqing Gu Date: Wed, 22 Apr 2026 15:03:18 -0400 Subject: [PATCH 2/3] fix(#830): scope discover_primitives in init_link_resolver, not install_path Address Copilot review feedback: changing install_path to ~/.apm/ broke integrator file discovery (they look under /.apm//). Move the fix to BaseIntegrator.init_link_resolver() instead: when install_path == $HOME, scope discover_primitives() to ~/.apm/ to avoid recursive-globbing the entire home directory. - Revert services.py install_path change (keep project_root) - Fix base_integrator.py init_link_resolver to scope scan root - Update test to verify install_path stays project_root at user scope - Add tests for init_link_resolver home-directory scoping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/install/services.py | 18 +-------- src/apm_cli/integration/base_integrator.py | 8 +++- .../unit/integration/test_base_integrator.py | 39 +++++++++++++++++++ tests/unit/test_local_content_install.py | 11 +++--- 4 files changed, 54 insertions(+), 22 deletions(-) diff --git a/src/apm_cli/install/services.py b/src/apm_cli/install/services.py index 5e6e062b6..13211b154 100644 --- a/src/apm_cli/install/services.py +++ b/src/apm_cli/install/services.py @@ -193,29 +193,15 @@ def integrate_local_content( """ from ..models.apm_package import APMPackage, PackageInfo, PackageType - # For user-scope installs, project_root is Path.home(). The local - # package should only scan ~/.apm/ (the actual primitives directory), - # NOT the entire home directory -- otherwise discover_primitives() - # does a recursive glob of ~/ which hangs and triggers macOS privacy - # dialogs. See #626 + #452 interaction bug. - if scope is not None: - from ..core.scope import InstallScope - if scope is InstallScope.USER: - apm_dir = project_root / ".apm" - else: - apm_dir = project_root - else: - apm_dir = project_root - local_pkg = APMPackage( name="_local", version="0.0.0", - package_path=apm_dir, + package_path=project_root, source="local", ) local_info = PackageInfo( package=local_pkg, - install_path=apm_dir, + install_path=project_root, package_type=PackageType.APM_PACKAGE, ) diff --git a/src/apm_cli/integration/base_integrator.py b/src/apm_cli/integration/base_integrator.py index fa7c92ee5..1fc42f91e 100644 --- a/src/apm_cli/integration/base_integrator.py +++ b/src/apm_cli/integration/base_integrator.py @@ -312,7 +312,13 @@ def init_link_resolver(self, package_info, project_root: Path) -> None: """Initialise and register the link resolver for a package.""" self.link_resolver = UnifiedLinkResolver(project_root) try: - primitives = discover_primitives(package_info.install_path) + scan_root = package_info.install_path + # When install_path is $HOME (user-scope local package), + # only scan the .apm/ subdirectory to avoid recursive- + # globbing the entire home tree. See issue #830. + if scan_root == Path.home(): + scan_root = scan_root / ".apm" + primitives = discover_primitives(scan_root) self.link_resolver.register_contexts(primitives) except Exception: self.link_resolver = None diff --git a/tests/unit/integration/test_base_integrator.py b/tests/unit/integration/test_base_integrator.py index 8737d2dbe..6e5a95500 100644 --- a/tests/unit/integration/test_base_integrator.py +++ b/tests/unit/integration/test_base_integrator.py @@ -11,6 +11,7 @@ from unittest.mock import MagicMock, patch from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult +from apm_cli.primitives.discovery import discover_primitives # --------------------------------------------------------------------------- @@ -592,3 +593,41 @@ class TestShouldIntegrate: def test_always_returns_true(self): bi = BaseIntegrator() assert bi.should_integrate(Path("/any/path")) is True + + +# --------------------------------------------------------------------------- +# init_link_resolver — home-directory scoping (#830) +# --------------------------------------------------------------------------- + +class TestInitLinkResolverHomeScoping: + """When install_path is $HOME, init_link_resolver must scope + discover_primitives to ~/.apm/ to avoid recursive-globbing the + entire home directory. See issue #830.""" + + @patch("apm_cli.integration.base_integrator.discover_primitives") + @patch("apm_cli.integration.base_integrator.UnifiedLinkResolver") + def test_scopes_to_apm_subdir_when_install_path_is_home( + self, mock_resolver_cls, mock_discover + ): + mock_discover.return_value = [] + bi = BaseIntegrator() + pkg_info = MagicMock() + pkg_info.install_path = Path.home() + + bi.init_link_resolver(pkg_info, Path.home()) + + mock_discover.assert_called_once_with(Path.home() / ".apm") + + @patch("apm_cli.integration.base_integrator.discover_primitives") + @patch("apm_cli.integration.base_integrator.UnifiedLinkResolver") + def test_uses_install_path_when_not_home( + self, mock_resolver_cls, mock_discover, tmp_path + ): + mock_discover.return_value = [] + bi = BaseIntegrator() + pkg_info = MagicMock() + pkg_info.install_path = tmp_path + + bi.init_link_resolver(pkg_info, tmp_path) + + mock_discover.assert_called_once_with(tmp_path) diff --git a/tests/unit/test_local_content_install.py b/tests/unit/test_local_content_install.py index 7e89cfa04..21d3ade5f 100644 --- a/tests/unit/test_local_content_install.py +++ b/tests/unit/test_local_content_install.py @@ -183,10 +183,11 @@ def test_package_info_install_path_is_project_root(self, mock_integrate, tmp_pat assert package_info.install_path == tmp_path @patch("apm_cli.install.services.integrate_package_primitives") - def test_user_scope_install_path_is_apm_dir_not_home(self, mock_integrate, tmp_path): - """At user scope, install_path must be /.apm/, not - project_root itself (which is $HOME). Scanning $HOME recursively - causes hangs and macOS privacy dialogs. Fixes #830.""" + def test_user_scope_install_path_stays_project_root(self, mock_integrate, tmp_path): + """At user scope, install_path must remain project_root so that + integrators can still find /.apm//. + The recursive-glob fix lives in init_link_resolver, not here. + Regression check for #830.""" from apm_cli.core.scope import InstallScope mock_integrate.return_value = _zero_counters() @@ -195,7 +196,7 @@ def test_user_scope_install_path_is_apm_dir_not_home(self, mock_integrate, tmp_p _integrate_local_content(tmp_path, **_make_integrators(), scope=InstallScope.USER) package_info = mock_integrate.call_args[0][0] - assert package_info.install_path == tmp_path / ".apm" + assert package_info.install_path == tmp_path @patch("apm_cli.install.services.integrate_package_primitives") def test_returns_zero_counters_when_nothing_deployed(self, mock_integrate, tmp_path): From 30f9068b9d3490d287c037c8db1cfb132f3f3f20 Mon Sep 17 00:00:00 2001 From: Wenqing Gu Date: Thu, 23 Apr 2026 16:15:37 -0400 Subject: [PATCH 3/3] docs(changelog): add Fixed entry for #830 user-scope home-glob fix Add the required CHANGELOG entry under [Unreleased] per the APM Review Panel verdict on PR #850. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9ddc62b..aba658068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CI: smoke tests in `build-release.yml`'s `build-and-test` job (Linux x86_64, Linux arm64, Windows) are now gated to promotion boundaries (tag/schedule/dispatch) instead of running on every push to main. Push-time smoke duplicated the merge-time smoke gate in `ci-integration.yml` and burned ~15 redundant codex-binary downloads/day. Tag-cut releases still run smoke as a pre-ship gate; nightly catches upstream codex URL drift; merge-time still gates merges into main. (#878) - CI docs: clarify that branch-protection ruleset must store the check-run name (`gate`), not the workflow display string (`Merge Gate / gate`); document the merge-gate aggregator in `cicd.instructions.md` and mark the legacy stub workflow as deprecated. +### Fixed + +- `apm install` (user scope): `init_link_resolver` now scopes `discover_primitives` to `~/.apm/` instead of `~/`, preventing recursive-glob across the entire home directory. Fixes #830 (#850) + ### Removed - CI: deleted `ci-integration-pr-stub.yml`. The four stubs were a holdover from the pre-merge-gate model where branch protection required each Tier 2 check name directly. After #867, branch protection requires only `gate`, so the stubs are dead weight. Reduced `EXPECTED_CHECKS` in `merge-gate.yml` to just `Build & Test (Linux)`.