diff --git a/scripts/profile_sync.py b/scripts/profile_sync.py index 50577df2..2bcdc5db 100644 --- a/scripts/profile_sync.py +++ b/scripts/profile_sync.py @@ -1104,7 +1104,7 @@ def select_views( continue seen.add((repo.host, repo.slug)) views.append(RepoView(repo, pin, head, origin, field)) - return _follow_submodule(views, cache_dir, offline) + return _follow_fork_base(_follow_submodule(views, cache_dir, offline), cache_dir, offline) def _follow_submodule( @@ -1144,6 +1144,34 @@ def _follow_submodule( return followed +def _follow_fork_base( + views: list[RepoView], cache_dir: str, offline: bool +) -> list[RepoView]: + """An upstream the port is a fork of moves with what the port merged. + + snes9x, kronos, np2kai and six others pinned upstream_commit on the merge + base of upstream with the fork, the upstream code the port carries. Read + against upstream's own HEAD, the bump moved it past anything the port + built from. Outside one fork network the HEAD stays the reference. + """ + source = next((v for v in views if v.field == "source"), None) + if source is None: + return views + followed: list[RepoView] = [] + for view in views: + if view.field != "upstream" or view.pin == view.head or " via " in view.origin: + followed.append(view) + continue + base = upstream.fork_base( + view.repo, view.head, source.repo, source.head, cache_dir, offline + ) + followed.append( + replace(view, head=base, origin=f"{view.origin}, fork base of {source.repo.slug}") + if base else view + ) + return followed + + def declared_repositories(profile: dict) -> list[tuple[str, str, str]]: """Every repository URL the profile declares, source before upstream. diff --git a/scripts/upstream.py b/scripts/upstream.py index bb652fe2..1c225600 100644 --- a/scripts/upstream.py +++ b/scripts/upstream.py @@ -840,6 +840,26 @@ def _commits_ahead(payload: object, family: str) -> int | None: return value if isinstance(value, int) else None +def fork_base( + repo: Repo, sha: str, fork: Repo, fork_sha: str, cache_dir: str, + offline: bool = False, +) -> str | None: + """The commit of ``repo`` that ``fork`` at ``fork_sha`` has merged up to. + + GitHub compares across a fork network and names the merge base. Two + repositories outside one network answer 404, and None is returned. + """ + if repo.family != "github" or fork.family != "github": + return None + payload = _api( + f"{repo.api_base}/repos/{repo.slug}/compare/" + f"{sha}...{fork.owner}:{fork.name}:{fork_sha}", + cache_dir, offline, + ) + base = payload.get("merge_base_commit") if isinstance(payload, dict) else None + return base.get("sha") if isinstance(base, dict) else None + + def ancestry( repo: Repo, base: str, head: str, cache_dir: str, offline: bool = False ) -> tuple[int, int] | None: diff --git a/tests/test_profile_sync.py b/tests/test_profile_sync.py index 10c2f780..f81b3eb5 100644 --- a/tests/test_profile_sync.py +++ b/tests/test_profile_sync.py @@ -1181,6 +1181,12 @@ class TestBuildReport(unittest.TestCase): profile_sync.upstream._http_text, profile_sync.upstream.list_gitlinks, profile_sync.upstream.current_slug, + profile_sync.upstream.fork_base, + ) + self.fork_bases: dict[str, str] = {} + profile_sync.upstream.fork_base = ( + lambda repo, sha, fork, fork_sha, cache_dir, offline=False: + self.fork_bases.get(fork_sha) ) self.current_slugs: dict[str, str] = {} profile_sync.upstream.current_slug = ( @@ -1243,6 +1249,7 @@ class TestBuildReport(unittest.TestCase): profile_sync.upstream._http_text, profile_sync.upstream.list_gitlinks, profile_sync.upstream.current_slug, + profile_sync.upstream.fork_base, ) = self._orig self.tmp.cleanup() @@ -4789,6 +4796,39 @@ class UpstreamVendoredAsASubmodule(unittest.TestCase): self.assertEqual(views[1].head, "nesbox-tip") +class UpstreamForkedByThePort(unittest.TestCase): + """snes9x, kronos and np2kai pinned upstream_commit on the merge base of + upstream with the fork. Compared to upstream's own HEAD, a bump moved it + past anything the port carries.""" + + def _views(self, base): + from unittest import mock # noqa: PLC0415 + + fork = profile_sync.upstream.make_repo("github.com", "f", "port") + self.up = profile_sync.upstream.make_repo("github.com", "o", "up") + self.fork = fork + views = [ + profile_sync.RepoView(fork, "src-pin", "src-head", "source_commit", "source"), + profile_sync.RepoView(self.up, "old-base", "up-tip", "upstream_commit", "upstream"), + ] + with mock.patch.object( + profile_sync.upstream, "fork_base", return_value=base + ) as asked: + followed = profile_sync._follow_fork_base(views, "cache", True) + return followed, asked + + def test_the_upstream_head_is_the_fork_base(self): + views, asked = self._views("new-base") + self.assertEqual(views[1].head, "new-base") + self.assertEqual( + asked.call_args.args[:4], (self.up, "up-tip", self.fork, "src-head") + ) + + def test_a_port_outside_the_network_keeps_the_tip(self): + views, _ = self._views(None) + self.assertEqual(views[1].head, "up-tip") + + class OfflineCacheMissIsNotGone(TestBuildReport): """Offline, a file the cache never stored came back as None and the report read "GONE (absent at HEAD, no rename found)" for files that diff --git a/tests/test_upstream.py b/tests/test_upstream.py index 2aa04c4b..fec3da0c 100644 --- a/tests/test_upstream.py +++ b/tests/test_upstream.py @@ -957,5 +957,32 @@ class TestNamedTagCommit(unittest.TestCase): self.assertEqual(upstream.tag_commit(repo, "1.2.35", self.tmp.name), "c74fc36") self.assertTrue(asked[0].endswith("/repository/tags/1.2.35")) + +class TestForkBase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.saved = upstream._http_json + + def tearDown(self): + upstream._http_json = self.saved + self.tmp.cleanup() + + def test_the_merge_base_github_names(self): + asked = [] + + def answer(url): + asked.append(url) + return {"merge_base_commit": {"sha": "base"}, "status": "diverged"} + + upstream._http_json = answer + up, fork = make_repo("github.com", "o", "up"), make_repo("github.com", "f", "port") + self.assertEqual(upstream.fork_base(up, "tip", fork, "head", self.tmp.name), "base") + self.assertTrue(asked[0].endswith("/repos/o/up/compare/tip...f:port:head")) + + def test_no_network_is_none(self): + upstream._http_json = lambda url: None + up, fork = make_repo("github.com", "o", "up"), make_repo("github.com", "f", "port") + self.assertIsNone(upstream.fork_base(up, "tip", fork, "head", self.tmp.name)) + if __name__ == "__main__": unittest.main()