| # 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_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.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_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(): |
| 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 |