From db1ac1167daaafa80ba83465be511751ac17756f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 05:47:32 +0000 Subject: [PATCH 1/5] Fix purl namespace corruption when path contains ".git" mid-path The generic VCS branch in _vcs_namespace_and_name used str.replace to strip a ".git" suffix from the path, which silently removed every occurrence anywhere in the path. A URL like https://gitlab.com/group/foo.github/project.git produced the namespace "group/foohub" instead of "group/foo.github". Use removesuffix so only the trailing .git is stripped. https://claude.ai/code/session_01KKvrvnVvsBChohuxbRRmzA --- CHANGELOG.rst | 5 +++++ dfetch/util/purl.py | 2 +- tests/test_purl.py | 5 +++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 94ce400a4..a1f8c15be 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,8 @@ +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 + Release 0.14.0 (released 2026-06-14) =========================== diff --git a/dfetch/util/purl.py b/dfetch/util/purl.py index f5829a7f3..ff573ae2d 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/tests/test_purl.py b/tests/test_purl.py index 80699f5b2..78b76cf20 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"), From 0d3defd910c807041987a134ec366865f948a246 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 05:48:22 +0000 Subject: [PATCH 2/5] Fix Version.__eq__ crashing when compared with a non-Version object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallthrough guard only handled falsy operands, so comparing a Version to any truthy non-Version (a plain tuple with matching arity, a string, an int) raised AttributeError when trying to read other.tag. Replace the guard with an isinstance check returning False — Version equality is domain-specific (tag has precedence over branch/revision) and should not silently fall back to tuple equality. Add an explicit __hash__ to keep the class hashable under pyright's static rules. https://claude.ai/code/session_01KKvrvnVvsBChohuxbRRmzA --- CHANGELOG.rst | 1 + dfetch/manifest/version.py | 6 +++++- tests/test_project_version.py | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a1f8c15be..5bcc8d8fe 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,7 @@ 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 Release 0.14.0 (released 2026-06-14) =========================== diff --git a/dfetch/manifest/version.py b/dfetch/manifest/version.py index 6d7299fb7..e53cd841d 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/tests/test_project_version.py b/tests/test_project_version.py index 0c6a03387..bd074d69c 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} From 5f01197b76d719cf2c3cc3008c03999ba4e4be74 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 05:49:14 +0000 Subject: [PATCH 3/5] Fix find_remote_for_url matching across path boundaries target.startswith(remote_base) matched any URL whose host+path happened to share a leading string with the remote, regardless of path component boundaries. A remote base https://github.com/myorg therefore matched an unrelated URL https://github.com/myorg-private/repo, and dfetch add would attach the wrong remote (and compute a bogus repo-path). Require either an exact match or a "/" path boundary. https://claude.ai/code/session_01KKvrvnVvsBChohuxbRRmzA --- CHANGELOG.rst | 1 + dfetch/manifest/manifest.py | 2 +- tests/test_add.py | 20 ++++++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5bcc8d8fe..66df349fb 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,6 +3,7 @@ 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 Release 0.14.0 (released 2026-06-14) =========================== diff --git a/dfetch/manifest/manifest.py b/dfetch/manifest/manifest.py index e98bde027..953cb76ec 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/tests/test_add.py b/tests/test_add.py index 7796290a2..8ad225117 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 # --------------------------------------------------------------------------- From 265943f9930fa3de238a21a842a4d06311a12f40 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 19:55:26 +0000 Subject: [PATCH 4/5] Guard git ref resolution against an empty revision sha.startswith(rev) matched the first reference unconditionally when rev was an empty string, returning a bogus branch/tag tuple for any caller that passed in an uninitialised SHA. Return early when rev is empty. https://claude.ai/code/session_01KKvrvnVvsBChohuxbRRmzA --- CHANGELOG.rst | 1 + dfetch/vcs/git.py | 2 ++ tests/test_git_vcs.py | 9 +++++++++ 3 files changed, 12 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 66df349fb..fde5e182e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,7 @@ 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 Release 0.14.0 (released 2026-06-14) =========================== diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index 1cd3bd189..7d188a614 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/"): diff --git a/tests/test_git_vcs.py b/tests/test_git_vcs.py index 4b6c1ca1d..341da0430 100644 --- a/tests/test_git_vcs.py +++ b/tests/test_git_vcs.py @@ -318,6 +318,15 @@ def test_ls_remote(): assert info == expected +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", [ From 29590cfa5b50ea09bd6695ed7eb0ed7413ba7714 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 05:51:13 +0000 Subject: [PATCH 5/5] Strip trailing newline from get_remote_url git remote get-url emits the URL with a trailing newline. The decoded result was returned verbatim, leaving the newline embedded in the URL string used by submodule URL resolution and downstream string operations. https://claude.ai/code/session_01KKvrvnVvsBChohuxbRRmzA --- CHANGELOG.rst | 1 + dfetch/vcs/git.py | 2 +- tests/test_git_vcs.py | 7 +++++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fde5e182e..c313370ce 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,7 @@ Release 0.14.1 (unreleased) * 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/vcs/git.py b/dfetch/vcs/git.py index 7d188a614..4dce4b5b7 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -682,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_git_vcs.py b/tests/test_git_vcs.py index 341da0430..03f97dcfd 100644 --- a/tests/test_git_vcs.py +++ b/tests/test_git_vcs.py @@ -318,6 +318,13 @@ 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 = {