diff --git a/git_refs.py b/git_refs.py index e3da103a0..42af8e0d0 100644 --- a/git_refs.py +++ b/git_refs.py @@ -14,6 +14,7 @@ import os +from git_command import git_require from git_command import GitCommand import platform_utils from repo_trace import Trace @@ -41,6 +42,12 @@ class GitRefs: self._EnsureLoaded() return self._phyref + @property + def head(self) -> str: + """Return HEAD's symbolic target or detached object ID.""" + self._EnsureLoaded() + return self._symref.get(HEAD) or self._phyref.get(HEAD, "") + def get(self, name): try: return self.all[name] @@ -87,8 +94,12 @@ class GitRefs: self._symref = {} self._mtime = {} - self._ReadRefs() - self._ReadSymbolicRef(HEAD) + root_refs_loaded = self._ReadRefs() + if not root_refs_loaded or ( + HEAD not in self._phyref and HEAD not in self._symref + ): + # --include-root-refs does not report an unborn HEAD. + self._ReadSymbolicRef(HEAD) scan = self._symref attempts = 0 @@ -113,18 +124,32 @@ class GitRefs: """Check if a ref_id is a null object ID.""" return ref_id and all(ch == "0" for ch in ref_id) - def _ReadRefs(self) -> None: - """Read all references using git for-each-ref.""" + def _ReadRefs(self) -> bool: + """Read all references using git for-each-ref. + + Returns: + Whether root refs, including HEAD when it exists, were loaded. + """ + include_root_refs = git_require((2, 45, 0)) + cmd = [ + "for-each-ref", + "--format=%(objectname)%00%(refname)%00%(symref)", + ] + if include_root_refs: + cmd.insert(1, "--include-root-refs") + # Avoid caching volatile root refs such as ORIG_HEAD. HEAD and + # refs/* are the only namespaces GitRefs exposes to callers. + cmd.extend([HEAD, "refs"]) p = GitCommand( None, - ["for-each-ref", "--format=%(objectname)%00%(refname)%00%(symref)"], + cmd, capture_stdout=True, capture_stderr=True, bare=True, gitdir=self._gitdir, ) if p.Wait() != 0: - return + return False for line in p.stdout.splitlines(): ref_id, name, symref = line.split("\0") @@ -132,6 +157,7 @@ class GitRefs: self._symref[name] = symref elif ref_id and not self._IsNullRef(ref_id): self._phyref[name] = ref_id + return include_root_refs def _ReadSymbolicRef(self, name: str) -> None: """Read a symbolic reference.""" diff --git a/tests/test_git_refs.py b/tests/test_git_refs.py index 70c0e08ad..183ed9d74 100644 --- a/tests/test_git_refs.py +++ b/tests/test_git_refs.py @@ -17,6 +17,8 @@ import os from pathlib import Path import subprocess +from typing import Any, List +from unittest import mock import pytest import utils_for_test @@ -24,7 +26,7 @@ import utils_for_test import git_refs -def _run(repo, *args): +def _run(repo: str, *args: str) -> str: return subprocess.run( ["git", "-C", repo, *args], stdout=subprocess.PIPE, @@ -34,7 +36,7 @@ def _run(repo, *args): ).stdout.strip() -def _init_repo(tmp_path, reftable=False): +def _init_repo(tmp_path: Path, reftable: bool = False) -> str: repo = os.path.join(tmp_path, "repo") ref_format = "reftable" if reftable else "files" utils_for_test.init_git_tree(repo, ref_format=ref_format) @@ -61,6 +63,149 @@ def test_reads_refs(tmp_path, reftable): assert refs.get(f"refs/heads/{branch}") == head +@pytest.mark.parametrize("reftable", [False, True]) +def test_reads_detached_head(tmp_path: Path, reftable: bool) -> None: + if reftable and not utils_for_test.supports_reftable(): + pytest.skip("reftable not supported") + + repo = _init_repo(tmp_path, reftable=reftable) + head = _run(repo, "rev-parse", "HEAD") + _run(repo, "checkout", "--detach", head) + refs = git_refs.GitRefs(os.path.join(repo, ".git")) + + assert refs.symref("HEAD") == "" + assert refs.head == head + assert refs.get("HEAD") == head + + +def test_reads_head_with_root_refs_in_one_command( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Git 2.45 and newer include HEAD in the ref snapshot.""" + head = "1" * 40 + commands = [] + + class FakeGitCommand: + def __init__( + self, _project: Any, cmdv: List[str], **_kwargs: Any + ) -> None: + commands.append(cmdv) + self.stdout = ( + f"{head}\0HEAD\0refs/heads/main\n" + f"{head}\0refs/heads/main\0\n" + ) + + def Wait(self) -> int: + return 0 + + monkeypatch.setattr(git_refs, "GitCommand", FakeGitCommand) + monkeypatch.setattr(git_refs, "git_require", lambda _version: True) + refs = git_refs.GitRefs("/nonexistent") + with mock.patch.object(refs, "_ReadSymbolicRef") as read_head: + assert refs.get("HEAD") == head + + assert commands == [ + [ + "for-each-ref", + "--include-root-refs", + "--format=%(objectname)%00%(refname)%00%(symref)", + "HEAD", + "refs", + ] + ] + read_head.assert_not_called() + + +def test_root_ref_snapshot_falls_back_for_unborn_head( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An unborn HEAD still uses symbolic-ref after the ref snapshot.""" + + class FakeGitCommand: + def __init__( + self, _project: Any, _cmdv: List[str], **_kwargs: Any + ) -> None: + self.stdout = "" + + def Wait(self) -> int: + return 0 + + monkeypatch.setattr(git_refs, "GitCommand", FakeGitCommand) + monkeypatch.setattr(git_refs, "git_require", lambda _version: True) + refs = git_refs.GitRefs("/nonexistent") + + def read_head(name: str) -> None: + assert name == "HEAD" + refs._symref[name] = "refs/heads/main" + + monkeypatch.setattr(refs, "_ReadSymbolicRef", read_head) + + assert refs.symref("HEAD") == "refs/heads/main" + + +def test_old_git_keeps_separate_head_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Git before 2.45 uses the original for-each-ref and HEAD calls.""" + head = "1" * 40 + commands = [] + + class FakeGitCommand: + def __init__( + self, _project: Any, cmdv: List[str], **_kwargs: Any + ) -> None: + commands.append(cmdv) + self.stdout = f"{head}\0refs/heads/main\0\n" + + def Wait(self) -> int: + return 0 + + monkeypatch.setattr(git_refs, "GitCommand", FakeGitCommand) + monkeypatch.setattr(git_refs, "git_require", lambda _version: False) + refs = git_refs.GitRefs("/nonexistent") + + def read_head(name: str) -> None: + assert name == "HEAD" + refs._symref[name] = "refs/heads/main" + + monkeypatch.setattr(refs, "_ReadSymbolicRef", read_head) + + assert refs.get("HEAD") == head + assert commands == [ + [ + "for-each-ref", + "--format=%(objectname)%00%(refname)%00%(symref)", + ] + ] + + +def test_for_each_ref_failure_falls_back_to_symbolic_ref( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When for-each-ref fails, HEAD is still resolved via symbolic-ref.""" + + class FakeGitCommand: + def __init__( + self, _project: Any, _cmdv: List[str], **_kwargs: Any + ) -> None: + self.stdout = "" + + def Wait(self) -> int: + return 1 + + monkeypatch.setattr(git_refs, "GitCommand", FakeGitCommand) + monkeypatch.setattr(git_refs, "git_require", lambda _version: True) + refs = git_refs.GitRefs("/nonexistent") + + def read_head(name: str) -> None: + assert name == "HEAD" + refs._symref[name] = "refs/heads/main" + + monkeypatch.setattr(refs, "_ReadSymbolicRef", read_head) + + assert refs.symref("HEAD") == "refs/heads/main" + + @pytest.mark.parametrize("reftable", [False, True]) def test_updates_when_refs_change(tmp_path, reftable): if reftable and not utils_for_test.supports_reftable():