diff --git a/scripts/profile_sync.py b/scripts/profile_sync.py index ba1fc787..f706b89b 100644 --- a/scripts/profile_sync.py +++ b/scripts/profile_sync.py @@ -1047,10 +1047,15 @@ class ProfileReport: upstream_pin: str | None = None upstream_head: str | None = None upstream_origin: str | None = None + # Repositories whose pin is no ancestor of the branch the profile + # tracks: history was rewritten, or the pin lies on another branch. + orphaned: list[str] = field(default_factory=list) def needs_review(self) -> int: counts = self.counts or {} - return sum(counts.get(status, 0) for status in REVIEW_STATUSES) + return sum(counts.get(status, 0) for status in REVIEW_STATUSES) + len( + self.orphaned + ) def select_repo(profile: dict) -> upstream.Repo | None: @@ -1422,6 +1427,16 @@ def build_report( report.pinned_tag = frozen or detect_pinned_tag( profile, views, cache_dir, offline ) + if not report.pinned_tag: + # A tag or a held pin is off the tracked branch by design. + report.orphaned = [ + f"{view.repo.slug}: pin {view.pin[:8]} is no ancestor of {view.head[:8]}" + for view in views + if view.pin != view.head + and upstream.compare( + view.repo, view.pin, view.head, cache_dir, offline + ).diverged + ] # A profile carrying no source_ref still has a pin worth writing and a # version worth checking, so the revisions above are resolved first. @@ -1814,6 +1829,8 @@ def format_report(report: ProfileReport, changed_only: bool = False) -> str: lines.append(" pin is HEAD: checked for self-consistency") for url in report.unread or []: lines.append(f" not read (unsupported host): {url}") + for line in report.orphaned: + lines.append(f" orphaned pin: {line}") if report.skipped: lines.append(f" skipped: {report.skipped}") return "\n".join(lines) diff --git a/scripts/upstream.py b/scripts/upstream.py index 985925d5..e8e08134 100644 --- a/scripts/upstream.py +++ b/scripts/upstream.py @@ -122,6 +122,10 @@ class FileChange: class CompareResult: files: list[FileChange] truncated: bool + # The forge reports the two revisions on separate lines of history: the + # base is no ancestor of the head. A pin in that state survives only + # while the forge keeps the orphaned object (advancemame, 2026-10-05). + diverged: bool = False def parse_repo(url: str) -> Repo | None: @@ -526,7 +530,7 @@ def compare( else: files = _changes_from_github(payload) truncated = bool(payload.get("truncated")) or len(files) >= GITHUB_COMPARE_CAP - return CompareResult(files, truncated) + return CompareResult(files, truncated, payload.get("status") == "diverged") def find_renamed(result: CompareResult, path: str) -> str | None: diff --git a/tests/test_profile_sync.py b/tests/test_profile_sync.py index c70e0f95..e1f659db 100644 --- a/tests/test_profile_sync.py +++ b/tests/test_profile_sync.py @@ -1510,6 +1510,29 @@ class TestBuildReport(unittest.TestCase): self.assertEqual(entry.status, "GONE") self.assertIn("written against HEAD", entry.parts[0].reason) + def test_a_pin_off_the_tracked_history_is_reported(self): + # advancemame's master was rewritten: its pin still answered, from + # the forge's orphaned objects, and every ref read ANCHORED. + self.files[("pinsha", "a.c")] = ["x", "hit"] + self.files[("headsha", "a.c")] = ["x", "hit"] + profile_sync.upstream.compare = ( + lambda repo, base, head, cache_dir, offline=False: CompareResult([], False, True) + ) + report = build_report("test", self._profile(["a.c:2"]), self.dir) + self.assertEqual(len(report.orphaned), 1) + self.assertGreater(report.needs_review(), 0) + self.assertIn("orphaned pin", profile_sync.format_report(report)) + + def test_a_frozen_tag_is_off_the_branch_on_purpose(self): + self.files[("pinsha", "a.c")] = ["x", "hit"] + self.files[("headsha", "a.c")] = ["x", "hit"] + self.tag_commits = {"v1.6.0": "pinsha"} + profile_sync.upstream.compare = ( + lambda repo, base, head, cache_dir, offline=False: CompareResult([], False, True) + ) + report = build_report("test", self._versioned("1.6.0"), self.dir) + self.assertEqual(report.orphaned, []) + def test_pin_on_the_declared_version_tag_is_flagged(self): self.files[("pinsha", "a.c")] = ["x", "hit"] self.files[("headsha", "a.c")] = ["x", "hit"]