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,