diff --git a/git_refs.py b/git_refs.py index 42af8e0d0..0811377a5 100644 --- a/git_refs.py +++ b/git_refs.py @@ -48,6 +48,11 @@ class GitRefs: self._EnsureLoaded() return self._symref.get(HEAD) or self._phyref.get(HEAD, "") + @property + def is_loaded(self) -> bool: + """Whether a ref snapshot has already been loaded.""" + return self._phyref is not None + def get(self, name): try: return self.all[name] diff --git a/project.py b/project.py index b4d77fead..f4b4d5db1 100644 --- a/project.py +++ b/project.py @@ -770,15 +770,28 @@ class Project: work_git is otheriwse inaccessible (e.g. an incomplete sync). """ try: - b = self.work_git.GetHead() + b = self._GetHead() except NoManifestException: # If the local checkout is in a bad state, don't barf. Let the # callers process this like the head is unreadable. return None - if b.startswith(R_HEADS): + if b and b.startswith(R_HEADS): return b[len(R_HEADS) :] return None + def _GetHead(self) -> Optional[str]: + """Return worktree HEAD, reusing a compatible loaded ref snapshot.""" + if not self.work_git: + return None + # Git worktrees keep the checkout's HEAD in the worktree admin dir, + # while bare_ref reads the shared repository. Its HEAD is not the + # checked-out worktree's HEAD and must not be reused here. + if not self.use_git_worktrees and self.bare_ref.is_loaded: + head = self.bare_ref.head + if head: + return head + return self.work_git.GetHead() + def IsRebaseInProgress(self): """Returns true if a rebase or "am" is in progress""" # "rebase-apply" is used for "git rebase". @@ -877,8 +890,8 @@ class Project: def GetBranches(self): """Get all existing local branches.""" - current = self.CurrentBranch all_refs = self._allrefs + current = self.CurrentBranch heads = {} for name, ref_id in all_refs.items(): @@ -1766,6 +1779,10 @@ class Project: Returns None if worktree is not checked out or HEAD cannot be resolved. """ if self.work_git: + if not self.use_git_worktrees and self.bare_ref.is_loaded: + head = self.bare_ref.get(HEAD) + if head: + return head try: return self.work_git.rev_parse("HEAD") except GitError: @@ -1879,8 +1896,8 @@ class Project: if p.Wait() != 0: logger.warning("warn: %s: stateless gc failed", self.name) - head = self.work_git.GetHead() - if head.startswith(R_HEADS): + head = self._GetHead() + if head and head.startswith(R_HEADS): branch = head[len(R_HEADS) :] try: head = all_refs[head] diff --git a/tests/test_git_refs.py b/tests/test_git_refs.py index 183ed9d74..ab7bdbe97 100644 --- a/tests/test_git_refs.py +++ b/tests/test_git_refs.py @@ -59,6 +59,7 @@ def test_reads_refs(tmp_path, reftable): branch = _run(repo, "symbolic-ref", "--short", "HEAD") head = _run(repo, "rev-parse", "HEAD") assert refs.symref("HEAD") == f"refs/heads/{branch}" + assert refs.head == f"refs/heads/{branch}" assert refs.get("HEAD") == head assert refs.get(f"refs/heads/{branch}") == head diff --git a/tests/test_project.py b/tests/test_project.py index e750a88c4..08d280689 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -386,6 +386,78 @@ class ProjectTests(unittest.TestCase): proj.work_git.checkout("HEAD~0") self.assertEqual(commit_sha, proj.GetHeadRevisionId()) + def test_get_branches_reuses_ref_snapshot_for_current_branch(self) -> None: + """GetBranches derives the current branch from the loaded refs.""" + with utils_for_test.TempGitTree() as tempdir: + proj = _create_mock_project(tempdir) + proj.work_git = mock.MagicMock() + proj.bare_ref = mock.MagicMock() + proj.bare_ref.all = { + "HEAD": "1" * 40, + "refs/heads/topic": "1" * 40, + } + proj.bare_ref.head = "refs/heads/topic" + + branches = proj.GetBranches() + + self.assertTrue(branches["topic"].current) + proj.work_git.GetHead.assert_not_called() + + def test_get_branches_reads_worktree_head_for_git_worktrees(self) -> None: + """A shared repository HEAD is not a linked worktree's HEAD.""" + with utils_for_test.TempGitTree() as tempdir: + proj = _create_mock_project(tempdir) + proj.use_git_worktrees = True + proj.work_git = mock.MagicMock() + proj.work_git.GetHead.return_value = "refs/heads/worktree" + proj.bare_ref = mock.MagicMock() + proj.bare_ref.all = { + "HEAD": "1" * 40, + "refs/heads/common": "1" * 40, + "refs/heads/worktree": "1" * 40, + } + proj.bare_ref.head = "refs/heads/common" + + branches = proj.GetBranches() + + self.assertFalse(branches["common"].current) + self.assertTrue(branches["worktree"].current) + proj.work_git.GetHead.assert_called_once_with() + + def test_get_branches_tolerates_unreadable_head(self) -> None: + """An incomplete checkout still reports its local branches.""" + with utils_for_test.TempGitTree() as tempdir: + proj = _create_mock_project(tempdir) + proj.bare_ref = mock.MagicMock() + proj.bare_ref.all = {"refs/heads/topic": "1" * 40} + + with mock.patch.object( + proj, + "_GetHead", + side_effect=error.NoManifestException("HEAD", "unreadable"), + ): + branches = proj.GetBranches() + + self.assertFalse(branches["topic"].current) + + def test_get_head_returns_none_when_work_git_is_none(self) -> None: + """Bare and mirror checkouts without work_git do not crash.""" + with utils_for_test.TempGitTree() as tempdir: + proj = _create_mock_project(tempdir) + proj.work_git = None + + self.assertIsNone(proj._GetHead()) + self.assertIsNone(proj.CurrentBranch) + + def test_current_branch_returns_none_when_get_head_returns_none( + self, + ) -> None: + """CurrentBranch safely returns None when _GetHead returns None.""" + with utils_for_test.TempGitTree() as tempdir: + proj = _create_mock_project(tempdir) + with mock.patch.object(proj, "_GetHead", return_value=None): + self.assertIsNone(proj.CurrentBranch) + @unittest.skipUnless( utils_for_test.supports_reftable(), "git reftable support is required for this test", @@ -1621,12 +1693,11 @@ class StatelessSyncTests(unittest.TestCase): proj.IsCherryPickInProgress = mock.MagicMock(return_value=False) proj.bare_ref = mock.MagicMock() proj.bare_ref.all = {} + proj.bare_ref.head = "5678abcd" proj.GetRevisionId = mock.MagicMock(return_value="1234abcd") proj._CopyAndLinkFiles = mock.MagicMock() proj.work_git = mock.MagicMock() - proj.work_git.GetHead.return_value = "5678abcd" - syncbuf = project.SyncBuffer(proj.config) with mock.patch("project.GitCommand") as mock_git_cmd: