Skip to content

fix: create target dir for explicit --target claude; content hash fallback when .git absent#763

Merged
danielmeppiel merged 5 commits intomainfrom
copilot/fix-agent-installation-issue
Apr 19, 2026
Merged

fix: create target dir for explicit --target claude; content hash fallback when .git absent#763
danielmeppiel merged 5 commits intomainfrom
copilot/fix-agent-installation-issue

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 19, 2026

Description

Two bugs in apm install:

  1. --target claude silently skips agent installation when .claude/ doesn't exist. The directory creation loop only runs for auto_create=True targets (only Copilot), ignoring explicitly requested targets.
  2. Re-running apm install after .git/ is cleaned from an installed package triggers spurious re-downloads and "content hash mismatch" warnings. The git SHA check throws, the except block does pass, and the package gets needlessly re-fetched.

Changes (ported to the post-#764 install pipeline during rebase):

  • Target dir creation (install/phases/targets.py): Changed guard from if not _t.auto_create to if not _t.auto_create and not _explicit -- explicit --target or apm.yml target: now creates the root dir regardless of auto_create
  • Content hash fallback: Added verify_package_hash() fallback in all three git-check except Exception blocks (install/phases/download.py pre-download check, plus the update_refs and normal-mode branches in install/phases/integrate.py) -- if .git/ is gone but lockfile has a content_hash and it matches, skip re-download
  • Docs (commit 1e00ffd): Clarified docs that previously stated apm install skips folder integration when neither .github/ nor .claude/ exists, to note the explicit-target override
  • CHANGELOG: Entries updated to include the PR reference per Keep-a-Changelog conventions

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Maintenance / refactor

Testing

  • Tested locally
  • All existing tests pass
  • Added tests for new functionality (if applicable)

6 new tests: TestExplicitTargetDirCreation (3 tests covering explicit, auto-detect, and auto_create=True cases) and TestContentHashFallback (3 tests covering hash match, mismatch, and missing hash guard). Reviewer feedback to convert these into CliRunner-based integration tests is deferred to issue #768 alongside the broader integration-coverage push in #767.

Copilot AI changed the title [WIP] Fix agent installation for Claude when .claude dir is missing fix: create target dir for explicit --target claude; content hash fallback when .git absent Apr 19, 2026
Copilot AI requested a review from danielmeppiel April 19, 2026 08:44
@danielmeppiel danielmeppiel marked this pull request as ready for review April 19, 2026 08:48
Copilot AI review requested due to automatic review settings April 19, 2026 08:48
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 --target or apm.yml target:), even if the target is auto_create=False.
  • When git SHA validation fails due to missing/invalid .git/, fall back to content_hash verification 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

Comment on lines +1070 to +1112
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)

Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread src/apm_cli/commands/install.py Outdated
Comment on lines 1774 to 1776
for _t in _targets:
if not _t.auto_create:
if not _t.auto_create and not _explicit:
continue
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot generated this review using guidance from repository custom instructions.
Comment thread CHANGELOG.md Outdated
Comment on lines +18 to +19
- 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
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +999 to +1067
"""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}"
)
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
"""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()

Copilot uses AI. Check for mistakes.
Copilot AI and others added 3 commits April 19, 2026 16:52
…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>
@danielmeppiel danielmeppiel force-pushed the copilot/fix-agent-installation-issue branch from 5863b6e to 06ca5fd Compare April 19, 2026 14:55
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>
@danielmeppiel
Copy link
Copy Markdown
Collaborator

Maintainer verdict on Copilot review

Thanks @copilot-pull-request-reviewer for the thorough pass. Triage:

# Comment Verdict
1 test_install_command.py:1067TestExplicitTargetDirCreation is a logic replay Valid, deferred -> #768
2 test_install_command.py:1112TestContentHashFallback doesn't exercise install path Valid, deferred -> #768
3 CHANGELOG entries missing PR ref Stale — both new lines now end with (#763) (fixed during rebase onto post-#764 main)
4 Docs say apm install skips folder integration when neither .github/ nor .claude/ exists -- doesn't mention explicit-target override Valid, fixed in 1e00ffd (this PR)

Why defer the test refactors

The fixes themselves are correct and the surrounding 3987 unit tests pass. The cleanest way to genuinely guard these two paths is a CliRunner-based integration test (mocked download/integration) -- which fits naturally with the broader integration-coverage push in #767 rather than as more isolated unit tests here. Tracked in #768.

Rebase note

This PR was rebased onto post-#764 main. The original surgical edits to commands/install.py (the 2,905-LOC monolith) were obsolete because that file is now a thin dispatcher to InstallService. The 4 fixes were ported to:

  • install/phases/targets.py:69 -- explicit-target dir creation
  • install/phases/download.py:81-90 -- content-hash fallback (downloader pre-check)
  • install/phases/integrate.py:104-114 -- content-hash fallback (update_refs branch)
  • install/phases/integrate.py:115-126 -- content-hash fallback (normal-mode branch)

Ready to merge once CI is green.

@danielmeppiel danielmeppiel merged commit db52a9d into main Apr 19, 2026
7 checks passed
@danielmeppiel danielmeppiel deleted the copilot/fix-agent-installation-issue branch April 19, 2026 15:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] installing agents for claude doesn't work if .claude dir is not present

3 participants