From 5f378458d2568f56927522518aff6ffd7db60678 Mon Sep 17 00:00:00 2001 From: Gavin Mak Date: Tue, 25 Aug 2026 08:53:15 -0700 Subject: [PATCH] project: reuse ref OIDs in branch maintenance Load refs before resolving the current branch, compare cached HEAD and manifest OIDs instead of walking their symmetric difference, and reuse the snapshot when preserving bare HEAD. Apply the same HEAD reuse to abandon and MetaProject checks, and bound the latter revision walk to one commit. Bug: 553599402 Change-Id: I715e65dcfa02c0df4aaa08da5b7bfbb04ca17725 Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/623183 Reviewed-by: Brian Gan Commit-Queue: Gavin Mak Tested-by: Gavin Mak --- project.py | 38 ++++++++++----- tests/test_project.py | 106 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 11 deletions(-) diff --git a/project.py b/project.py index 036e9fe32..4853c768d 100644 --- a/project.py +++ b/project.py @@ -1754,7 +1754,9 @@ class Project: for linkfile in self.linkfiles: linkfile._Link() - def GetCommitRevisionId(self): + def GetCommitRevisionId( + self, all_refs: Optional[Dict[str, str]] = None + ) -> str: """Get revisionId of a commit. Use this method instead of GetRevisionId to get the id of the commit @@ -1764,7 +1766,9 @@ class Project: if self.revisionId: return self.revisionId if not self.revisionExpr.startswith(R_TAGS): - return self.GetRevisionId(self._allrefs) + if all_refs is None: + all_refs = self._allrefs + return self.GetRevisionId(all_refs) try: return self.bare_git.ResolveCommit(self.revisionExpr) @@ -2428,7 +2432,7 @@ class Project: # Doesn't exist return None - head = self.work_git.GetHead() + head = self._GetHead() if head == rev: # We can't destroy the branch while we are sitting # on it. Switch to a detached HEAD. @@ -2450,9 +2454,9 @@ class Project: def PruneHeads(self): """Prune any topic branches already merged into upstream.""" - cb = self.CurrentBranch kill = [] left = self._allrefs + cb = self.CurrentBranch for name in left.keys(): if name.startswith(R_HEADS): name = name[len(R_HEADS) :] @@ -2464,17 +2468,25 @@ class Project: if not kill and not cb: return [] - rev = self.GetRevisionId(left) + rev = self.GetCommitRevisionId(left) + head = left.get(R_HEADS + cb) if cb is not None else None if ( cb is not None - and not self._revlist(HEAD + "..." + rev) + and head == rev and not self.IsDirty(consider_untracked=False) ): self.work_git.DetachHead(HEAD) kill.append(cb) if kill: - old = self.bare_git.GetHead() + if not self.use_git_worktrees: + old = ( + head + if cb in kill + else (self.bare_ref.head or self.bare_git.GetHead()) + ) + else: + old = self.bare_git.GetHead() try: self.bare_git.DetachHead(rev) @@ -2489,7 +2501,11 @@ class Project: if IsId(old): self.bare_git.DetachHead(old) else: - self.bare_git.SetHead(old) + branch = ( + old[len(R_HEADS) :] if old.startswith(R_HEADS) else old + ) + if branch not in kill: + self.bare_git.SetHead(old) left = self._allrefs for branch in kill: @@ -4930,8 +4946,8 @@ class MetaProject(Project): all_refs = self.bare_ref.all revid = self.GetRevisionId(all_refs) - head = self.work_git.GetHead() - if head.startswith(R_HEADS): + head = self._GetHead() + if head and head.startswith(R_HEADS): try: head = all_refs[head] except KeyError: @@ -4939,7 +4955,7 @@ class MetaProject(Project): if revid == head: return False - elif self._revlist(not_rev(HEAD), revid): + elif self._revlist("-1", not_rev(HEAD), revid): return True return False diff --git a/tests/test_project.py b/tests/test_project.py index 06d7c76a5..c28af9a78 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -501,6 +501,97 @@ class ProjectTests(unittest.TestCase): with mock.patch.object(proj, "_GetHead", return_value=None): self.assertIsNone(proj.CurrentBranch) + def test_prune_heads_reuses_refs_and_avoids_revision_walk(self) -> None: + """Pruning uses snapshot OIDs to recognize an exact manifest HEAD.""" + with utils_for_test.TempGitTree() as tempdir: + proj = _create_mock_project(tempdir) + Path(tempdir, "tracked").write_text("initial") + proj.work_git.add("tracked") + proj.work_git.commit("-m", "initial") + revision = proj.work_git.rev_parse("HEAD") + proj.work_git.branch("merged") + proj.work_git.checkout("-b", "unmerged") + Path(tempdir, "tracked").write_text("topic") + proj.work_git.commit("-am", "topic") + proj.work_git.checkout("main") + proj.work_git.config("branch.unmerged.remote", ".") + proj.work_git.config("branch.unmerged.merge", "refs/heads/main") + proj.revisionId = revision + proj.bare_git = project.Project._GitGetByExec( + proj, bare=True, gitdir=proj.gitdir + ) + proj._revlist = mock.MagicMock( + side_effect=AssertionError("unexpected revision walk") + ) + + kept = proj.PruneHeads() + + self.assertEqual(["unmerged"], [branch.name for branch in kept]) + self.assertEqual("", proj.bare_ref.get("refs/heads/merged")) + proj._revlist.assert_not_called() + + def test_prune_heads_compares_peeled_tag_commit(self) -> None: + """A branch at an annotated manifest tag can be pruned.""" + with utils_for_test.TempGitTree() as tempdir: + proj = _create_mock_project( + tempdir, revisionExpr="refs/tags/release" + ) + Path(tempdir, "tracked").write_text("initial") + proj.work_git.add("tracked") + proj.work_git.commit("-m", "initial") + proj.work_git.tag("-a", "release", "-m", "release") + proj.bare_git = project.Project._GitGetByExec( + proj, bare=True, gitdir=proj.gitdir + ) + + kept = proj.PruneHeads() + + self.assertEqual([], kept) + self.assertIsNone(proj.CurrentBranch) + self.assertEqual("", proj.bare_ref.get("refs/heads/main")) + + def test_prune_heads_preserves_detached_head_when_current_branch_pruned( + self, + ) -> None: + """Pruning current branch keeps HEAD detached at target commit.""" + with utils_for_test.TempGitTree() as tempdir: + proj = _create_mock_project(tempdir) + Path(tempdir, "tracked").write_text("initial") + proj.work_git.add("tracked") + proj.work_git.commit("-m", "initial") + revision = proj.work_git.rev_parse("HEAD") + proj.revisionId = revision + proj.bare_git = project.Project._GitGetByExec( + proj, bare=True, gitdir=proj.gitdir + ) + proj.bare_ref.head + + kept = proj.PruneHeads() + + self.assertEqual([], kept) + self.assertIsNone(proj.CurrentBranch) + self.assertEqual("", proj.bare_ref.get("refs/heads/main")) + self.assertEqual(revision, proj.bare_git.rev_parse("HEAD")) + + def test_prune_heads_with_git_worktrees_preserves_bare_head(self) -> None: + """Worktree pruning does not detach the shared bare repository HEAD.""" + with utils_for_test.TempGitTree() as tempdir: + proj = _create_mock_project(tempdir) + Path(tempdir, "tracked").write_text("initial") + proj.work_git.add("tracked") + proj.work_git.commit("-m", "initial") + revision = proj.work_git.rev_parse("HEAD") + proj.revisionId = revision + proj.use_git_worktrees = True + proj.bare_git = mock.MagicMock() + proj.bare_git.GetHead.return_value = "refs/heads/manifest" + + kept = proj.PruneHeads() + + self.assertEqual([], kept) + proj.bare_git.SetHead.assert_called_once_with("refs/heads/manifest") + proj.bare_git.DetachHead.assert_called_once_with(revision) + @unittest.skipUnless( utils_for_test.supports_reftable(), "git reftable support is required for this test", @@ -1605,6 +1696,21 @@ def test_default_branch_fallback( project._DefaultBranchFallback.cache_clear() +def test_metaproject_has_changes_bounds_revision_walk() -> None: + """MetaProject only asks whether one remote-only commit exists.""" + meta = mock.MagicMock() + meta.remote = mock.sentinel.remote + meta.revisionExpr = "refs/remotes/origin/main" + meta.bare_ref.all = {"HEAD": "local"} + meta.GetRevisionId.return_value = "remote" + meta._GetHead.return_value = "local" + meta._revlist.return_value = ["remote"] + + assert project.MetaProject.HasChanges.fget(meta) + + meta._revlist.assert_called_once_with("-1", "^HEAD", "remote") + + def _create_mock_project( tempdir, use_local_gitdirs=False,