diff --git a/scripts/profile_sync.py b/scripts/profile_sync.py index 164830b7..0127ed83 100644 --- a/scripts/profile_sync.py +++ b/scripts/profile_sync.py @@ -45,7 +45,8 @@ HEAD = "head" _REF_RE = re.compile(r"^(?P[^:]+?)(?::(?P\d+)(?:-(?P\d+))?)?$") _BARE_RANGE_RE = re.compile(r"^\d+(?:-\d+)?$") _SPLIT_RE = re.compile(r"[,;]") -_ANNOTATION_RE = re.compile(r"\s*\([^)]*\)") +_ANNOTATION_RE = re.compile(r"\s*\([^)]*\)?") +_LOCATED_RE = re.compile(r"^[^\s:]+:\d+(?:-\d+)?$") @dataclass(frozen=True) @@ -119,8 +120,11 @@ def split_source_ref(ref: str) -> list[RefPart]: across 50 profiles carry them. """ parts: list[RefPart] = [] - for raw in _SPLIT_RE.split(str(ref or "")): - chunk = _ANNOTATION_RE.sub("", raw).strip() + # Annotations come out first: they may contain the separators themselves, + # and splitting inside one would cut it into meaningless fragments. + cleaned = _ANNOTATION_RE.sub("", str(ref or "")) + for raw in _SPLIT_RE.split(cleaned): + chunk = _trim_prose(raw.strip()) if not chunk: continue if _BARE_RANGE_RE.match(chunk) and parts: @@ -134,6 +138,22 @@ def split_source_ref(ref: str) -> list[RefPart]: return parts +def _trim_prose(chunk: str) -> str: + """Drop a trailing comment written without parentheses. + + `libretro.cpp:1520-1521 candidates_a1200` cites a location followed by a + note. The leading token is kept only when it already reads as a reference, + so `munt ROMInfo.cpp`, which names an external project, stays intact and + is reported rather than silently truncated to `munt`. + """ + if " " not in chunk: + return chunk + head = chunk.split(" ", 1)[0] + if _LOCATED_RE.match(head) or _BARE_RANGE_RE.match(head): + return head + return chunk + + def collect_tokens(entry: dict) -> list[str]: """Values declared by one file entry: hashes, then name as fallback.""" tokens: list[str] = [] @@ -334,6 +354,11 @@ def anchor_part(part: RefPart, fetch, rename_getter, describe=None) -> PartResul ) pin_lines = fetch(PIN, part.path) + 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) if pin_lines is None: return PartResult( part, "GONE", None, None, None, [], "pin revision missing", slug, url diff --git a/tests/test_profile_sync.py b/tests/test_profile_sync.py index 9bcdd9be..5ecbf263 100644 --- a/tests/test_profile_sync.py +++ b/tests/test_profile_sync.py @@ -140,6 +140,33 @@ class TestSplitSourceRef(unittest.TestCase): [("src/emulator.cpp", 234, 234), ("src/core/aes.cpp", 13, 92)], ) + def test_annotation_containing_a_separator_is_not_split(self): + parts = split_source_ref( + "libretro.cpp:944-951 (system_dir + name, then fopen), core.c:12" + ) + self.assertEqual( + [(p.path, p.start) for p in parts], + [("libretro.cpp", 944), ("core.c", 12)], + ) + + def test_trailing_prose_after_a_location_is_dropped(self): + parts = split_source_ref("libretro/libretro.cpp:1520-1521 candidates_a1200") + self.assertEqual( + (parts[0].path, parts[0].start, parts[0].end), + ("libretro/libretro.cpp", 1520, 1521), + ) + + def test_bare_range_followed_by_prose_still_continues(self): + parts = split_source_ref("a.c:10-12, 151-156 load loop") + self.assertEqual( + [(p.path, p.start, p.end) for p in parts], + [("a.c", 10, 12), ("a.c", 151, 156)], + ) + + def test_external_project_citation_is_kept_whole(self): + parts = split_source_ref("munt ROMInfo.cpp") + self.assertEqual(parts[0].path, "munt ROMInfo.cpp") + def test_leading_bare_range_stays_a_path(self): parts = split_source_ref("273-285") self.assertEqual(parts[0].path, "273-285") @@ -359,6 +386,32 @@ class TestAnchorPart(unittest.TestCase): self.assertEqual(result.new_path, "new.c") self.assertEqual(result.start, 2) + def test_bare_filename_resolves_at_both_revisions(self): + fetch = make_fetch( + { + (PIN, "src/midi/mt32.cpp"): ["x", "hit"], + (HEAD, "src/midi/mt32.cpp"): ["x", "hit"], + } + ) + comparison = CompareResult([], True) + result = anchor_part( + RefPart("mt32.cpp", 2, 2), + fetch, + renamer(comparison, ["src/midi/mt32.cpp"]), + ) + self.assertEqual(result.status, "RENAMED") + self.assertEqual(result.new_path, "src/midi/mt32.cpp") + self.assertEqual(result.start, 2) + + def test_genuine_rename_still_reads_the_pin_at_the_old_path(self): + fetch = make_fetch( + {(PIN, "old.c"): ["x", "hit"], (HEAD, "new.c"): ["pad", "x", "hit"]} + ) + comparison = CompareResult([FileChange("renamed", "new.c", "old.c")], False) + result = anchor_part(RefPart("old.c", 2, 2), fetch, renamer(comparison)) + self.assertEqual(result.status, "RENAMED") + self.assertEqual(result.start, 3) + def test_gone_when_pin_file_missing(self): result = anchor_part( RefPart("a.c", 1, 1), make_fetch({}), renamer(CompareResult([], False)) @@ -595,10 +648,18 @@ class TestBuildReport(unittest.TestCase): self.assertEqual(part.status, "ANCHORED") self.assertIsNone(part.new_path) - def test_prefix_matching_no_repository_is_not_stripped(self): + def test_unknown_prefix_resolves_by_basename_not_by_stripping(self): self.files[("pinsha", "a.c")] = ["x", "hit"] self.files[("headsha", "a.c")] = ["x", "hit"] report = build_report("test", self._profile(["other/a.c:2"]), self.dir) + part = report.entries[0].parts[0] + self.assertEqual(part.status, "RENAMED") + self.assertEqual(part.new_path, "a.c") + + def test_basename_absent_everywhere_stays_gone(self): + self.files[("pinsha", "a.c")] = ["x", "hit"] + self.files[("headsha", "a.c")] = ["x", "hit"] + report = build_report("test", self._profile(["other/absent.c:2"]), self.dir) self.assertEqual(report.entries[0].parts[0].status, "GONE") def test_missing_date_and_commit_is_skipped(self):