diff --git a/scripts/profile_sync.py b/scripts/profile_sync.py index 24681a3c1..97823280e 100644 --- a/scripts/profile_sync.py +++ b/scripts/profile_sync.py @@ -2216,15 +2216,41 @@ def page_version(profile: dict, cache_dir: str, offline: bool) -> str | None: return match.group(1) if match else None -def version_warning(report: VersionReport | None, pin: str | None) -> str | None: - """Flag a declared version that names a tag away from the resolved pin.""" +def version_warning( + report: VersionReport | None, + pin: str | None, + relation: tuple[int, int] | None = None, + passed_release: str | None = None, +) -> str | None: + """Flag a declared version that names a tag away from the resolved pin. + + ``relation`` is the pin's (ahead, behind) count against the tag commit. + A pin after its tag is a build between two releases. A pin before it + describes code the declared release had already replaced. + ``passed_release`` is a later release the pin already contains, which + core_version should name instead. + """ if report is None or not report.tag_commit or not pin: return None - if report.tag_commit == pin: + if report.tag_commit == pin and not passed_release: return None + if passed_release: + return ( + f"core_version {report.declared} is older than the code: the pin " + f"{pin[:7]} already contains release {passed_release}" + ) + where = "" + if relation is not None: + ahead, behind = relation + if ahead and not behind: + where = f", {ahead} commits after the tag" + elif behind and not ahead: + where = f", {behind} commits before the tag: core_version is later than the code" + elif ahead and behind: + where = f", off the tag's history ({ahead} ahead, {behind} behind)" return ( f"core_version {report.declared} names tag commit {report.tag_commit[:7]}, " - f"the pin resolved to {pin[:7]}" + f"the pin resolved to {pin[:7]}{where}" ) @@ -2901,6 +2927,16 @@ def bump_commit( if pending_recale(report, accept_changed, text): return False document = yaml.safe_load(text) + if document.get("built_by"): + # The pin is the commit a platform builds (recalbox gsplus.mk, the + # mame0258 its package fetches), not a point on a moving branch: + # HEAD is a build nobody ships under this name. + print( + f"{path.stem}: pinned to the build {', '.join(document['built_by'])} " + "ships, advanced by hand", + file=sys.stderr, + ) + return False if any(isinstance(document.get(f), dict) for f in ("source_commit", "upstream_commit")): # A pin per build mode names one revision per repository, and the # report heads only the primary one: rewriting str(dict) was a @@ -3937,7 +3973,48 @@ def _attach_version( except upstream.UpstreamError as exc: print(f"{report.name}: version check skipped: {exc}", file=sys.stderr) return - report.version_warning = version_warning(report.version, report.pin) + relation = None + passed = None + version = report.version + try: + if version and version.tag_commit and report.pin and version.tag_commit != report.pin: + relation = upstream.ancestry( + repo, version.tag_commit, report.pin, args.cache_dir, args.offline + ) + passed = _passed_release(args, repo, version, report.pin) + except upstream.RateLimitError: + raise + except upstream.UpstreamError as exc: + print(f"{report.name}: tag ancestry unread: {exc}", file=sys.stderr) + report.version_warning = version_warning(version, report.pin, relation, passed) + + +def _passed_release( + args: argparse.Namespace, + repo: upstream.Repo, + version: VersionReport | None, + pin: str | None, +) -> str | None: + """The latest release, when the pin already contains it and core_version + names another one. + + The latest tag is no witness: nightly, continuous and Pre-release tags + sort first on many forges. The latest release is a version the project + published as such. + """ + if not version or not pin or not version.latest_release: + return None + if version.latest_release == version.declared: + return None + commit = upstream.tag_commit(repo, version.latest_release, args.cache_dir, args.offline) + if not commit: + return None + if commit == pin: + return version.latest_release + relation = upstream.ancestry(repo, commit, pin, args.cache_dir, args.offline) + if relation and relation[1] == 0: + return version.latest_release + return None def _print_extras( diff --git a/scripts/upstream.py b/scripts/upstream.py index 487c9e3cf..c23eb2e38 100644 --- a/scripts/upstream.py +++ b/scripts/upstream.py @@ -742,6 +742,41 @@ def compare( return CompareResult(files, truncated, payload.get("status") == "diverged") +def _commits_ahead(payload: object, family: str) -> int | None: + """Commits the compare head carries that its base does not.""" + if not isinstance(payload, dict): + return None + if family == "github": + value = payload.get("ahead_by") + elif family == "gitlab": + value = len(payload.get("commits") or []) + else: + value = payload.get("total_commits") + return value if isinstance(value, int) else None + + +def ancestry( + repo: Repo, base: str, head: str, cache_dir: str, offline: bool = False +) -> tuple[int, int] | None: + """(ahead, behind): commits head has that base lacks, and the reverse. + + Two shas never change their history, so both answers are cached for + good. GitHub gives both counts in one answer, GitLab and Forgejo list + one direction only and are asked twice. + """ + forward = _api(_compare_url(repo, base, head), cache_dir, offline) + ahead = _commits_ahead(forward, repo.family) + if ahead is None: + return None + if repo.family == "github": + behind = forward.get("behind_by") + else: + behind = _commits_ahead( + _api(_compare_url(repo, head, base), cache_dir, offline), repo.family + ) + return (ahead, behind) if isinstance(behind, int) else None + + def find_renamed(result: CompareResult, path: str) -> str | None: """New path of a file the comparison reports as renamed.""" for change in result.files: diff --git a/tests/test_profile_sync.py b/tests/test_profile_sync.py index d5644cd09..e98e8a0d0 100644 --- a/tests/test_profile_sync.py +++ b/tests/test_profile_sync.py @@ -2999,6 +2999,24 @@ class TestBumpCommit(unittest.TestCase): self.assertEqual(profile_sync.pending_recale(report), 0) self.assertTrue(bump_commit(self.path, report)) + def test_refused_on_a_build_a_platform_compiles(self): + # mame0258 documents the MAME Recalbox builds: libretro/mame HEAD + # is a later release, and every ref still anchors there. + self.path.write_text(SAMPLE + "built_by: [recalbox]\n", encoding="utf-8") + part = PartResult( + RefPart("a.c", 10, 12, "a.c:10-12"), "ANCHORED", None, 10, 12, [] + ) + report = ProfileReport( + name="p", repo="o/n", pin="pin", head="newhead", + entries=[ + EntryReport("a.bin", "a.c:10-12, (just a note)", "ANCHORED", [part]) + ], + counts={"ANCHORED": 1}, + ) + with contextlib.redirect_stderr(io.StringIO()): + self.assertFalse(bump_commit(self.path, report)) + self.assertNotIn("newhead", self.path.read_text()) + def test_refused_when_no_ref_reads_the_pinned_repository(self): """gam4980: every bare libretro.c ref anchored in upstream, and the bump wrote the fork's HEAD over refs that never described it.""" @@ -3171,6 +3189,55 @@ class TestCheckVersion(unittest.TestCase): result = check_version({"core_version": "v3.0.0"}, self.repo, self.dir, False) self.assertIsNone(version_warning(result, "tagsha")) + def test_warning_says_which_side_of_the_tag_the_pin_is(self): + # 52 warnings read alike: a build after its release and a pin older + # than the release it claims could not be told apart. + result = check_version({"core_version": "v3.0.0"}, self.repo, self.dir, False) + self.assertIn("4 commits after the tag", version_warning(result, "p", (4, 0))) + self.assertIn("core_version is later than the code", + version_warning(result, "p", (0, 9))) + self.assertIn("off the tag's history", version_warning(result, "p", (2, 3))) + + def test_warning_names_a_release_the_pin_already_contains(self): + result = check_version({"core_version": "v3.0.0"}, self.repo, self.dir, False) + warning = version_warning(result, "tagsha", None, "v3.1.0") + self.assertIn("already contains release v3.1.0", warning) + + +class TestAncestry(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.saved = profile_sync.upstream._http_json + + def tearDown(self): + profile_sync.upstream._http_json = self.saved + self.tmp.cleanup() + + def test_github_answers_both_directions_at_once(self): + calls = [] + + def answer(url): + calls.append(url) + return {"ahead_by": 3, "behind_by": 0} + + profile_sync.upstream._http_json = answer + repo = profile_sync.upstream.parse_repo("https://github.com/o/n") + self.assertEqual( + profile_sync.upstream.ancestry(repo, "tag", "pin", self.tmp.name), (3, 0) + ) + self.assertEqual(len(calls), 1) + + def test_gitlab_is_asked_both_ways(self): + def answer(url): + forward = "from=tag&to=pin" in url + return {"commits": [{}] * (0 if forward else 5)} + + profile_sync.upstream._http_json = answer + repo = profile_sync.upstream.parse_repo("https://gitlab.com/o/n") + self.assertEqual( + profile_sync.upstream.ancestry(repo, "tag", "pin", self.tmp.name), (0, 5) + ) + HASH_PROFILE = { "files": [ diff --git a/wiki/first-install.md b/wiki/first-install.md index f6a62a281..e62a9596b 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -76,8 +76,7 @@ It shows your setup and its BIOS folder: ``` - **Right folder?** Go on. -- **Wrong folder?** Press Ctrl+C. Run the command again with your folder - after `--dest`: +- **Wrong folder?** Press Ctrl+C. Run it again with your folder: ```sh @@ -120,14 +119,12 @@ Add `--check` to the same command. It changes nothing. curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh -s -- --check ``` -All good when every file is present and none is wrong: +All good when every file is present: ``` 109/109 present (109 verified, 0 wrong hash) ``` -An empty folder also shows `0 wrong hash`, with `0/109 present`. - ## Undo It keeps every file it replaces in a `retrobios-backup` folder, next to your @@ -138,8 +135,7 @@ BIOS folder. The end of the run prints the line that puts them back. ### No supported platform detected. At a terminal, a menu follows. Pick your setup by its number, then type its -BIOS folder, or press Enter for the usual one. A folder pasted with quotes -around it is fine. +BIOS folder, or press Enter for the usual one. With `--no-input`, or without a terminal, it stops and prints the command to use. diff --git a/wiki/troubleshooting.md b/wiki/troubleshooting.md index fdbfaeb74..260544c0d 100644 --- a/wiki/troubleshooting.md +++ b/wiki/troubleshooting.md @@ -66,9 +66,8 @@ version. ## If the command fails { #installation-script-fails } -- **Network error:** try again once github.com opens. -- **`The clock reads ...`:** the date is wrong. Let it sync over Wi-Fi, or set - it, then run again. +- **Network error:** try again later. +- **`The clock reads ...`:** set the right date. - **Permission denied:** do what the message says. It names what to run. - **No setup found:** name it and its folder: