fix: create target dir for explicit --target claude; content hash fallback when .git absent#763
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes two apm install regressions: explicit --target claude now creates the target root directory even when auto_create=False, and installs avoid unnecessary re-downloads when an installed package no longer has a .git/ directory by falling back to lockfile content_hash verification.
Changes:
- Create target root directories when a target is explicitly requested (CLI
--targetorapm.yml target:), even if the target isauto_create=False. - When git SHA validation fails due to missing/invalid
.git/, fall back tocontent_hashverification to decide whether to skip re-download. - Add unit tests and changelog entries covering the fixes.
Show a summary per file
| File | Description |
|---|---|
| src/apm_cli/commands/install.py | Adjusts target dir creation guard for explicit targets; adds content-hash fallback in git-check exception paths. |
| tests/unit/test_install_command.py | Adds new tests for explicit target dir creation and content-hash fallback behavior. |
| CHANGELOG.md | Adds Unreleased Fixed entries describing both bug fixes. |
Copilot's findings
- Files reviewed: 3/3 changed files
- Comments generated: 4
| class TestContentHashFallback: | ||
| """Verify content-hash fallback when .git is removed from installed packages.""" | ||
|
|
||
| def test_hash_match_skips_redownload(self): | ||
| """Content hash verification allows skipping re-download.""" | ||
| from apm_cli.utils.content_hash import compute_package_hash, verify_package_hash | ||
|
|
||
| with tempfile.TemporaryDirectory() as tmpdir: | ||
| pkg_dir = Path(tmpdir) / "pkg" | ||
| pkg_dir.mkdir() | ||
| (pkg_dir / "file.txt").write_text("hello") | ||
| content_hash = compute_package_hash(pkg_dir) | ||
|
|
||
| assert verify_package_hash(pkg_dir, content_hash) is True | ||
|
|
||
| def test_hash_mismatch_triggers_redownload(self): | ||
| """Mismatched content hash means re-download should proceed.""" | ||
| from apm_cli.utils.content_hash import verify_package_hash | ||
|
|
||
| with tempfile.TemporaryDirectory() as tmpdir: | ||
| pkg_dir = Path(tmpdir) / "pkg" | ||
| pkg_dir.mkdir() | ||
| (pkg_dir / "file.txt").write_text("original") | ||
|
|
||
| assert verify_package_hash(pkg_dir, "sha256:badhash") is False | ||
|
|
||
| def test_missing_content_hash_skips_fallback(self): | ||
| """When locked dep has no content_hash, the fallback guard prevents | ||
| verify_package_hash from being called.""" | ||
| from apm_cli.utils.content_hash import verify_package_hash | ||
|
|
||
| with tempfile.TemporaryDirectory() as tmpdir: | ||
| pkg_dir = Path(tmpdir) / "pkg" | ||
| pkg_dir.mkdir() | ||
| (pkg_dir / "file.txt").write_text("data") | ||
|
|
||
| # Simulate the guard logic from install.py: | ||
| # if _pd_locked_chk.content_hash and _pd_path.is_dir(): | ||
| content_hash = None # no content_hash recorded in lockfile | ||
| fallback_triggered = False | ||
| if content_hash and pkg_dir.is_dir(): | ||
| fallback_triggered = verify_package_hash(pkg_dir, content_hash) | ||
|
|
There was a problem hiding this comment.
The new content-hash tests validate compute_package_hash/verify_package_hash in isolation, but they don't cover the install-time fallback behavior added in install.py (i.e., .git missing -> git SHA check raises -> fallback hash check prevents a re-download). To prevent regressions, add a test that runs the install flow (or the relevant internal function) with a mocked GitRepo that raises and a mocked downloader, and assert the downloader is not called when the lockfile content_hash matches (and is called when it mismatches or is missing).
| for _t in _targets: | ||
| if not _t.auto_create: | ||
| if not _t.auto_create and not _explicit: | ||
| continue |
There was a problem hiding this comment.
This change updates apm install behavior so an explicit --target/apm.yml target: will create the target root directory even when auto_create=False (e.g. .claude/). There are docs pages that currently state that when neither .github/ nor .claude/ exists, apm install skips folder integration; that should be clarified to note the explicit-target override so users aren't misled.
| - Fix `apm install --target claude` not creating `.claude/` when the directory does not already exist (`auto_create=False` targets now get their root directory created when explicitly requested) | ||
| - Fix content hash mismatch on re-install when `.git/` is absent from installed packages by falling back to content-hash verification before re-downloading |
There was a problem hiding this comment.
The new changelog entries under ## [Unreleased] do not follow the repo's Keep-a-Changelog rule of ending each entry with the PR number (e.g. (#123)). Please update these two lines to be single-line entries that include the PR reference (and keep any code/config references in backticks).
| """Verify --target creates root_dir even when auto_create=False (GH bug fix).""" | ||
|
|
||
| def setup_method(self): | ||
| self._tmpdir = tempfile.mkdtemp() | ||
| self.project_root = Path(self._tmpdir) | ||
|
|
||
| def teardown_method(self): | ||
| import shutil | ||
| shutil.rmtree(self._tmpdir, ignore_errors=True) | ||
|
|
||
| def test_explicit_target_creates_dir_for_auto_create_false(self): | ||
| """When _explicit is set, target dirs are created even if auto_create=False.""" | ||
| from apm_cli.integration.targets import KNOWN_TARGETS | ||
|
|
||
| claude = KNOWN_TARGETS["claude"] | ||
| assert claude.auto_create is False | ||
|
|
||
| # Simulate the fixed loop logic: create dir when _explicit is set | ||
| _explicit = "claude" | ||
| _targets = [claude] | ||
| for _t in _targets: | ||
| if not _t.auto_create and not _explicit: | ||
| continue | ||
| _target_dir = self.project_root / _t.root_dir | ||
| if not _target_dir.exists(): | ||
| _target_dir.mkdir(parents=True, exist_ok=True) | ||
|
|
||
| assert (self.project_root / ".claude").is_dir() | ||
|
|
||
| def test_auto_detect_skips_dir_for_auto_create_false(self): | ||
| """Without _explicit, auto_create=False targets don't get dirs created.""" | ||
| from apm_cli.integration.targets import KNOWN_TARGETS | ||
|
|
||
| claude = KNOWN_TARGETS["claude"] | ||
| assert claude.auto_create is False | ||
|
|
||
| _explicit = None | ||
| _targets = [claude] | ||
| for _t in _targets: | ||
| if not _t.auto_create and not _explicit: | ||
| continue | ||
| _target_dir = self.project_root / _t.root_dir | ||
| if not _target_dir.exists(): | ||
| _target_dir.mkdir(parents=True, exist_ok=True) | ||
|
|
||
| assert not (self.project_root / ".claude").exists() | ||
|
|
||
| def test_auto_create_true_always_creates_dir(self): | ||
| """auto_create=True targets create dir regardless of _explicit.""" | ||
| from apm_cli.integration.targets import KNOWN_TARGETS | ||
|
|
||
| copilot = KNOWN_TARGETS["copilot"] | ||
| assert copilot.auto_create is True | ||
|
|
||
| for _explicit in [None, "copilot"]: | ||
| import shutil | ||
| shutil.rmtree(self.project_root / copilot.root_dir, ignore_errors=True) | ||
|
|
||
| _targets = [copilot] | ||
| for _t in _targets: | ||
| if not _t.auto_create and not _explicit: | ||
| continue | ||
| _target_dir = self.project_root / _t.root_dir | ||
| if not _target_dir.exists(): | ||
| _target_dir.mkdir(parents=True, exist_ok=True) | ||
|
|
||
| assert (self.project_root / ".github").is_dir(), ( | ||
| f"auto_create=True should create dir when _explicit={_explicit!r}" | ||
| ) |
There was a problem hiding this comment.
These tests duplicate the production directory-creation loop inline (re-implementing the _explicit/auto_create condition) instead of exercising apm install/_install_apm_dependencies behavior. This makes the tests brittle (they can pass even if install.py regresses) and doesn't assert the CLI-facing bug fix. Consider invoking the install command via CliRunner with --target claude (mocking downloads/integration as needed) and asserting .claude/ is created, or factoring the loop into a helper and testing that helper directly.
| """Verify --target creates root_dir even when auto_create=False (GH bug fix).""" | |
| def setup_method(self): | |
| self._tmpdir = tempfile.mkdtemp() | |
| self.project_root = Path(self._tmpdir) | |
| def teardown_method(self): | |
| import shutil | |
| shutil.rmtree(self._tmpdir, ignore_errors=True) | |
| def test_explicit_target_creates_dir_for_auto_create_false(self): | |
| """When _explicit is set, target dirs are created even if auto_create=False.""" | |
| from apm_cli.integration.targets import KNOWN_TARGETS | |
| claude = KNOWN_TARGETS["claude"] | |
| assert claude.auto_create is False | |
| # Simulate the fixed loop logic: create dir when _explicit is set | |
| _explicit = "claude" | |
| _targets = [claude] | |
| for _t in _targets: | |
| if not _t.auto_create and not _explicit: | |
| continue | |
| _target_dir = self.project_root / _t.root_dir | |
| if not _target_dir.exists(): | |
| _target_dir.mkdir(parents=True, exist_ok=True) | |
| assert (self.project_root / ".claude").is_dir() | |
| def test_auto_detect_skips_dir_for_auto_create_false(self): | |
| """Without _explicit, auto_create=False targets don't get dirs created.""" | |
| from apm_cli.integration.targets import KNOWN_TARGETS | |
| claude = KNOWN_TARGETS["claude"] | |
| assert claude.auto_create is False | |
| _explicit = None | |
| _targets = [claude] | |
| for _t in _targets: | |
| if not _t.auto_create and not _explicit: | |
| continue | |
| _target_dir = self.project_root / _t.root_dir | |
| if not _target_dir.exists(): | |
| _target_dir.mkdir(parents=True, exist_ok=True) | |
| assert not (self.project_root / ".claude").exists() | |
| def test_auto_create_true_always_creates_dir(self): | |
| """auto_create=True targets create dir regardless of _explicit.""" | |
| from apm_cli.integration.targets import KNOWN_TARGETS | |
| copilot = KNOWN_TARGETS["copilot"] | |
| assert copilot.auto_create is True | |
| for _explicit in [None, "copilot"]: | |
| import shutil | |
| shutil.rmtree(self.project_root / copilot.root_dir, ignore_errors=True) | |
| _targets = [copilot] | |
| for _t in _targets: | |
| if not _t.auto_create and not _explicit: | |
| continue | |
| _target_dir = self.project_root / _t.root_dir | |
| if not _target_dir.exists(): | |
| _target_dir.mkdir(parents=True, exist_ok=True) | |
| assert (self.project_root / ".github").is_dir(), ( | |
| f"auto_create=True should create dir when _explicit={_explicit!r}" | |
| ) | |
| """Verify install creates target root dirs through the CLI flow.""" | |
| def setup_method(self): | |
| self._tmpdir = tempfile.mkdtemp() | |
| self.project_root = Path(self._tmpdir) | |
| self.runner = CliRunner() | |
| self.original_dir = os.getcwd() | |
| os.chdir(self.project_root) | |
| def teardown_method(self): | |
| import shutil | |
| os.chdir(self.original_dir) | |
| shutil.rmtree(self._tmpdir, ignore_errors=True) | |
| @patch("apm_cli.commands.install._validate_package_exists") | |
| @patch("apm_cli.commands.install.APM_DEPS_AVAILABLE", True) | |
| @patch("apm_cli.commands.install.APMPackage") | |
| @patch("apm_cli.commands.install._install_apm_dependencies") | |
| def test_explicit_target_creates_dir_for_auto_create_false( | |
| self, mock_install_apm, mock_apm_package, mock_validate | |
| ): | |
| """When --target is set, target dirs are created even if auto_create=False.""" | |
| mock_validate.return_value = True | |
| mock_pkg_instance = MagicMock() | |
| mock_pkg_instance.get_apm_dependencies.return_value = [ | |
| MagicMock(repo_url="test/package", reference="main") | |
| ] | |
| mock_pkg_instance.get_mcp_dependencies.return_value = [] | |
| mock_apm_package.from_apm_yml.return_value = mock_pkg_instance | |
| mock_install_apm.return_value = InstallResult( | |
| diagnostics=MagicMock( | |
| has_diagnostics=False, has_critical_security=False | |
| ) | |
| ) | |
| result = self.runner.invoke( | |
| cli, ["install", "--target", "claude", "test/package"] | |
| ) | |
| assert result.exit_code == 0 | |
| assert (self.project_root / ".claude").is_dir() | |
| @patch("apm_cli.commands.install._validate_package_exists") | |
| @patch("apm_cli.commands.install.APM_DEPS_AVAILABLE", True) | |
| @patch("apm_cli.commands.install.APMPackage") | |
| @patch("apm_cli.commands.install._install_apm_dependencies") | |
| def test_auto_detect_skips_dir_for_auto_create_false( | |
| self, mock_install_apm, mock_apm_package, mock_validate | |
| ): | |
| """Without --target, auto_create=False targets do not get dirs created.""" | |
| mock_validate.return_value = True | |
| mock_pkg_instance = MagicMock() | |
| mock_pkg_instance.get_apm_dependencies.return_value = [ | |
| MagicMock(repo_url="test/package", reference="main") | |
| ] | |
| mock_pkg_instance.get_mcp_dependencies.return_value = [] | |
| mock_apm_package.from_apm_yml.return_value = mock_pkg_instance | |
| mock_install_apm.return_value = InstallResult( | |
| diagnostics=MagicMock( | |
| has_diagnostics=False, has_critical_security=False | |
| ) | |
| ) | |
| result = self.runner.invoke(cli, ["install", "test/package"]) | |
| assert result.exit_code == 0 | |
| assert not (self.project_root / ".claude").exists() | |
| @patch("apm_cli.commands.install._validate_package_exists") | |
| @patch("apm_cli.commands.install.APM_DEPS_AVAILABLE", True) | |
| @patch("apm_cli.commands.install.APMPackage") | |
| @patch("apm_cli.commands.install._install_apm_dependencies") | |
| @pytest.mark.parametrize( | |
| "args", | |
| [ | |
| ["install", "test/package"], | |
| ["install", "--target", "copilot", "test/package"], | |
| ], | |
| ) | |
| def test_auto_create_true_always_creates_dir( | |
| self, mock_install_apm, mock_apm_package, mock_validate, args | |
| ): | |
| """auto_create=True targets create dirs with and without an explicit target.""" | |
| import shutil | |
| shutil.rmtree(self.project_root / ".github", ignore_errors=True) | |
| mock_validate.return_value = True | |
| mock_pkg_instance = MagicMock() | |
| mock_pkg_instance.get_apm_dependencies.return_value = [ | |
| MagicMock(repo_url="test/package", reference="main") | |
| ] | |
| mock_pkg_instance.get_mcp_dependencies.return_value = [] | |
| mock_apm_package.from_apm_yml.return_value = mock_pkg_instance | |
| mock_install_apm.return_value = InstallResult( | |
| diagnostics=MagicMock( | |
| has_diagnostics=False, has_critical_security=False | |
| ) | |
| ) | |
| result = self.runner.invoke(cli, args) | |
| assert result.exit_code == 0 | |
| assert (self.project_root / ".github").is_dir() |
…lse, add content hash fallback when .git is absent Agent-Logs-Url: https://github.com/microsoft/apm/sessions/b38e9e9f-cf61-41fe-932a-cecd717bdaf8 Co-authored-by: danielmeppiel <51440732+danielmeppiel@users.noreply.github.com>
…gic in context Agent-Logs-Url: https://github.com/microsoft/apm/sessions/b38e9e9f-cf61-41fe-932a-cecd717bdaf8 Co-authored-by: danielmeppiel <51440732+danielmeppiel@users.noreply.github.com>
5863b6e to
06ca5fd
Compare
When a user passes --target (or sets target: in apm.yml), apm install now creates the target's root folder even when auto_create=False (#763). Update VS Code and Claude integration auto-detection notes so users know the explicit-target override exists. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Maintainer verdict on Copilot reviewThanks @copilot-pull-request-reviewer for the thorough pass. Triage:
Why defer the test refactorsThe fixes themselves are correct and the surrounding 3987 unit tests pass. The cleanest way to genuinely guard these two paths is a Rebase noteThis PR was rebased onto post-#764 main. The original surgical edits to
Ready to merge once CI is green. |
Description
Two bugs in
apm install:--target claudesilently skips agent installation when.claude/doesn't exist. The directory creation loop only runs forauto_create=Truetargets (only Copilot), ignoring explicitly requested targets.apm installafter.git/is cleaned from an installed package triggers spurious re-downloads and "content hash mismatch" warnings. The git SHA check throws, theexceptblock doespass, and the package gets needlessly re-fetched.Changes (ported to the post-#764 install pipeline during rebase):
install/phases/targets.py): Changed guard fromif not _t.auto_createtoif not _t.auto_create and not _explicit-- explicit--targetorapm.yml target:now creates the root dir regardless ofauto_createverify_package_hash()fallback in all three git-checkexcept Exceptionblocks (install/phases/download.pypre-download check, plus theupdate_refsand normal-mode branches ininstall/phases/integrate.py) -- if.git/is gone but lockfile has acontent_hashand it matches, skip re-download1e00ffd): Clarified docs that previously statedapm installskips folder integration when neither.github/nor.claude/exists, to note the explicit-target overrideType of change
Testing
6 new tests:
TestExplicitTargetDirCreation(3 tests covering explicit, auto-detect, and auto_create=True cases) andTestContentHashFallback(3 tests covering hash match, mismatch, and missing hash guard). Reviewer feedback to convert these intoCliRunner-based integration tests is deferred to issue #768 alongside the broader integration-coverage push in #767.