Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
94d0518
fix: preserve ssh URLs instead of forcing https fallback
edenfunf Apr 10, 2026
073ef92
test: verify _clone_with_fallback uses original ssh:// URL verbatim
edenfunf Apr 10, 2026
d178dda
Merge branch 'main' into fix/preserve-ssh-url-bitbucket-datacenter
danielmeppiel Apr 10, 2026
ecff195
fix: sanitize original_ssh_url; strip #ref/@alias before storing
edenfunf Apr 11, 2026
9e1af7d
Merge branch 'main' into fix/preserve-ssh-url-bitbucket-datacenter
sergio-sisternes-epam Apr 12, 2026
f2cbada
Merge branch 'main' into fix/preserve-ssh-url-bitbucket-datacenter
edenfunf Apr 13, 2026
aab6c39
Merge branch 'main' into fix/preserve-ssh-url-bitbucket-datacenter
sergio-sisternes-epam Apr 14, 2026
c75b2e6
Merge branch 'main' into fix/preserve-ssh-url-bitbucket-datacenter
edenfunf Apr 14, 2026
0a04345
Merge branch 'main' into fix/preserve-ssh-url-bitbucket-datacenter
edenfunf Apr 16, 2026
2c8e602
Merge branch 'main' into fix/preserve-ssh-url-bitbucket-datacenter
sergio-sisternes-epam Apr 16, 2026
219fa21
Merge branch 'main' into fix/preserve-ssh-url-bitbucket-datacenter
danielmeppiel Apr 16, 2026
dcafa1c
Merge branch 'main' into fix/preserve-ssh-url-bitbucket-datacenter
danielmeppiel Apr 17, 2026
c13ca0a
Merge branch 'main' into fix/preserve-ssh-url-bitbucket-datacenter
danielmeppiel Apr 17, 2026
bffa332
Merge branch 'main' into fix/preserve-ssh-url-bitbucket-datacenter
danielmeppiel Apr 17, 2026
c8abcdb
fix: record original_ssh_url in lockfile for provenance tracking
edenfunf Apr 18, 2026
039f42e
Merge branch 'main' into fix/preserve-ssh-url-bitbucket-datacenter
edenfunf Apr 18, 2026
40a157e
Merge branch 'main' into fix/preserve-ssh-url-bitbucket-datacenter
danielmeppiel Apr 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/apm_cli/deps/github_downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -677,9 +677,14 @@ def _clone_with_fallback(self, repo_url_base: str, target_path: Path, progress_r
last_error = e
# Continue to next method

# Method 2: Try SSH (works with SSH keys for any host)
# Method 2: Try SSH (works with SSH keys for any host).
# When the user supplied an explicit ssh:// URL (e.g. with a custom port for
# Bitbucket Datacenter), use it verbatim so the port is not silently dropped.
try:
ssh_url = self._build_repo_url(repo_url_base, use_ssh=True, dep_ref=dep_ref)
if dep_ref and dep_ref.original_ssh_url:
ssh_url = dep_ref.original_ssh_url
else:
ssh_url = self._build_repo_url(repo_url_base, use_ssh=True, dep_ref=dep_ref)
repo = Repo.clone_from(ssh_url, target_path, env=clone_env, progress=progress_reporter, **clone_kwargs)
Comment thread
edenfunf marked this conversation as resolved.
if verbose_callback:
verbose_callback(f"Cloned from: {ssh_url}")
Expand Down
10 changes: 10 additions & 0 deletions src/apm_cli/models/dependency/reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ class DependencyReference:
None # e.g., "artifactory/github" (repo key path)
)

# Preserved verbatim when the user supplied an explicit ssh:// URL in apm.yml.
# Used by the downloader to clone with the exact URL (including any custom port)
# instead of the reconstructed https:// fallback URL.
original_ssh_url: Optional[str] = None

# Supported file extensions for virtual packages
VIRTUAL_FILE_EXTENSIONS = (
".prompt.md",
Expand Down Expand Up @@ -904,6 +909,10 @@ def parse(cls, dependency_str: str) -> "DependencyReference":
)
)

# Preserve the original ssh:// URL verbatim before normalization so the
# downloader can clone with the exact user-supplied URL (e.g. custom port).
original_ssh_url = dependency_str if dependency_str.startswith("ssh://") else None

dependency_str = cls._normalize_ssh_protocol_url(dependency_str)
Comment thread
edenfunf marked this conversation as resolved.
Outdated

# Phase 1: detect virtual packages
Expand Down Expand Up @@ -986,6 +995,7 @@ def parse(cls, dependency_str: str) -> "DependencyReference":
ado_project=ado_project,
ado_repo=ado_repo,
artifactory_prefix=artifactory_prefix,
original_ssh_url=original_ssh_url,
)

def to_github_url(self) -> str:
Expand Down
78 changes: 78 additions & 0 deletions tests/unit/test_auth_scoping.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,84 @@ def test_generic_host_error_message_mentions_credential_helpers(self):
shutil.rmtree(target, ignore_errors=True)


# ===========================================================================
# Regression: ssh:// URLs with custom ports (issue #661)
# ===========================================================================

class TestCloneWithFallbackSshUrl:
"""Verify that an explicit ssh:// URL is passed verbatim to git clone.

Regression for #661: Bitbucket Datacenter uses custom SSH ports (e.g.
7999). APM was stripping the port during normalisation and then falling
back to https://. The fix stores the original url in
DependencyReference.original_ssh_url and uses it in Method 2 of
_clone_with_fallback so the port is never silently dropped.
"""

def _run_clone_capture_urls(self, dep):
"""Run _clone_with_fallback and return every URL passed to clone_from."""
mock_repo = Mock()
mock_repo.head.commit.hexsha = "abc123"
dl = _make_downloader()
dl.auth_resolver._cache.clear()

called_urls = []

def _fake_clone(url, *a, **kw):
called_urls.append(url)
return mock_repo

with patch.dict(os.environ, {}, clear=True), \
patch(
"apm_cli.core.token_manager.GitHubTokenManager.resolve_credential_from_git",
return_value=None,
), \
patch('apm_cli.deps.github_downloader.Repo') as MockRepo:
MockRepo.clone_from.side_effect = _fake_clone
target = Path(tempfile.mkdtemp())
try:
dl._clone_with_fallback(dep.repo_url, target, dep_ref=dep)
except (RuntimeError, Exception):
Comment thread
edenfunf marked this conversation as resolved.
Outdated
pass
finally:
import shutil
shutil.rmtree(target, ignore_errors=True)
return called_urls

def test_bitbucket_datacenter_ssh_with_port_used_verbatim(self):
"""The first clone attempt must use the exact ssh:// URL including port."""
original = "ssh://git@bitbucket.domain.ext:7999/project/repo.git"
dep = _dep(original)

assert dep.original_ssh_url == original, "original_ssh_url not stored"

urls = self._run_clone_capture_urls(dep)
assert len(urls) >= 1
assert urls[0] == original, (
f"Expected first clone URL to be the original ssh:// URL, got: {urls[0]!r}"
)

def test_bitbucket_datacenter_ssh_no_https_attempted_first(self):
"""APM must not attempt https:// before the explicit ssh:// URL."""
original = "ssh://git@bitbucket.domain.ext:7999/project/repo.git"
dep = _dep(original)

urls = self._run_clone_capture_urls(dep)
assert len(urls) >= 1
assert not urls[0].startswith("https://"), (
f"First clone attempt must not be https://, got: {urls[0]!r}"
)

def test_standard_ssh_url_without_port_also_preserved(self):
"""ssh:// without a custom port is also used verbatim."""
original = "ssh://git@github.com/org/repo.git"
dep = _dep(original)

urls = self._run_clone_capture_urls(dep)
assert len(urls) >= 1
assert urls[0] == original


# ===========================================================================
# Object-style dependency entries (parse_from_dict)
# ===========================================================================
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/test_generic_git_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,48 @@ def test_git_at_url_unchanged(self):
assert result == "git@gitlab.com:acme/repo.git"


class TestBitbucketDatacenterSSH:
"""Regression tests for issue #661: ssh:// URLs with custom ports must be preserved.

Bitbucket Datacenter (and other self-hosted instances) commonly use non-standard
SSH ports (e.g. 7999). When a user explicitly specifies an ssh:// URL in apm.yml
the original URL must be kept verbatim so git clones against the correct port
instead of silently falling back to HTTPS.
"""

def test_preserve_bitbucket_datacenter_ssh_url_with_port(self):
"""ssh:// URL with custom port must be stored in original_ssh_url."""
url = "ssh://git@bitbucket.domain.ext:7999/project/repo.git"
dep = DependencyReference.parse(url)
assert dep.original_ssh_url == url

def test_bitbucket_datacenter_host_and_repo_still_parsed(self):
"""Parsed host/repo_url fields should still be populated correctly."""
dep = DependencyReference.parse(
"ssh://git@bitbucket.domain.ext:7999/project/repo.git"
)
assert dep.host == "bitbucket.domain.ext"
assert dep.repo_url == "project/repo"

def test_preserve_standard_ssh_protocol_url(self):
"""ssh:// without a port also stores the original URL."""
url = "ssh://git@github.com/org/repo.git"
dep = DependencyReference.parse(url)
assert dep.original_ssh_url == url

def test_https_url_does_not_set_original_ssh_url(self):
"""HTTPS dependencies must not set original_ssh_url."""
dep = DependencyReference.parse(
"https://bitbucket.domain.ext/scm/project/repo.git"
)
assert dep.original_ssh_url is None

def test_git_at_url_does_not_set_original_ssh_url(self):
"""git@ SSH shorthand does not go through ssh:// normalisation."""
dep = DependencyReference.parse("git@bitbucket.org:acme/rules.git")
assert dep.original_ssh_url is None


class TestCloneURLBuilding:
"""Test that clone URLs are correctly built for generic hosts."""

Expand Down