From b91449590d7fa5a1e6a415539f7b242095fe23c3 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:45:27 +0200 Subject: [PATCH] fix: follow an upstream the port vendors as module libretro/TIC-80 builds nesbox/TIC-80 through its core submodule and tic80's upstream_commit is that submodule commit. Compared to nesbox HEAD, profile_sync reported changes the shipped core never had, and a bump would have moved the pin past it. When an upstream pin is a submodule pointer of the source pin, its head is the pointer at the source HEAD. --- scripts/profile_sync.py | 41 ++++++++++++++++++++++++++++++++++++-- scripts/upstream.py | 39 ++++++++++++++++++++++++++++++++++++ tests/test_profile_sync.py | 36 +++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/scripts/profile_sync.py b/scripts/profile_sync.py index be0623f1..bff81387 100644 --- a/scripts/profile_sync.py +++ b/scripts/profile_sync.py @@ -21,7 +21,7 @@ import subprocess import sys import tempfile from collections.abc import Callable, Iterable, Iterator, Sequence -from dataclasses import asdict, dataclass, field +from dataclasses import asdict, dataclass, field, replace from datetime import date from pathlib import Path from typing import Any @@ -1293,7 +1293,44 @@ def select_views( continue seen.add((repo.host, repo.slug)) views.append(RepoView(repo, pin, head, origin, field)) - return views + return _follow_submodule(views, cache_dir, offline) + + +def _follow_submodule( + views: list[RepoView], cache_dir: str, offline: bool +) -> list[RepoView]: + """An upstream the port vendors as a submodule moves with the port. + + libretro/TIC-80 builds nesbox/TIC-80 through its `core` submodule, and + upstream_commit was that submodule's commit. Compared to nesbox's own + HEAD, the view reported changes the shipped core never had, and a bump + would have moved upstream_commit past what it builds. The commit the + port's HEAD points the submodule at is the upstream HEAD that matters. + """ + source = next((v for v in views if v.field == "source"), None) + behind = [v for v in views if v.field == "upstream" and v.pin != v.head] + if source is None or not behind: + return views + pinned = { + sha: path + for path, sha in upstream.list_gitlinks( + source.repo, source.pin, cache_dir, offline + ).items() + } + if not pinned: + return views + followed: list[RepoView] = [] + for view in views: + path = pinned.get(view.pin) if view.field == "upstream" else None + head = ( + upstream.list_gitlinks(source.repo, source.head, cache_dir, offline).get(path) + if path else None + ) + followed.append( + replace(view, head=head, origin=f"{view.origin} via {path}") + if head else view + ) + return followed def declared_repositories(profile: dict) -> list[tuple[str, str, str]]: diff --git a/scripts/upstream.py b/scripts/upstream.py index 253629bd..422d6cec 100644 --- a/scripts/upstream.py +++ b/scripts/upstream.py @@ -673,6 +673,45 @@ def list_tree( return paths, bool(payload.get("truncated")) +def list_gitlinks( + repo: Repo, sha: str, cache_dir: str, offline: bool = False +) -> dict[str, str]: + """Submodule path -> the commit it points at, at one revision. + + Read from the same tree listing as list_tree, so the call costs nothing + once the tree is cached. + """ + if repo.family == "gitlab": + links: dict[str, str] = {} + for page in range(1, MAX_TREE_PAGES + 1): + url = ( + f"{repo.api_base}/projects/{_project(repo)}/repository/tree" + f"?ref={sha}&recursive=true&per_page={TREE_PAGE_SIZE}&page={page}" + ) + payload = _api(url, cache_dir, offline) + if not isinstance(payload, list): + break + links.update( + (entry["path"], entry["id"]) + for entry in payload + if isinstance(entry, dict) and entry.get("type") == "commit" + and entry.get("path") and entry.get("id") + ) + if len(payload) < TREE_PAGE_SIZE: + break + return links + url = _tree_url(repo, sha) + payload = _api(url, cache_dir, offline) if url else None + if not isinstance(payload, dict): + return {} + return { + entry["path"]: entry["sha"] + for entry in payload.get("tree") or [] + if isinstance(entry, dict) and entry.get("type") == "commit" + and entry.get("path") and entry.get("sha") + } + + def commit_date(repo: Repo, sha: str, cache_dir: str, offline: bool = False) -> str | None: """When a revision was committed, as the forge reports it (ISO 8601).""" if repo.family == "gitlab": diff --git a/tests/test_profile_sync.py b/tests/test_profile_sync.py index e9f7d059..3d5f62cd 100644 --- a/tests/test_profile_sync.py +++ b/tests/test_profile_sync.py @@ -1155,8 +1155,13 @@ class TestBuildReport(unittest.TestCase): profile_sync.upstream.tag_commit, profile_sync.upstream._http_json, profile_sync.upstream._http_text, + profile_sync.upstream.list_gitlinks, ) self.tags: list[str] = [] + self.gitlinks: dict[str, dict[str, str]] = {} + profile_sync.upstream.list_gitlinks = ( + lambda repo, sha, cache_dir, offline=False: self.gitlinks.get(sha, {}) + ) self.tag_commits: dict[str, str] = {} # Any code path reaching the HTTP layer is a leak, not a slow test. def _no_network(url): @@ -1205,6 +1210,7 @@ class TestBuildReport(unittest.TestCase): profile_sync.upstream.tag_commit, profile_sync.upstream._http_json, profile_sync.upstream._http_text, + profile_sync.upstream.list_gitlinks, ) = self._orig self.tmp.cleanup() @@ -3896,5 +3902,35 @@ class TestEmulatorsDirStatus(unittest.TestCase): profile_sync.emulators_dir_is_dirty(root) +class UpstreamVendoredAsASubmodule(unittest.TestCase): + """libretro/TIC-80 builds nesbox/TIC-80 through its `core` submodule, and + tic80's upstream_commit is that submodule's commit. Compared to nesbox's + own HEAD it reported changes the shipped core never had, and a bump would + have moved upstream_commit past what libretro builds.""" + + def _views(self, links: dict) -> list: + from unittest import mock # noqa: PLC0415 + + repo_s, repo_u = object(), object() + views = [ + profile_sync.RepoView(repo_s, "src-pin", "src-head", "source_commit", "source"), + profile_sync.RepoView(repo_u, "sub-old", "nesbox-tip", "upstream_commit", "upstream"), + ] + with mock.patch.object( + profile_sync.upstream, "list_gitlinks", + side_effect=lambda repo, sha, *a, **k: links.get(sha, {}), + ): + return profile_sync._follow_submodule(views, "cache", True) + + def test_the_upstream_head_is_what_the_port_head_builds(self): + views = self._views({"src-pin": {"core": "sub-old"}, "src-head": {"core": "sub-new"}}) + self.assertEqual(views[1].head, "sub-new") + self.assertEqual(views[0].head, "src-head") + + def test_an_upstream_the_port_does_not_vendor_keeps_its_tip(self): + views = self._views({"src-pin": {"deps/zlib": "other"}, "src-head": {}}) + self.assertEqual(views[1].head, "nesbox-tip") + + if __name__ == "__main__": unittest.main()