diff --git a/emulators/pyxel.yml b/emulators/pyxel.yml index ca93770e..c022de91 100644 --- a/emulators/pyxel.yml +++ b/emulators/pyxel.yml @@ -5,6 +5,7 @@ source: "https://github.com/kitao/pyxel" upstream: "https://github.com/kitao/pyxel" profiled_date: "2026-10-04" source_commit: "50f9bd77780c993aca62b5b221766bde5791081d" +upstream_gone: "github.com/kitao/pyxel answers HTTP 404 to the API and the web, with no redirect. Observed 2026-10-18; no mirror found." core_version: "v2.9.9" display_name: "Pyxel" cores: [pyxel] diff --git a/scripts/profile_sync.py b/scripts/profile_sync.py index 0799579b..aa64f2a8 100644 --- a/scripts/profile_sync.py +++ b/scripts/profile_sync.py @@ -1293,15 +1293,19 @@ def select_views( # no profile carries, and fell back to profiled_date, which a bump # never moves (eden judged from f6e76860 while pinned on d16735f5). pin_field = "source" if field == "source_mirror" else field - branch = branch_in_url(url) or ( - declared if pin_field == "source" else None - ) + branch = declared if pin_field == "source" else None + head = None + for candidate in branch_candidates(url): + head = upstream.resolve_head(repo, cache_dir, offline, candidate) + if head: + branch = candidate + break pin, origin = resolve_pin( profile, repo, cache_dir, offline, pin_field, mode, branch ) if not pin: continue - head = upstream.resolve_head(repo, cache_dir, offline, branch) + head = head or upstream.resolve_head(repo, cache_dir, offline, branch) if not head: continue seen.add((repo.host, repo.slug)) @@ -1372,12 +1376,29 @@ def branch_in_url(url: str) -> str | None: """Branch named by a forge URL pointing inside a repository. A profile can cite a fork as `.../Ymir/tree/libretro`, which names both - the repository and the branch the port lives on. + the repository and the branch the port lives on. The tail may go on into + a directory (`.../libretro-samples/tree/master/tests/cruzes`): this is + the whole tail, and branch_candidates() its prefixes. """ _, sep, tail = url.partition("/tree/") return tail.strip("/") or None if sep else None +def branch_candidates(url: str) -> list[str]: + """Each prefix of a /tree/ tail, shortest first, that may be the branch. + + A branch name can hold a slash, so the tail is not split blindly; the + first prefix the forge resolves is the branch, the rest a directory. + Taken whole, `master/tests/cruzes` resolved to nothing and the profile + was skipped as having no resolvable pin. + """ + tail = branch_in_url(url) + if not tail: + return [] + parts = tail.split("/") + return ["/".join(parts[:n]) for n in range(1, len(parts) + 1)] + + SELF_CHECK_CONTEXT = 2 @@ -1632,9 +1653,18 @@ def build_report( f"upstream_gone is declared but the forge answers: {stated_gone}" ) return report + if not views and stated_gone: + report.skipped = f"upstream gone (declared): {stated_gone}" + return report if not views: + # A declared pin whose repository or branch does not answer is not a + # profile without a pin: pyxel's kitao/pyxel answers 404, and the + # message sent the reader looking for a missing source_commit. report.skipped = ( - "no source_commit and no resolvable profiled_date " + "no declared repository answers (moved, removed, or a branch the " + "forge does not know)" + if profile.get("source_commit") or profile.get("upstream_commit") + else "no source_commit and no resolvable profiled_date " "on any declared repository" ) return report diff --git a/tests/test_profile_sync.py b/tests/test_profile_sync.py index 12f3c3f4..c03fd554 100644 --- a/tests/test_profile_sync.py +++ b/tests/test_profile_sync.py @@ -4070,5 +4070,60 @@ class TestSelectionNamesWhatMatchedNothing(unittest.TestCase): self.assertEqual(sorted(selected), ["handy", "mednafen_lynx"]) + +class TestTreeUrlIntoADirectory(unittest.TestCase): + """`.../libretro-samples/tree/master/tests/cruzes` took the whole tail as + the branch; nothing resolved and cruzes was skipped as having no pin.""" + + def test_candidates_are_prefixes_shortest_first(self): + self.assertEqual( + profile_sync.branch_candidates( + "https://github.com/libretro/libretro-samples/tree/master/tests/cruzes" + ), + ["master", "master/tests", "master/tests/cruzes"], + ) + self.assertEqual(profile_sync.branch_candidates("https://github.com/o/r"), []) + + def test_the_first_resolving_prefix_is_the_branch(self): + from unittest import mock + + asked = [] + + def head(repo, cache, offline=False, branch=None): + asked.append(branch) + return "tipsha" if branch == "master" else None + + profile = {"source": "https://github.com/libretro/libretro-samples/tree/master/tests/cruzes", + "source_commit": "pinsha"} + with mock.patch.object(profile_sync.upstream, "resolve_head", side_effect=head): + views = profile_sync.select_views(profile, ".cache", True) + self.assertEqual([(v.pin, v.head) for v in views], [("pinsha", "tipsha")]) + self.assertEqual(asked[0], "master") + + + +class TestAnUnansweringRepository(unittest.TestCase): + """pyxel declares source_commit, yet the skip read "no source_commit": + its repository answers 404. A declared upstream_gone was honoured only + when the forge raised GoneError, never on a plain non-answer.""" + + def _report(self, profile): + from unittest import mock + + with mock.patch.object(profile_sync, "select_views", return_value=[]): + return profile_sync.build_report("p", profile, ".cache", True) + + def test_a_declared_pin_names_the_repository(self): + report = self._report({"source": "https://github.com/o/r", "source_commit": "abc", + "files": [{"name": "a", "source_ref": "a.c:1"}]}) + self.assertIn("no declared repository answers", report.skipped) + + def test_a_declared_gone_upstream_is_honoured(self): + report = self._report({"source": "https://github.com/o/r", "source_commit": "abc", + "upstream_gone": "404 since 2026-10-18", + "files": [{"name": "a", "source_ref": "a.c:1"}]}) + self.assertEqual(report.skipped, "upstream gone (declared): 404 since 2026-10-18") + + if __name__ == "__main__": unittest.main()