Skip to content

Commit 8a4f0fe

Browse files
Merge pull request #178 from sergio-sisternes-epam/fix/177-lockfile-commit-sha-clone
fix: handle commit SHA refs in subdirectory package clones
2 parents c8b8949 + 8b5c69c commit 8a4f0fe

2 files changed

Lines changed: 190 additions & 5 deletions

File tree

src/apm_cli/deps/github_downloader.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,19 +1138,27 @@ def download_subdirectory_package(self, dep_ref: DependencyReference, target_pat
11381138
sparse_ok = self._try_sparse_checkout(dep_ref, temp_clone_path, subdir_path, ref)
11391139

11401140
if not sparse_ok:
1141-
# Full shallow clone fallback
1141+
# Full clone fallback
11421142
if temp_clone_path.exists():
11431143
shutil.rmtree(temp_clone_path)
11441144

11451145
package_display_name = subdir_path.split('/')[-1]
11461146
progress_reporter = GitProgressReporter(progress_task_id, progress_obj, package_display_name) if progress_task_id and progress_obj else None
11471147

1148+
# Detect if ref is a commit SHA (can't be used with --branch in shallow clones)
1149+
is_commit_sha = ref and re.match(r'^[a-f0-9]{7,40}$', ref) is not None
1150+
11481151
clone_kwargs = {
11491152
'dep_ref': dep_ref,
1150-
'depth': 1,
11511153
}
1152-
if ref:
1153-
clone_kwargs['branch'] = ref
1154+
if is_commit_sha:
1155+
# For commit SHAs, clone without checkout then checkout the specific commit.
1156+
# Shallow clone doesn't support fetching by arbitrary SHA.
1157+
clone_kwargs['no_checkout'] = True
1158+
else:
1159+
clone_kwargs['depth'] = 1
1160+
if ref:
1161+
clone_kwargs['branch'] = ref
11541162

11551163
try:
11561164
self._clone_with_fallback(
@@ -1160,7 +1168,14 @@ def download_subdirectory_package(self, dep_ref: DependencyReference, target_pat
11601168
**clone_kwargs
11611169
)
11621170
except Exception as e:
1163-
raise RuntimeError(f"Failed to clone repository: {e}")
1171+
raise RuntimeError(f"Failed to clone repository: {e}") from e
1172+
1173+
if is_commit_sha:
1174+
try:
1175+
repo_obj = Repo(temp_clone_path)
1176+
repo_obj.git.checkout(ref)
1177+
except Exception as e:
1178+
raise RuntimeError(f"Failed to checkout commit {ref}: {e}") from e
11641179

11651180
# Disable progress reporter after clone
11661181
if progress_reporter:

tests/test_github_downloader.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,5 +828,175 @@ def test_ghe_without_github_token_falls_back(self):
828828
assert 'ado-token' not in url
829829

830830

831+
class TestSubdirectoryPackageCommitSHA:
832+
"""Test commit SHA handling in download_subdirectory_package."""
833+
834+
def setup_method(self):
835+
self.temp_dir = Path(tempfile.mkdtemp())
836+
837+
def teardown_method(self):
838+
if self.temp_dir.exists():
839+
shutil.rmtree(self.temp_dir, ignore_errors=True)
840+
841+
def _make_dep_ref(self, ref=None):
842+
"""Create a virtual subdirectory DependencyReference."""
843+
dep = DependencyReference(
844+
repo_url='owner/monorepo',
845+
host='github.com',
846+
reference=ref,
847+
virtual_path='packages/my-skill',
848+
is_virtual=True,
849+
)
850+
return dep
851+
852+
@patch('apm_cli.deps.github_downloader.Repo')
853+
@patch('apm_cli.deps.github_downloader.validate_apm_package')
854+
def test_sha_ref_clones_without_depth_and_checks_out(self, mock_validate, mock_repo_class):
855+
"""Commit SHA refs must clone with no_checkout (no depth/branch) then checkout the SHA."""
856+
sha = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'
857+
dep_ref = self._make_dep_ref(ref=sha)
858+
859+
mock_repo = Mock()
860+
mock_repo_class.return_value = mock_repo
861+
862+
mock_validation = ValidationResult()
863+
mock_validation.is_valid = True
864+
mock_validation.package = APMPackage(name='my-skill', version='1.0.0')
865+
mock_validate.return_value = mock_validation
866+
867+
with patch.dict(os.environ, {}, clear=True):
868+
downloader = GitHubPackageDownloader()
869+
870+
with patch.object(downloader, '_clone_with_fallback') as mock_clone:
871+
mock_clone.return_value = mock_repo
872+
873+
target = self.temp_dir / 'my-skill'
874+
875+
# Create the subdirectory structure that download_subdirectory_package expects
876+
def setup_subdir(*args, **kwargs):
877+
clone_path = args[1]
878+
subdir = clone_path / 'packages' / 'my-skill'
879+
subdir.mkdir(parents=True)
880+
(subdir / 'apm.yml').write_text('name: my-skill\nversion: 1.0.0\n')
881+
return mock_repo
882+
883+
mock_clone.side_effect = setup_subdir
884+
885+
downloader.download_subdirectory_package(dep_ref, target)
886+
887+
# Verify clone was called without depth/branch but WITH no_checkout
888+
call_kwargs = mock_clone.call_args
889+
assert 'depth' not in call_kwargs.kwargs, "SHA ref should NOT use shallow clone"
890+
assert 'branch' not in call_kwargs.kwargs, "SHA ref should NOT pass branch"
891+
assert call_kwargs.kwargs.get('no_checkout') is True, "SHA ref should use no_checkout=True"
892+
893+
# Verify checkout was called with the SHA
894+
mock_repo.git.checkout.assert_called_once_with(sha)
895+
896+
@patch('apm_cli.deps.github_downloader.Repo')
897+
@patch('apm_cli.deps.github_downloader.validate_apm_package')
898+
def test_branch_ref_uses_shallow_clone(self, mock_validate, mock_repo_class):
899+
"""Branch/tag refs must use shallow clone with depth=1 and branch kwarg."""
900+
dep_ref = self._make_dep_ref(ref='main')
901+
902+
mock_repo = Mock()
903+
mock_repo_class.return_value = mock_repo
904+
905+
mock_validation = ValidationResult()
906+
mock_validation.is_valid = True
907+
mock_validation.package = APMPackage(name='my-skill', version='1.0.0')
908+
mock_validate.return_value = mock_validation
909+
910+
with patch.dict(os.environ, {}, clear=True):
911+
downloader = GitHubPackageDownloader()
912+
913+
with patch.object(downloader, '_clone_with_fallback') as mock_clone:
914+
mock_clone.return_value = mock_repo
915+
916+
target = self.temp_dir / 'my-skill'
917+
918+
def setup_subdir(*args, **kwargs):
919+
clone_path = args[1]
920+
subdir = clone_path / 'packages' / 'my-skill'
921+
subdir.mkdir(parents=True)
922+
(subdir / 'apm.yml').write_text('name: my-skill\nversion: 1.0.0\n')
923+
return mock_repo
924+
925+
mock_clone.side_effect = setup_subdir
926+
927+
downloader.download_subdirectory_package(dep_ref, target)
928+
929+
call_kwargs = mock_clone.call_args
930+
assert call_kwargs.kwargs.get('depth') == 1, "Branch ref should use depth=1"
931+
assert call_kwargs.kwargs.get('branch') == 'main', "Branch ref should pass branch"
932+
assert 'no_checkout' not in call_kwargs.kwargs, "Branch ref should not set no_checkout"
933+
934+
# No explicit checkout for branch refs
935+
mock_repo.git.checkout.assert_not_called()
936+
937+
@patch('apm_cli.deps.github_downloader.Repo')
938+
@patch('apm_cli.deps.github_downloader.validate_apm_package')
939+
def test_no_ref_uses_shallow_clone_without_branch(self, mock_validate, mock_repo_class):
940+
"""No ref should use shallow clone without branch kwarg (default branch)."""
941+
dep_ref = self._make_dep_ref(ref=None)
942+
943+
mock_repo = Mock()
944+
mock_repo_class.return_value = mock_repo
945+
946+
mock_validation = ValidationResult()
947+
mock_validation.is_valid = True
948+
mock_validation.package = APMPackage(name='my-skill', version='1.0.0')
949+
mock_validate.return_value = mock_validation
950+
951+
with patch.dict(os.environ, {}, clear=True):
952+
downloader = GitHubPackageDownloader()
953+
954+
with patch.object(downloader, '_clone_with_fallback') as mock_clone:
955+
mock_clone.return_value = mock_repo
956+
957+
target = self.temp_dir / 'my-skill'
958+
959+
def setup_subdir(*args, **kwargs):
960+
clone_path = args[1]
961+
subdir = clone_path / 'packages' / 'my-skill'
962+
subdir.mkdir(parents=True)
963+
(subdir / 'apm.yml').write_text('name: my-skill\nversion: 1.0.0\n')
964+
return mock_repo
965+
966+
mock_clone.side_effect = setup_subdir
967+
968+
downloader.download_subdirectory_package(dep_ref, target)
969+
970+
call_kwargs = mock_clone.call_args
971+
assert call_kwargs.kwargs.get('depth') == 1, "No ref should still shallow clone"
972+
assert 'branch' not in call_kwargs.kwargs, "No ref should not pass branch"
973+
974+
@patch('apm_cli.deps.github_downloader.Repo')
975+
def test_sha_checkout_failure_raises_descriptive_error(self, mock_repo_class):
976+
"""Checkout failure for SHA ref should raise error mentioning 'checkout', not 'clone'."""
977+
sha = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'
978+
dep_ref = self._make_dep_ref(ref=sha)
979+
980+
mock_repo = Mock()
981+
mock_repo.git.checkout.side_effect = Exception("bad object a1b2c3d")
982+
mock_repo_class.return_value = mock_repo
983+
984+
with patch.dict(os.environ, {}, clear=True):
985+
downloader = GitHubPackageDownloader()
986+
987+
with patch.object(downloader, '_clone_with_fallback') as mock_clone:
988+
def setup_subdir(*args, **kwargs):
989+
clone_path = args[1]
990+
subdir = clone_path / 'packages' / 'my-skill'
991+
subdir.mkdir(parents=True)
992+
return mock_repo
993+
994+
mock_clone.side_effect = setup_subdir
995+
996+
target = self.temp_dir / 'my-skill'
997+
with pytest.raises(RuntimeError, match="Failed to checkout commit"):
998+
downloader.download_subdirectory_package(dep_ref, target)
999+
1000+
8311001
if __name__ == '__main__':
8321002
pytest.main([__file__])

0 commit comments

Comments
 (0)