From 739f651a169a6ac0840726774fef675b035a1304 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:58:22 +0100 Subject: [PATCH] feat: check refs into a submodule idtech4a++ cites xash3d-fwgs filesystem/filesystem.c, starship and ghostship Torch src/Companion.cpp: files of submodules of the declared repository. No blob of the declared tree answers, so the parts stayed external and a bump moved the submodule commit with nothing checked. A project word now also resolves to the one submodule named after it, and a file under a submodule is read in the repository .gitmodules names, at the commit the declared revision pins it to. --- scripts/profile_sync.py | 50 +++++++++++++++++++++++++++++++----- scripts/upstream.py | 52 ++++++++++++++++++++++++++++++++++++++ tests/test_profile_sync.py | 44 ++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 6 deletions(-) diff --git a/scripts/profile_sync.py b/scripts/profile_sync.py index 741ac22b7..9b9448a93 100644 --- a/scripts/profile_sync.py +++ b/scripts/profile_sync.py @@ -1463,14 +1463,40 @@ def tag_pinned_views( return tagged +def resolve_through_gitlinks(path: str, links: Iterable[Iterable[str]]) -> str | None: + """Path of `project file` inside the one submodule named after the project. + + idtech4a++ cites `xash3d-fwgs filesystem/filesystem.c` and starship + `Torch src/Companion.cpp`: the declared tree holds a gitlink there, not + the files, so no blob path answers and the part stayed external. + """ + word, _, rest = path.partition(" ") + rest = rest.strip().lstrip("/") + if not rest: + return None + word = word.lower() + for paths in links: + matches = [ + link for link in paths or () + if word in link.rsplit("/", 1)[-1].lower() + ] + if len(matches) == 1: + return f"{matches[0]}/{rest}" + if matches: + return None + return None + + def _resolve_project_words( - refs: list, tree_order: Callable[[], Iterable[list[str] | None]] + refs: list, tree_order: Callable[[], Iterable[list[str] | None]], + link_order: Callable[[], Iterable[Iterable[str]]] = lambda: (), ) -> list: """Point `project file` parts at the declared tree that vendors them. - tree_order() yields the trees to search, pin before HEAD. It is only - called for a part that needs it, since reading a HEAD tree costs a - request. Parts no tree carries stay external. + tree_order() yields the trees to search, pin before HEAD, and + link_order() the submodule paths of the declared trees at their pins. + Each is only called for a part that needs it, since reading a tree costs + a request. Parts no tree carries stay external. """ resolved: dict[str, str | None] = {} out = [] @@ -1479,7 +1505,9 @@ def _resolve_project_words( for part in parts: if is_external_citation(part.path): if part.path not in resolved: - resolved[part.path] = resolve_project_word(part.path, tree_order()) + resolved[part.path] = resolve_project_word( + part.path, tree_order() + ) or resolve_through_gitlinks(part.path, link_order()) if resolved[part.path]: part = replace(part, path=resolved[part.path]) rewritten.append(part) @@ -1860,7 +1888,14 @@ def build_report( if key in mute: return None try: - return upstream.fetch_file(repo, sha, wanted, cache_dir, offline) + found = upstream.fetch_file(repo, sha, wanted, cache_dir, offline) + if found is None and "/" in wanted: + # A file of a submodule is read at the commit its gitlink + # pins in this revision, so a bump moves it with the port. + inner = upstream.through_gitlink(repo, sha, wanted, cache_dir, offline) + if inner is not None: + found = upstream.fetch_file(*inner, cache_dir, offline) + return found except (upstream.RateLimitError, upstream.NotCachedError): raise except upstream.UpstreamError as exc: @@ -1917,6 +1952,9 @@ def build_report( lambda: itertools.chain( map(_pin_tree_for, views), map(lambda v: _context_for(v)[1], views) ), + lambda: ( + upstream.list_gitlinks(v.repo, v.pin, cache_dir, offline) for v in views + ), ) lines_cache: dict[tuple[str, str, int | None], list[str] | None] = {} diff --git a/scripts/upstream.py b/scripts/upstream.py index bf0b2f7e6..131d0bc83 100644 --- a/scripts/upstream.py +++ b/scripts/upstream.py @@ -1115,6 +1115,58 @@ def list_gitlinks( } +def submodule_urls(text: str, base: Repo) -> dict[str, Repo]: + """Submodule path -> repository, read from a .gitmodules text. + + A relative url (`../xash3d-fwgs.git`) names a sibling of the repository + that declares it, on the same forge. + """ + repos: dict[str, Repo] = {} + path = url = None + for line in [*text.splitlines(), "[end]"]: + stripped = line.strip() + if stripped.startswith("["): + if path and url: + if url.startswith(("../", "./")): + segments = [base.owner, base.name] + for part in url.split("/"): + if part == "..": + segments.pop() + elif part not in (".", ""): + segments.append(part) + url = f"https://{base.host}/{'/'.join(segments)}" + repo = parse_repo(url) + if repo is not None: + repos[path] = repo + path = url = None + continue + key, _, value = stripped.partition("=") + if key.strip() == "path": + path = value.strip() + elif key.strip() == "url": + url = value.strip() + return repos + + +def through_gitlink( + repo: Repo, sha: str, path: str, cache_dir: str, offline: bool = False +) -> tuple[Repo, str, str] | None: + """The submodule repository, commit and inner path a path falls under. + + starship cites `Torch src/Companion.cpp`, a file of the Torch submodule: + the declared tree holds only the gitlink, which pins the revision. + """ + links = list_gitlinks(repo, sha, cache_dir, offline) + link = next((p for p in links if path.startswith(p + "/")), None) + if link is None: + return None + modules = fetch_file(repo, sha, ".gitmodules", cache_dir, offline) + target = submodule_urls("\n".join(modules or ()), repo).get(link) + if target is None: + return None + return target, links[link], path[len(link) + 1:] + + def commit_date(repo: Repo, sha: str, cache_dir: str, offline: bool = False) -> str | None: """When a revision was committed, as the forge reports it (ISO 8601).""" if repo.family == "gitlab": diff --git a/tests/test_profile_sync.py b/tests/test_profile_sync.py index b7f2d424a..025a56fe2 100644 --- a/tests/test_profile_sync.py +++ b/tests/test_profile_sync.py @@ -3950,6 +3950,50 @@ class TestCollectCitations(unittest.TestCase): self.assertIsNone(resolve("munt ROMInfo.cpp", [tree])) self.assertIsNone(resolve("mupen64plus Config.cpp", [tree])) + def test_project_word_resolves_inside_a_submodule(self): + """idtech4a++ and starship cite files of a submodule. The declared + tree holds only the gitlink, so the parts stayed external and moved + unchecked with every bump.""" + links = ["Q3E/src/main/jni/xash3d/xash3d-fwgs", "tools/Torch", "libs/zlib"] + resolve = profile_sync.resolve_through_gitlinks + self.assertEqual( + resolve("xash3d-fwgs filesystem/filesystem.c", [links]), + "Q3E/src/main/jni/xash3d/xash3d-fwgs/filesystem/filesystem.c", + ) + self.assertEqual( + resolve("Torch src/Companion.cpp", [links]), "tools/Torch/src/Companion.cpp" + ) + self.assertIsNone(resolve("munt ROMInfo.cpp", [links])) + self.assertIsNone(resolve("lib x.c", [["a/lib1", "b/lib2"]])) + + def test_a_submodule_file_is_read_at_its_pinned_commit(self): + port = upstream.make_repo("github.com", "glKarin", "com.n0n3m4.diii4a") + files = { + (port.slug, "p1", ".gitmodules"): [ + '[submodule "Q3E/src/main/jni/xash3d/xash3d-fwgs"]', + "\tpath = Q3E/src/main/jni/xash3d/xash3d-fwgs", + "\turl = ../xash3d-fwgs.git", + ], + ("glKarin/xash3d-fwgs", "s1", "filesystem/filesystem.c"): ["getenv(x)"], + } + + def fetch(repo, sha, path, *_): + return files.get((repo.slug, sha, path)) + + with mock.patch.object(upstream, "fetch_file", side_effect=fetch), \ + mock.patch.object( + upstream, "list_gitlinks", + return_value={"Q3E/src/main/jni/xash3d/xash3d-fwgs": "s1"}, + ): + target = upstream.through_gitlink( + port, "p1", + "Q3E/src/main/jni/xash3d/xash3d-fwgs/filesystem/filesystem.c", "c", + ) + self.assertEqual( + (target[0].slug, target[1], target[2]), + ("glKarin/xash3d-fwgs", "s1", "filesystem/filesystem.c"), + ) + def test_run_is_rendered_with_its_written_path(self): parts = [RefPart("mupen64plus-video-rice/upstream/src/Config.cpp", 38, 38, "38")] self.assertEqual(