From dd7faf2b5f422c28200d3c280ffd746f08183895 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:40:48 +0200 Subject: [PATCH] feat: attribute a shared path by its cited subject --- scripts/profile_sync.py | 59 ++++++++++++++++++++++++++------------ tests/test_profile_sync.py | 21 +++++++++++++- 2 files changed, 61 insertions(+), 19 deletions(-) diff --git a/scripts/profile_sync.py b/scripts/profile_sync.py index 80236c97..63952b6f 100644 --- a/scripts/profile_sync.py +++ b/scripts/profile_sync.py @@ -482,7 +482,7 @@ def anchor_part( slug, url, actual = ( describe(part.path) if describe else (None, None, part.path) ) - head_lines = fetch(HEAD, part.path) + head_lines = fetch(HEAD, part.path, part.start) path = actual renamed = actual != part.path @@ -513,7 +513,7 @@ def anchor_part( url, ) path = moved - head_lines = fetch(HEAD, path) + head_lines = fetch(HEAD, path, part.start) if head_lines is None: return PartResult( part, "GONE", None, None, None, [], None, slug, url @@ -528,12 +528,12 @@ def anchor_part( part, status, path if renamed else None, None, None, [], None, slug, url ) - pin_lines = fetch(PIN, part.path) + pin_lines = fetch(PIN, part.path, part.start) if pin_lines is None and path != part.path: # A ref may cite a bare filename the tree search resolved at HEAD; the # same resolved path usually holds at the pin. A genuine rename keeps # the old path at the pin, which is why that one is tried first. - pin_lines = fetch(PIN, path) + pin_lines = fetch(PIN, path, part.start) if pin_lines is None: return PartResult( part, "GONE", None, None, None, [], "pin revision missing", slug, url @@ -867,35 +867,58 @@ def build_report( report.skipped = "no source_ref" return report - owners: dict[str, RepoView] = {} + owners: dict[tuple[str, int | None], tuple[RepoView, str]] = {} context: dict[str, object] = {} - def _locate(path: str) -> tuple[RepoView, str]: + def _locate(path: str, start: int | None = None) -> tuple[RepoView, str]: """Repository and real path carrying a cited path, HEAD before pin. A ref may prefix the path with the repository directory name, as it appears in a parent folder holding both clones: 270 parts across 21 profiles do. That prefix is stripped only as a last resort, once the path as written has failed against every repository and revision. + + The pin comes before HEAD because that is the revision the ref was + written against. `mame` cites files that both mamedev/mame and the + libretro fork carry today, but only the upstream pin holds them; the + fork's pin is an older tree where they lived elsewhere. """ candidates = [path] head, _, tail = path.partition("/") if tail and any(head == v.repo.name for v in views): candidates.append(tail) + + def carries_subject(lines) -> bool: + """A citation never points at a blank line. + + Two repositories can hold the same path with different contents: + `mame` cites files that both mamedev/mame and its libretro fork + carry. Where the cited line is empty, that repository is not the + one the ref was written against. + """ + if start is None: + return True + return start <= len(lines) and bool(lines[start - 1].strip()) + + fallback = None for candidate in candidates: - for sha_of in (lambda v: v.head, lambda v: v.pin): + for sha_of in (lambda v: v.pin, lambda v: v.head): for view in views: found = upstream.fetch_file( view.repo, sha_of(view), candidate, cache_dir, offline ) - if found is not None: + if found is None: + continue + if carries_subject(found): return view, candidate - return primary, path + fallback = fallback or (view, candidate) + return fallback or (primary, path) - def resolve_path(path: str) -> tuple[RepoView, str]: - if path not in owners: - owners[path] = _locate(path) - return owners[path] + def resolve_path(path: str, start: int | None = None) -> tuple[RepoView, str]: + key = (path, start) + if key not in owners: + owners[key] = _locate(path, start) + return owners[key] def _context_for(view: RepoView): key = view.repo.slug @@ -936,17 +959,17 @@ def build_report( candidates = near return None, candidates - lines_cache: dict[tuple[str, str], list[str] | None] = {} + lines_cache: dict[tuple[str, str, int | None], list[str] | None] = {} - def fetch(which: str, path: str): + def fetch(which: str, path: str, start: int | None = None): """Lines for one path at one revision, read once per run. The same object is handed back every time so the opcode cache can key on identity. """ - key = (which, path) + key = (which, path, start) if key not in lines_cache: - view, actual = resolve_path(path) + view, actual = resolve_path(path, start) sha = view.pin if which == PIN else view.head lines_cache[key] = upstream.fetch_file( view.repo, sha, actual, cache_dir, offline @@ -964,7 +987,7 @@ def build_report( if self_check: staged = [ (entry_name, ref, reconcile_self_check([ - verify_at_pin(part, fetch(PIN, part.path), tokens) + verify_at_pin(part, fetch(PIN, part.path, part.start), tokens) for part in split_source_ref(ref) ])) for entry_name, ref, tokens in refs diff --git a/tests/test_profile_sync.py b/tests/test_profile_sync.py index d9e867e1..709c2800 100644 --- a/tests/test_profile_sync.py +++ b/tests/test_profile_sync.py @@ -562,7 +562,9 @@ HEAD = profile_sync.HEAD def make_fetch(files: dict[tuple[str, str], list[str]]): - return lambda sha, path: files.get((sha, path)) + # The real fetch takes the cited line too, so that a path carried by two + # repositories is attributed to the one whose revision is not blank there. + return lambda sha, path, start=None: files.get((sha, path)) def renamer(result: CompareResult, head_paths=()): @@ -1089,6 +1091,23 @@ class TestBuildReport(unittest.TestCase): report = build_report("test", profile, self.dir) self.assertEqual(report.entries[0].status, "ANCHORED") + def test_the_repository_with_a_subject_owns_a_shared_path(self): + # A port and its upstream can both carry a path; only one holds the + # cited subject, and a blank line is never a citation. + self.files[("portpin", "src/a.cpp")] = ["x", "", "y"] + self.files[("uppin", "src/a.cpp")] = ["x", "GAME(1985, bubsys", "y"] + self.files[("headsha", "src/a.cpp")] = ["x", "GAME(1985, bubsys", "y"] + profile = { + "emulator": "MAME", + "source": "https://github.com/libretro/mame", + "upstream": "https://github.com/mamedev/mame", + "source_commit": "portpin", + "upstream_commit": "uppin", + "files": [{"name": "bubsys.zip", "source_ref": "src/a.cpp:2"}], + } + report = build_report("mame", profile, self.dir) + self.assertEqual(report.entries[0].status, "ANCHORED") + 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"]