diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 94ce400a..c313370c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,12 @@ +Release 0.14.1 (unreleased) +=========================== + +* Fix ``dfetch import`` mangling the namespace of a generic VCS URL whose path contains ``.git`` other than as a suffix +* Fix ``Version`` comparison raising ``AttributeError`` when compared against a non-``Version`` object +* Fix ``dfetch add`` matching a remote whose base URL is only a string prefix (not a path prefix) of the project URL +* Fix git ref resolution spuriously matching the first reference for an empty revision +* Strip the trailing newline from the git origin URL returned by ``get_remote_url`` + Release 0.14.0 (released 2026-06-14) =========================== diff --git a/dfetch/manifest/manifest.py b/dfetch/manifest/manifest.py index e98bde02..953cb76e 100644 --- a/dfetch/manifest/manifest.py +++ b/dfetch/manifest/manifest.py @@ -592,7 +592,7 @@ def find_remote_for_url(self, remote_url: str) -> Remote | None: target = remote_url.rstrip("/") for remote in self.remotes: remote_base = remote.url.rstrip("/") - if target.startswith(remote_base): + if target == remote_base or target.startswith(remote_base + "/"): return remote return None diff --git a/dfetch/manifest/version.py b/dfetch/manifest/version.py index 6d7299fb..e53cd841 100644 --- a/dfetch/manifest/version.py +++ b/dfetch/manifest/version.py @@ -16,7 +16,7 @@ class Version(NamedTuple): def __eq__(self, other: Any) -> bool: """Check if two versions can be considered as equal.""" - if not other: + if not isinstance(other, Version): return False if self.tag or other.tag: @@ -24,6 +24,10 @@ def __eq__(self, other: Any) -> bool: return bool(self.branch == other.branch and self.revision == other.revision) + def __hash__(self) -> int: + """Hash based on the underlying tuple value.""" + return tuple.__hash__(self) + @property def field(self) -> tuple[str, str]: """Return ``(kind, value)`` for the active field: tag, revision, or branch.""" diff --git a/dfetch/util/purl.py b/dfetch/util/purl.py index f5829a7f..ff573ae2 100644 --- a/dfetch/util/purl.py +++ b/dfetch/util/purl.py @@ -101,7 +101,7 @@ def _vcs_namespace_and_name(remote_url: str) -> tuple[str, str, str]: remote_url = f"ssh://{parsed.path.replace(':', '/')}" else: namespace, name = _namespace_and_name_from_domain_and_path( - remote_url, path.replace(".git", "") + remote_url, path.removesuffix(".git") ) return namespace, name, remote_url diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index 1cd3bd18..4dce4b5b 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -277,6 +277,8 @@ def _find_branch_tip_or_tag_from_sha( ) -> tuple[str, str]: """Check all branch tips and tags and see if the sha is one of them.""" branch, tag = "", "" + if not rev: + return (branch, tag) for reference, sha in info.items(): if sha.startswith(rev): # Also allow for shorter SHA's if reference.startswith("refs/tags/"): @@ -680,7 +682,7 @@ def get_remote_url() -> str: """Get the url of the remote origin.""" try: result = run_on_cmdline(logger, ["git", "remote", "get-url", "origin"]) - decoded_result = str(result.stdout.decode()) + decoded_result = str(result.stdout.decode()).strip() except SubprocessCommandError: decoded_result = "" diff --git a/tests/test_add.py b/tests/test_add.py index 7796290a..8ad22511 100644 --- a/tests/test_add.py +++ b/tests/test_add.py @@ -151,6 +151,26 @@ def test_determine_remote_returns_none_for_empty_remotes(): assert result is None +def test_determine_remote_requires_path_boundary(): + """An org-scoped remote must not match a different org sharing its prefix.""" + m = Mock() + m.remotes = [_make_remote("myorg", "https://github.com/myorg")] + result = Manifest.find_remote_for_url( + m, "https://github.com/myorg-private/repo.git" + ) + assert result is None + + +def test_determine_remote_matches_exact_and_subpath(): + """The boundary check still matches the remote itself and any URL beneath it.""" + m = Mock() + m.remotes = [_make_remote("myorg", "https://github.com/myorg")] + assert Manifest.find_remote_for_url(m, "https://github.com/myorg") is not None + assert ( + Manifest.find_remote_for_url(m, "https://github.com/myorg/repo.git") is not None + ) + + # --------------------------------------------------------------------------- # Add command – non-interactive # --------------------------------------------------------------------------- diff --git a/tests/test_git_vcs.py b/tests/test_git_vcs.py index 4b6c1ca1..03f97dcf 100644 --- a/tests/test_git_vcs.py +++ b/tests/test_git_vcs.py @@ -318,6 +318,22 @@ def test_ls_remote(): assert info == expected +def test_get_remote_url_strips_trailing_newline(): + """git remote get-url appends a newline that must not leak into the URL.""" + with patch("dfetch.vcs.git.run_on_cmdline") as run_on_cmdline_mock: + run_on_cmdline_mock.return_value.stdout = b"https://github.com/org/repo.git\n" + assert GitLocalRepo.get_remote_url() == "https://github.com/org/repo.git" + + +def test_find_branch_tip_or_tag_from_sha_empty_rev_matches_nothing(): + """An empty revision must not spuriously match the first reference.""" + info = { + "refs/heads/master": "33d11e10699bae03ba2a58a280e92494f4fa0d82", + "refs/tags/v1.0": "0e3b216c7ab365b67765e94aeb45085c4db029e0", + } + assert GitRemote._find_branch_tip_or_tag_from_sha(info, "") == ("", "") + + @pytest.mark.parametrize( "name, env_ssh, git_config_ssh, expected", [ diff --git a/tests/test_project_version.py b/tests/test_project_version.py index 0c6a0338..bd074d69 100644 --- a/tests/test_project_version.py +++ b/tests/test_project_version.py @@ -202,3 +202,22 @@ def test_version_comparison(name, version_1, version_2, expected_equality): actual_equality = version_1 == version_2 assert actual_equality == expected_equality + + +@pytest.mark.parametrize( + "other", + [ + ("", "main", "123"), # a plain tuple + "main", # a string + 42, # an int + ], +) +def test_version_comparison_with_non_version(other): + """Comparing a Version with a non-Version must not crash.""" + assert (Version(branch="main", revision="123") == other) is False + + +def test_version_remains_hashable(): + """Defining __eq__ must not break hashing/set membership.""" + version = Version(tag="1.0") + assert version in {version} diff --git a/tests/test_purl.py b/tests/test_purl.py index 80699f5b..78b76cf2 100644 --- a/tests/test_purl.py +++ b/tests/test_purl.py @@ -115,6 +115,11 @@ "git://git.git.savannah.gnu.org/automake.git", "pkg:generic/automake?vcs_url=git://git.git.savannah.gnu.org/automake.git", ), + # A ".git" substring inside the path must not be stripped (only the suffix) + ( + "https://gitlab.com/group/foo.github/project.git", + "pkg:generic/group/foo.github/project?vcs_url=https://gitlab.com/group/foo.github/project.git", + ), # Trailing slash – issue #1137 ("https://github.com/cpputest/cpputest/", "pkg:github/cpputest/cpputest"), ("https://github.com/dfetch-org/dfetch/", "pkg:github/dfetch-org/dfetch"),