From 3f1775607ffe67548fc5b43a0ce2021002069ee6 Mon Sep 17 00:00:00 2001 From: kimhappy Date: Tue, 25 Aug 2026 13:02:01 +0900 Subject: [PATCH] project: allow re-reading submodule gitlinks A discovered submodule becomes a project whose revision is the gitlink read from its parent at manifest load time, which is before the parent has been fetched. Remember which gitlink a submodule was derived from, and add a way to read those gitlinks again, so that callers can resolve a submodule revision once its parent is up-to-date. A revision that is set does not have to be fetched, so verify its objects are really there. Without them a submodule that was removed cannot be told apart from one that was never fetched, which the caller has to know about. Bug: 550074864 Change-Id: I4f35d53607126508eec6f02158a3fb604917a199 Signed-off-by: kimhappy Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/621661 Reviewed-by: Brian Gan Reviewed-by: Gavin Mak --- project.py | 36 ++++++++++++++++++++++++++++++++++++ tests/test_project.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/project.py b/project.py index 67e3fe6b6..2b0019070 100644 --- a/project.py +++ b/project.py @@ -568,6 +568,7 @@ class Project: parent=None, use_git_worktrees=False, is_derived=False, + gitlink_path: Optional[str] = None, dest_branch=None, optimized_fetch=False, retry_fetches=0, @@ -597,6 +598,8 @@ class Project: use_git_worktrees: Whether to use `git worktree` for this project. is_derived: False if the project was explicitly defined in the manifest; True if the project is a discovered submodule. + gitlink_path: For a discovered submodule, its path inside the + parent project. dest_branch: The branch to which to push changes for review by default. optimized_fetch: If True, when a project is set to a sha1 revision, @@ -624,6 +627,7 @@ class Project: # See the XmlManifest init code for more info. self.use_git_worktrees = use_git_worktrees self.is_derived = is_derived + self.gitlink_path = gitlink_path self.optimized_fetch = optimized_fetch self.retry_fetches = max(0, retry_fetches) self.subprojects = [] @@ -2573,6 +2577,37 @@ class Project: return [] return get_submodules(self.gitdir, rev) + def GetSubmoduleRevisions(self) -> Optional[Dict[str, str]]: + """Read the gitlinks of our submodules at our current revision. + + Discovered submodules are derived from the revision their parent was + at when the manifest was loaded, which is before it gets fetched. + Once it is up-to-date, its gitlinks have to be read again, otherwise + the submodules would be synced to the revisions of the previous sync. + + Returns: + The revision of every submodule, keyed by its path inside this + project, or None if our revision is not available locally. A + revision that is merely set does not have to be fetched yet, and + without its objects a removed submodule cannot be told apart from + one that was never fetched. + """ + try: + rev = self.GetRevisionId() + self.bare_git.rev_list( + "-1", + "--missing=allow-any", + f"{rev}^0", + "--", + log_as_error=False, + ) + except (GitError, ManifestInvalidRevisionError): + return None + + return { + path: sha for sha, path, _url, _shallow in self._GetSubmodules() + } + def GetDerivedSubprojects(self): result = [] if not self.Exists: @@ -2620,6 +2655,7 @@ class Project: parent=self, clone_depth=clone_depth, is_derived=True, + gitlink_path=path, ) result.append(subproject) result.extend(subproject.GetDerivedSubprojects()) diff --git a/tests/test_project.py b/tests/test_project.py index cbffec859..8c13c4f0e 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -948,6 +948,45 @@ def _create_mock_project( return proj +class GetSubmoduleRevisions(unittest.TestCase): + """Tests for Project.GetSubmoduleRevisions.""" + + def test_gitlinks_are_keyed_by_their_path(self) -> None: + with utils_for_test.TempGitTree() as tempdir: + parent = _create_mock_project(tempdir) + parent.GetRevisionId = mock.MagicMock(return_value="cafe1234") + parent._GetSubmodules = mock.MagicMock( + return_value=[("1234abcd", "sub", "http://example.com/sub", "")] + ) + + self.assertEqual( + parent.GetSubmoduleRevisions(), {"sub": "1234abcd"} + ) + + def test_none_when_the_revision_is_not_fetched(self) -> None: + with utils_for_test.TempGitTree() as tempdir: + parent = _create_mock_project(tempdir) + parent.GetRevisionId = mock.MagicMock(return_value="cafe1234") + parent.bare_git.rev_list = mock.MagicMock( + side_effect=error.GitError("missing object") + ) + parent._GetSubmodules = mock.MagicMock() + + self.assertIsNone(parent.GetSubmoduleRevisions()) + parent._GetSubmodules.assert_not_called() + + def test_none_without_a_revision(self) -> None: + with utils_for_test.TempGitTree() as tempdir: + parent = _create_mock_project(tempdir) + parent.GetRevisionId = mock.MagicMock( + side_effect=error.ManifestInvalidRevisionError("no revision") + ) + parent._GetSubmodules = mock.MagicMock() + + self.assertIsNone(parent.GetSubmoduleRevisions()) + parent._GetSubmodules.assert_not_called() + + class StatelessSyncTests(unittest.TestCase): """Tests for stateless sync strategy."""