diff --git a/src/vcspull/__init__.py b/src/vcspull/__init__.py index 5c9da904..1157e4d0 100644 --- a/src/vcspull/__init__.py +++ b/src/vcspull/__init__.py @@ -12,5 +12,6 @@ from logging import NullHandler from . import cli +from .url import enable_ssh_style_url_detection # Import custom URL handling logging.getLogger(__name__).addHandler(NullHandler()) diff --git a/src/vcspull/cli/sync.py b/src/vcspull/cli/sync.py index 1f754887..fc49b0da 100644 --- a/src/vcspull/cli/sync.py +++ b/src/vcspull/cli/sync.py @@ -12,6 +12,7 @@ from vcspull import exc from vcspull.config import filter_repos, find_config_files, load_configs +from vcspull.url import enable_ssh_style_url_detection if t.TYPE_CHECKING: import argparse @@ -147,6 +148,8 @@ def update_repo( # repo_dict: Dict[str, Union[str, Dict[str, GitRemote], pathlib.Path]] ) -> GitSync: """Synchronize a single repository.""" + # Ensure SSH-style URLs are recognized as explicit Git URLs + enable_ssh_style_url_detection() repo_dict = deepcopy(repo_dict) if "pip_url" not in repo_dict: repo_dict["pip_url"] = repo_dict.pop("url") diff --git a/src/vcspull/url.py b/src/vcspull/url.py new file mode 100644 index 00000000..2812bfc9 --- /dev/null +++ b/src/vcspull/url.py @@ -0,0 +1,113 @@ +"""URL handling for vcspull.""" + +from __future__ import annotations + +from typing import Any + +from libvcs.url.git import DEFAULT_RULES + +_orig_rule_meta: dict[str, tuple[bool, int]] = {} + + +def enable_ssh_style_url_detection() -> None: + """Enable detection of SSH-style URLs as explicit Git URLs. + + This makes the core-git-scp rule explicit, which allows URLs like + 'user@hostname:path/to/repo.git' to be detected with is_explicit=True. + + Examples + -------- + >>> from vcspull.url import enable_ssh_style_url_detection + >>> from libvcs.url.git import GitURL + >>> # Without the patch + >>> GitURL.is_valid('user@hostname:path/to/repo.git', is_explicit=True) + False + >>> # With the patch + >>> enable_ssh_style_url_detection() + >>> GitURL.is_valid('user@hostname:path/to/repo.git', is_explicit=True) + True + """ + # Patch the core-git-scp rule, storing its original state if not already stored + for rule in DEFAULT_RULES: + if rule.label == "core-git-scp": + if rule.label not in _orig_rule_meta: + _orig_rule_meta[rule.label] = (rule.is_explicit, rule.weight) + rule.is_explicit = True + rule.weight = 100 + break + + +def disable_ssh_style_url_detection() -> None: + """Disable detection of SSH-style URLs as explicit Git URLs. + + This reverts the core-git-scp rule to its original state, where URLs like + 'user@hostname:path/to/repo.git' are not detected with is_explicit=True. + + Examples + -------- + >>> from vcspull.url import enable_ssh_style_url_detection + >>> from vcspull.url import disable_ssh_style_url_detection + >>> from libvcs.url.git import GitURL + >>> # Enable the patch + >>> enable_ssh_style_url_detection() + >>> GitURL.is_valid('user@hostname:path/to/repo.git', is_explicit=True) + True + >>> # Disable the patch + >>> disable_ssh_style_url_detection() + >>> GitURL.is_valid('user@hostname:path/to/repo.git', is_explicit=True) + False + """ + # Restore the core-git-scp rule to its original state, if known + for rule in DEFAULT_RULES: + if rule.label == "core-git-scp": + orig = _orig_rule_meta.get(rule.label) + if orig: + rule.is_explicit, rule.weight = orig + _orig_rule_meta.pop(rule.label, None) + else: + # Fallback to safe defaults + rule.is_explicit = False + rule.weight = 0 + break + + +def is_ssh_style_url_detection_enabled() -> bool: + """Check if SSH-style URL detection is enabled. + + Returns + ------- + bool: True if SSH-style URL detection is enabled, False otherwise. + """ + for rule in DEFAULT_RULES: + if rule.label == "core-git-scp": + return rule.is_explicit + return False + + +""" +Context manager and utility for SSH-style URL detection. +""" + + +class ssh_style_url_detection: + """Context manager to enable/disable SSH-style URL detection.""" + + def __init__(self, enabled: bool = True) -> None: + self.enabled = enabled + + def __enter__(self) -> None: + """Enable or disable SSH-style URL detection on context enter.""" + if self.enabled: + enable_ssh_style_url_detection() + else: + disable_ssh_style_url_detection() + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: Any, + ) -> None: + """Restore original SSH-style URL detection state on context exit.""" + # Always restore to disabled after context + disable_ssh_style_url_detection() diff --git a/tests/test_sync.py b/tests/test_sync.py index e7a379ed..f4d09ccf 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -133,6 +133,35 @@ class ConfigVariationTest(t.NamedTuple): """, remote_list=["git_scheme_repo"], ), + ConfigVariationTest( + test_id="expanded_repo_style_with_unprefixed_remote_3", + config_tpl=""" + {tmp_path}/study/myrepo: + {CLONE_NAME}: + repo: git+file://{path} + remotes: + git_scheme_repo: user@myhostname.de:org/repo.git + """, + remote_list=["git_scheme_repo"], + ), + ConfigVariationTest( + test_id="expanded_repo_style_with_unprefixed_repo", + config_tpl=""" + {tmp_path}/study/myrepo: + {CLONE_NAME}: + repo: user@myhostname.de:org/repo.git + """, + remote_list=["git_scheme_repo"], + ), + ConfigVariationTest( + test_id="expanded_repo_style_with_prefixed_repo_3_with_prefix", + config_tpl=""" + {tmp_path}/study/myrepo: + {CLONE_NAME}: + repo: git+ssh://user@myhostname.de:org/repo.git + """, + remote_list=["git_scheme_repo"], + ), ] diff --git a/tests/test_url.py b/tests/test_url.py new file mode 100644 index 00000000..854ed4b5 --- /dev/null +++ b/tests/test_url.py @@ -0,0 +1,141 @@ +"""Tests for URL handling in vcspull.""" + +from __future__ import annotations + +import pytest +from libvcs.url.git import DEFAULT_RULES, GitURL + +from vcspull.url import ( + disable_ssh_style_url_detection, + enable_ssh_style_url_detection, + ssh_style_url_detection, +) + + +def test_ssh_style_url_detection_toggle() -> None: + """Test that SSH-style URL detection can be toggled on and off.""" + url = "user@myhostname.de:org/repo.git" + + # First, disable the detection + disable_ssh_style_url_detection() + + # Without the patch, SSH-style URLs should not be detected as explicit + assert GitURL.is_valid(url) # Should be valid in non-explicit mode + assert not GitURL.is_valid( + url, is_explicit=True + ) # Should not be valid in explicit mode + + # Now enable the detection + enable_ssh_style_url_detection() + + # With the patch, SSH-style URLs should be detected as explicit + assert GitURL.is_valid(url) # Should still be valid in non-explicit mode + assert GitURL.is_valid( + url, is_explicit=True + ) # Should now be valid in explicit mode + + # Verify the rule used + git_url = GitURL(url) + assert git_url.rule == "core-git-scp" + + # Re-enable for other tests + enable_ssh_style_url_detection() + + +@pytest.mark.parametrize( + "url", + [ + "user@myhostname.de:org/repo.git", + "git@github.com:vcs-python/vcspull.git", + "git@gitlab.com:vcs-python/vcspull.git", + "user@custom-host.com:path/to/repo.git", + ], +) +def test_ssh_style_url_detection(url: str) -> None: + """Test that SSH-style URLs are correctly detected.""" + # Ensure detection is enabled + enable_ssh_style_url_detection() + + assert GitURL.is_valid(url) + assert GitURL.is_valid(url, is_explicit=True) # Should be valid in explicit mode + git_url = GitURL(url) + assert git_url.rule == "core-git-scp" + + +@pytest.mark.parametrize( + "url,expected_user,expected_hostname,expected_path", + [ + ( + "user@myhostname.de:org/repo.git", + "user", + "myhostname.de", + "org/repo", + ), + ( + "git@github.com:vcs-python/vcspull.git", + "git", + "github.com", + "vcs-python/vcspull", + ), + ( + "git@gitlab.com:vcs-python/vcspull.git", + "git", + "gitlab.com", + "vcs-python/vcspull", + ), + ( + "user@custom-host.com:path/to/repo.git", + "user", + "custom-host.com", + "path/to/repo", + ), + ], +) +def test_ssh_style_url_parsing( + url: str, expected_user: str, expected_hostname: str, expected_path: str +) -> None: + """Test that SSH-style URLs are correctly parsed.""" + # Ensure detection is enabled + enable_ssh_style_url_detection() + + git_url = GitURL(url) + assert git_url.user == expected_user + assert git_url.hostname == expected_hostname + assert git_url.path == expected_path + assert git_url.suffix == ".git" + + +def test_enable_disable_restores_original_state() -> None: + """Original rule metadata is preserved and restored after enable/disable.""" + # Ensure any prior patch is cleared + disable_ssh_style_url_detection() + # Find the core-git-scp rule and capture its original state + rule = next(r for r in DEFAULT_RULES if r.label == "core-git-scp") + orig_state = (rule.is_explicit, rule.weight) + + # Disabling without prior enable should leave original state + disable_ssh_style_url_detection() + assert (rule.is_explicit, rule.weight) == orig_state + + # Enable should patch + enable_ssh_style_url_detection() + assert rule.is_explicit is True + assert rule.weight == 100 + + # Disable should restore to original + disable_ssh_style_url_detection() + assert (rule.is_explicit, rule.weight) == orig_state + + +def test_context_manager_restores_original_state() -> None: + """Context manager enables then restores original rule state.""" + rule = next(r for r in DEFAULT_RULES if r.label == "core-git-scp") + orig_state = (rule.is_explicit, rule.weight) + + # Use context manager + with ssh_style_url_detection(): + assert rule.is_explicit is True + assert rule.weight == 100 + + # After context, state should be back to original + assert (rule.is_explicit, rule.weight) == orig_state