| # Copyright (C) 2026 The Android Open Source Project | |
| # | |
| # Licensed under the Apache License, Version 2.0 (the "License"); | |
| # you may not use this file except in compliance with the License. | |
| # You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| """Unittests for the git_refs.py module.""" | |
| import os | |
| from pathlib import Path | |
| import subprocess | |
| from typing import Any, List | |
| from unittest import mock | |
| import pytest | |
| import utils_for_test | |
| import git_command | |
| import git_refs | |
| def _run(repo: str, *args: str) -> str: | |
| return subprocess.run( | |
| ["git", "-C", repo, *args], | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| encoding="utf-8", | |
| check=True, | |
| ).stdout.strip() | |
| 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) | |
| Path(os.path.join(repo, "a")).write_text("1") | |
| _run(repo, "add", "a") | |
| _run(repo, "commit", "-q", "-m", "init") | |
| return repo | |
| @pytest.mark.parametrize("reftable", [False, True]) | |
| def test_reads_refs(tmp_path, reftable): | |
| if reftable and not utils_for_test.supports_reftable(): | |
| pytest.skip("reftable not supported") | |
| repo = _init_repo(tmp_path, reftable=reftable) | |
| gitdir = os.path.join(repo, ".git") | |
| refs = git_refs.GitRefs(gitdir) | |
| 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 | |
| @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_command, "GitCommand", FakeGitCommand) | |
| monkeypatch.setattr(git_command, "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_command, "GitCommand", FakeGitCommand) | |
| monkeypatch.setattr(git_command, "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_command, "GitCommand", FakeGitCommand) | |
| monkeypatch.setattr(git_command, "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_command, "GitCommand", FakeGitCommand) | |
| monkeypatch.setattr(git_command, "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(): | |
| pytest.skip("reftable not supported") | |
| repo = _init_repo(tmp_path, reftable=reftable) | |
| gitdir = os.path.join(repo, ".git") | |
| refs = git_refs.GitRefs(gitdir) | |
| head = _run(repo, "rev-parse", "HEAD") | |
| assert refs.get("refs/heads/topic") == "" | |
| _run(repo, "branch", "topic") | |
| assert refs.get("refs/heads/topic") == head | |
| _run(repo, "branch", "-D", "topic") | |
| assert refs.get("refs/heads/topic") == "" | |
| @pytest.mark.skipif( | |
| not utils_for_test.supports_refs_migrate(), | |
| reason="git refs migrate reftable support is required for this test", | |
| ) | |
| def test_updates_when_storage_backend_toggles(tmp_path): | |
| repo = _init_repo(tmp_path, reftable=False) | |
| gitdir = os.path.join(repo, ".git") | |
| refs = git_refs.GitRefs(gitdir) | |
| head = _run(repo, "rev-parse", "HEAD") | |
| assert refs.get("refs/heads/reftable-branch") == "" | |
| _run(repo, "refs", "migrate", "--ref-format=reftable") | |
| _run(repo, "branch", "reftable-branch") | |
| assert refs.get("refs/heads/reftable-branch") == head | |
| assert refs.get("refs/heads/files-branch") == "" | |
| _run(repo, "refs", "migrate", "--ref-format=files") | |
| _run(repo, "branch", "files-branch") | |
| assert refs.get("refs/heads/files-branch") == head |