diff --git a/scripts/profile_sync.py b/scripts/profile_sync.py index 19223528..3d4b85d6 100644 --- a/scripts/profile_sync.py +++ b/scripts/profile_sync.py @@ -1012,6 +1012,13 @@ class ProfileReport: # rather than dropped: a `source` on an unknown host used to fall back to # `upstream` in silence, and a divergence between the two went unseen. unread: list[str] = field(default_factory=list) + # The upstream view when the profile declares one: its pin and HEAD. A + # recale moves its parts onto upstream HEAD lines, so a bump advances + # upstream_commit with source_commit, or the profile describes two + # revisions at once (geolith jg.c drifted 213 -> 221 -> 229). + upstream_pin: str | None = None + upstream_head: str | None = None + upstream_origin: str | None = None def needs_review(self) -> int: counts = self.counts or {} @@ -1195,22 +1202,32 @@ def verify_at_pin(part: RefPart, pin_lines, tokens, hash_tokens=()) -> PartResul ) -def reconcile_self_check(parts: list[PartResult]) -> list[PartResult]: +def reconcile_self_check( + parts: list[PartResult], eligible: list[bool] | None = None +) -> list[PartResult]: """Judge a self-checked entry whole rather than part by part. A ref often cites both the table carrying the value and the code that - loads the file. Once one part anchors on the value, the others describe - behaviour and cannot be judged by value: the checker would only rediscover - the value where the first part already points. A structurally absent part - still counts, since that verdict does not rest on the value. + loads the file. Once one part anchors on the value, the others in the + SAME file describe behaviour and cannot be judged by value: the checker + would only rediscover the value where the first part already points. A + part in another file carries its own value and is judged on it: geolith's + jg.c part, sixteen lines off, was settled by an anchored libretro.c part. + A structurally absent part still counts, and a part that was compared + against HEAD rather than self-checked is never rewritten. """ - if not any(part.status == "ANCHORED" for part in parts): - return parts + eligible = eligible if eligible is not None else [True] * len(parts) + anchored_files = { + part.part.path + for part, ok in zip(parts, eligible) + if ok and part.status == "ANCHORED" + } kept = ("ANCHORED", "GONE", "EXTERNAL", "BINARY") return [ - part if part.status in kept - else PartResult(part.part, "ANCHORED", None, None, None, []) - for part in parts + PartResult(part.part, "ANCHORED", None, None, None, []) + if ok and part.status not in kept and part.part.path in anchored_files + else part + for part, ok in zip(parts, eligible) ] @@ -1364,6 +1381,11 @@ def build_report( report.repos = [v.repo.slug for v in views] report.pin, report.pin_origin = primary.pin, primary.origin report.head = primary.head + for view in views[1:]: + if view.field == "upstream": + report.upstream_pin = view.pin + report.upstream_head = view.head + report.upstream_origin = view.origin # A profile can hold its pin on purpose, documenting a build that is that # revision. Comparing it to HEAD says nothing and recaling would repoint # its refs at code the build never had, so it is judged like a frozen tag: @@ -1639,26 +1661,24 @@ def build_report( ) # Comparing a revision with itself anchors every ref whatever it cites, so - # a profile already sitting on HEAD is judged on self-consistency instead. - self_check = bool(report.pinned_tag) or primary.pin == primary.head - if self_check: - staged = [ - (entry_name, ref, citation, reconcile_self_check([ - verify_at_pin( - part, fetch(PIN, part.path, part.start, tokens), tokens, hashes - ) - for part in ref_parts - ])) - for entry_name, ref, tokens, hashes, ref_parts, citation in refs - ] - else: - staged = [ - (entry_name, ref, citation, [ - anchor_across_views(part, tokens) - for part in ref_parts - ]) - for entry_name, ref, tokens, hashes, ref_parts, citation in refs + # a part whose repository already sits on HEAD is judged on + # self-consistency instead. Decided per part, by the repository that owns + # it: a primary on HEAD says nothing about an upstream that moved on. + def self_checked(part, tokens) -> bool: + if report.pinned_tag: + return True + view, _ = resolve_path(part.path, part.start, tuple(tokens)) + return view.pin == view.head + + staged = [] + for entry_name, ref, tokens, hashes, ref_parts, citation in refs: + flags = [self_checked(part, tokens) for part in ref_parts] + judged = [ + verify_at_pin(part, fetch(PIN, part.path, part.start, tokens), tokens, hashes) + if flag else anchor_across_views(part, tokens) + for part, flag in zip(ref_parts, flags) ] + staged.append((entry_name, ref, citation, reconcile_self_check(judged, flags))) shifts = dominant_shifts([p for _, _, _, parts in staged for p in parts]) for entry_name, ref, citation, parts in staged: @@ -2434,20 +2454,40 @@ def bump_commit( if pending_recale(report, accept_changed, text): return False document = yaml.safe_load(text) - if document.get("source_commit") == report.head: + upstream_moves = bool( + report.upstream_head and report.upstream_pin != report.upstream_head + ) + if document.get("source_commit") == report.head and not upstream_moves: # Rewriting the pin to the value it already holds is not a bump, and # announcing it buries the profiles that did move. return False + if isinstance(document.get("upstream_commit"), dict): + # A pin per build mode names no single upstream revision to advance. + return False expected = dict(document) expected["source_commit"] = report.head - if document.get("source_commit"): + new_text = text + if document.get("source_commit") == report.head: + pass + elif document.get("source_commit"): new_text, _ = replace_field_line( - text, "source_commit", str(document["source_commit"]), report.head + new_text, "source_commit", str(document["source_commit"]), report.head ) else: new_text = insert_after_line( - text, "profiled_date", f'source_commit: "{report.head}"' + new_text, "profiled_date", f'source_commit: "{report.head}"' ) + if upstream_moves: + expected["upstream_commit"] = report.upstream_head + if document.get("upstream_commit"): + new_text, _ = replace_field_line( + new_text, "upstream_commit", str(document["upstream_commit"]), + report.upstream_head, + ) + else: + new_text = insert_after_line( + new_text, "source_commit", f'upstream_commit: "{report.upstream_head}"' + ) apply_edit(path, new_text, expected) return True diff --git a/tests/test_profile_sync.py b/tests/test_profile_sync.py index 4ffc130e..f2b503e2 100644 --- a/tests/test_profile_sync.py +++ b/tests/test_profile_sync.py @@ -2483,6 +2483,22 @@ class TestBumpCommit(unittest.TestCase): self.assertEqual(profile_sync.pending_recale(report), 0) self.assertTrue(bump_commit(self.path, report)) + def test_upstream_pin_moves_with_source_pin(self): + """A recale onto upstream HEAD lines must advance upstream_commit too.""" + 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", "ANCHORED", [part])], + counts={"ANCHORED": 1}, + upstream_pin="uppin", upstream_head="uphead", upstream_origin="upstream_commit", + ) + self.assertTrue(bump_commit(self.path, report)) + written = yaml.safe_load(self.path.read_text()) + self.assertEqual(written["source_commit"], "newhead") + self.assertEqual(written["upstream_commit"], "uphead") + def test_refused_when_pinned_to_a_superseded_tag(self): report = ProfileReport( name="p", repo="o/n", pin="pin", head="newhead", @@ -3333,6 +3349,21 @@ class TestOutputModesApplyTheirFlags(unittest.TestCase): self.assertIn("--check-version", err) +class TestReconcilePerFile(unittest.TestCase): + def test_an_anchored_part_settles_only_its_own_file(self): + anchored = PartResult(RefPart("libretro.c", 5, 5, "5"), "ANCHORED", None, None, None, []) + same = PartResult(RefPart("libretro.c", 9, 9, "9"), "AMBIGUOUS", None, None, None, []) + other = PartResult(RefPart("jg.c", 229, 230, "229-230"), "AMBIGUOUS", None, None, None, []) + result = profile_sync.reconcile_self_check([anchored, same, other]) + self.assertEqual([p.status for p in result], ["ANCHORED", "ANCHORED", "AMBIGUOUS"]) + + def test_a_compared_part_is_never_rewritten(self): + anchored = PartResult(RefPart("a.c", 5, 5, "5"), "ANCHORED", None, None, None, []) + shifted = PartResult(RefPart("a.c", 9, 9, "9"), "SHIFTED", None, 12, 12, []) + result = profile_sync.reconcile_self_check([anchored, shifted], [True, False]) + self.assertEqual(result[1].status, "SHIFTED") + + class TestRealignFlagMatrix(unittest.TestCase): """--realign-prose applies a flag or refuses it, never swallows it."""