git_refs, project: read files-backend refs in Python

GitRefs._LoadAll spawns `git for-each-ref` to load HEAD and refs/*, and
Project._InitAnyMRef leaves warm snapshots stale after direct UpdateRef
writes. On virtualized checkouts with thousands of repositories, these
spawns add avoidable process overhead.

Read HEAD, packed-refs, and loose refs directly in GitRefs when the
repository uses a plain files ref backend, recording (mode, mtime, size,
inode) stat tuples during the single directory pass and rechecking
packed-refs afterward to guard against concurrent pack-refs updates.
The tuples leave out ctime, which backup agents and file system crawlers
move without changing a ref. Fall back to `git for-each-ref` when
GIT_DIR, GIT_COMMON_DIR, reftable, commondir, custom refStorage config,
symlinks, or lockfiles are present.

Also add GitRefs.TryResolveRef for single-ref lookups without scanning
all of refs/, GitRefs.Invalidate, and GitRefs.UpdateRef to keep cached
snapshots warm after _InitAnyMRef writes a direct ref.

Bug: 543101585
Change-Id: I804ac45712208e9f9e6437012661bc1de58b9825
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/655963
Tested-by: Gavin Mak <gavinmak@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
This commit is contained in:
Gavin Mak authored and gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com committed 2026-11-11 14:56:42 -08:00
1 parent 33aaf275be
commit bb9b4c2b60
3 files changed
+758 -39

No files matched your search

+420 -39
View File
@@ -13,6 +13,9 @@
# limitations under the License.
import os
import re
import stat
from typing import Dict, Optional, Set, Tuple
import git_command
import platform_utils
@@ -29,6 +32,26 @@ R_WORKTREE_M = R_WORKTREE + "m/"
R_M = "refs/remotes/m/"
# Maximum symbolic-ref indirection depth (matches Git SYMREF_MAXDEPTH).
_SYMREF_MAXDEPTH = 5
_HEX_OID_RE = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$")
_BAD_REF_CHARS = frozenset(" ~^:?*[\\")
_StatKey = Tuple[int, int, int, int]
def _IsValidRefName(name: str) -> bool:
"""Validate a Git refname per git-check-ref-format rules."""
if not name or name.endswith(".") or ".." in name or "@{" in name:
return False
if any(
ch in _BAD_REF_CHARS or ord(ch) < 0x20 or ord(ch) == 0x7F for ch in name
):
return False
parts = name.split("/")
if len(parts) < 2:
return False
for part in parts:
if not part or part.startswith(".") or part.endswith(".lock"):
return False
return True
class GitRefs:
@@ -36,7 +59,8 @@ class GitRefs:
self._gitdir = gitdir
self._phyref = None
self._symref = None
self._mtime = {}
self._mtime: Dict[str, Optional[_StatKey]] = {}
self._files_authoritative = False
@property
def all(self):
@@ -54,6 +78,18 @@ class GitRefs:
"""Whether a ref snapshot has already been loaded."""
return self._phyref is not None
@property
def is_files_authoritative(self) -> bool:
"""Whether the loaded snapshot came from a complete files scan."""
return self._phyref is not None and self._files_authoritative
def Invalidate(self) -> None:
"""Discard any cached ref snapshot so the next access reloads."""
self._phyref = None
self._symref = None
self._mtime = {}
self._files_authoritative = False
def get(self, name):
try:
return self.all[name]
@@ -70,6 +106,9 @@ class GitRefs:
if name in self._mtime:
del self._mtime[name]
norm_os = os.path.join(*name.strip("/").split("/"))
if norm_os in self._mtime:
del self._mtime[norm_os]
def symref(self, name):
try:
@@ -78,6 +117,80 @@ class GitRefs:
except KeyError:
return ""
def TryResolveRef(self, name: str) -> Optional[str]:
"""Resolve a single ref directly on the files backend without git.
Args:
name: Reference name ("HEAD" or "refs/...").
Returns:
The resolved hex object ID if present, "" if the repository uses a
clean files backend and the ref is authoritatively absent or
unborn, or None if git must be invoked as a fallback.
"""
if name != HEAD and (
not name.startswith("refs/") or not _IsValidRefName(name)
):
return None
if self._phyref is not None and not self._NeedUpdate():
if name in self._phyref:
return self._phyref[name]
if self._files_authoritative:
return ""
return None
if self._CheckFilesBackendConfig() is None:
return None
refs_dir = os.path.join(self._gitdir, "refs")
if platform_utils.islink(refs_dir) or not os.path.isdir(refs_dir):
return None
head_file = os.path.join(self._gitdir, HEAD)
if platform_utils.islink(head_file) or not os.path.isfile(head_file):
return None
cur = name
for _ in range(_SYMREF_MAXDEPTH + 1):
parts = cur.split("/")
for idx in range(1, len(parts) + 1):
if platform_utils.islink(
os.path.join(self._gitdir, *parts[:idx])
):
return None
ref_path = os.path.join(self._gitdir, *parts)
if os.path.lexists(ref_path + ".lock"):
return None
if os.path.lexists(ref_path):
if os.path.isdir(ref_path):
return None
parsed = self._ParseLooseRefFile(ref_path)
if parsed is None:
return None
is_sym, val, _ = parsed
if not is_sym:
return val
cur = val
continue
if cur == HEAD:
return None
packed_path = os.path.join(self._gitdir, "packed-refs")
if os.path.lexists(packed_path):
packed_res = self._ReadPackedRefs(packed_path)
if packed_res is None:
return None
packed_map, _ = packed_res
else:
packed_map = {}
if os.path.lexists(ref_path):
return None
return packed_map.get(cur, "")
return ""
def UpdateSymref(self, name: str, target: str) -> None:
"""Update a symbolic reference in an already-loaded ref snapshot.
@@ -88,7 +201,7 @@ class GitRefs:
if self._phyref is None:
return
if self._HasExternalRefChanges(name):
self._phyref = None
self.Invalidate()
return
self._symref[name] = target
@@ -100,38 +213,70 @@ class GitRefs:
self._TrackMtime(anc)
self._TrackMtime(os.path.join(*parts))
def _HasExternalRefChanges(self, updated_symref: str) -> bool:
"""Check whether refs outside |updated_symref| changed on disk.
def UpdateRef(self, name: str) -> None:
"""Refresh a direct reference in an already-loaded ref snapshot.
Args:
updated_symref: Symbolic ref path just updated on disk.
name: Full reference name just updated on disk (e.g.
"refs/remotes/m/main").
"""
if self._phyref is None:
return
if self._HasExternalRefChanges(name):
self.Invalidate()
return
parts = name.strip("/").split("/")
ref_rel = os.path.join(*parts)
parsed = self._ParseLooseRefFile(os.path.join(self._gitdir, ref_rel))
if parsed is None or parsed[0]:
self.Invalidate()
return
_, oid, ref_stat = parsed
self._symref.pop(name, None)
self._phyref[name] = oid
self._ResolveSymrefs()
for idx in range(1, len(parts)):
self._TrackMtime(os.path.join(*parts[:idx]))
self._mtime[ref_rel] = ref_stat
def _HasExternalRefChanges(self, updated_ref: str) -> bool:
"""Check whether refs outside |updated_ref| changed on disk.
Args:
updated_ref: Reference path just updated on disk.
Returns:
True if reftable is in use or any unrelated ref path changed.
True if files backend is unsafe or any unrelated ref path changed.
"""
if os.path.exists(os.path.join(self._gitdir, "reftable")):
if self._CheckFilesBackendConfig() is None:
return True
if "packed-refs" not in self._mtime and os.path.exists(
if "packed-refs" not in self._mtime and os.path.lexists(
os.path.join(self._gitdir, "packed-refs")
):
return True
parts = updated_symref.strip("/").split("/")
parts = updated_ref.strip("/").split("/")
expected_child = {
"/".join(parts[:idx]): parts[idx] for idx in range(1, len(parts))
}
for tracked, mtime in self._mtime.items():
for tracked, prev_stat in self._mtime.items():
norm = tracked.replace(os.sep, "/")
if norm == updated_symref:
if norm == updated_ref:
continue
full_path = os.path.join(self._gitdir, tracked)
try:
curr_mtime = os.path.getmtime(full_path)
except OSError:
curr_stat = self._StatPath(full_path)
if prev_stat is None:
if curr_stat is not None:
return True
continue
if curr_stat is None:
return True
if norm in expected_child:
if curr_mtime != mtime:
if curr_stat != prev_stat:
try:
children = platform_utils.listdir(full_path)
except OSError:
@@ -140,14 +285,15 @@ class GitRefs:
for child in children:
if child == next_comp:
continue
if os.path.join(tracked, child) not in self._mtime:
child_key = os.path.join(tracked, child)
if self._mtime.get(child_key) is None:
return True
elif curr_mtime != mtime:
elif curr_stat != prev_stat:
return True
for anc, next_comp in expected_child.items():
anc_os = anc.replace("/", os.sep)
if anc_os not in self._mtime:
if self._mtime.get(anc_os) is None:
anc_full = os.path.join(self._gitdir, anc_os)
try:
if platform_utils.listdir(anc_full) != [next_comp]:
@@ -177,20 +323,23 @@ class GitRefs:
if self._phyref is None or self._NeedUpdate():
self._LoadAll()
def _NeedUpdate(self):
def _NeedUpdate(self) -> bool:
with repo_trace.Trace(": scan refs %s", self._gitdir):
for name, mtime in self._mtime.items():
try:
if mtime != os.path.getmtime(
os.path.join(self._gitdir, name)
):
return True
except OSError:
for name, prev_stat in self._mtime.items():
if (
self._StatPath(os.path.join(self._gitdir, name))
!= prev_stat
):
return True
return False
def _LoadAll(self):
with repo_trace.Trace(": load refs %s", self._gitdir):
if self._TryReadFilesBackend():
self._ResolveSymrefs()
return
self._files_authoritative = False
self._phyref = {}
self._symref = {}
self._mtime = {}
@@ -205,19 +354,238 @@ class GitRefs:
self._ResolveSymrefs()
self._RefreshMtimes()
def _CheckFilesBackendConfig(self) -> Optional[_StatKey]:
"""Verify standard files ref backend preconditions and stat config."""
if "GIT_DIR" in os.environ or "GIT_COMMON_DIR" in os.environ:
return None
if not os.path.isdir(self._gitdir):
return None
for special in ("reftable", "commondir"):
if os.path.lexists(os.path.join(self._gitdir, special)):
return None
cfg_path = os.path.join(self._gitdir, "config")
if platform_utils.islink(cfg_path):
return None
try:
with open(cfg_path, "rb") as cfg_fd:
if b"refstorage" in cfg_fd.read().lower():
return None
return self._FormatStat(os.fstat(cfg_fd.fileno()))
except OSError:
return None
@classmethod
def _ParseLooseRefFile(
cls, path: str
) -> Optional[Tuple[bool, str, _StatKey]]:
"""Parse a loose ref or HEAD file into (is_symref, val, stat)."""
if platform_utils.islink(path):
return None
try:
with open(path, "r", encoding="utf-8") as fd:
raw = fd.read(1024)
st = cls._FormatStat(os.fstat(fd.fileno()))
except (OSError, UnicodeDecodeError):
return None
line = raw.rstrip("\r\n")
if not line or "\n" in line or "\r" in line:
return None
if line.startswith("ref:"):
target = line[4:].strip()
if not target or target == R_HEADS + ".invalid":
return None
if target != HEAD and (
not target.startswith("refs/") or not _IsValidRefName(target)
):
return None
return (True, target, st)
oid = line.strip()
if not _HEX_OID_RE.match(oid) or cls._IsNullRef(oid):
return None
return (False, oid, st)
@classmethod
def _ReadPackedRefs(
cls, packed_path: str
) -> Optional[Tuple[Dict[str, str], _StatKey]]:
"""Parse a packed-refs file into (ref_map, stat)."""
if platform_utils.islink(packed_path):
return None
packed: Dict[str, str] = {}
try:
with open(packed_path, "r", encoding="utf-8") as packed_fd:
for raw_line in packed_fd:
if raw_line.startswith("#"):
continue
line = raw_line.rstrip("\r\n")
if line.startswith("^"):
if not _HEX_OID_RE.match(line[1:]):
return None
continue
parts = line.split(" ", 1)
if len(parts) != 2:
return None
oid, ref_name = parts
if (
not _HEX_OID_RE.match(oid)
or cls._IsNullRef(oid)
or not ref_name.startswith("refs/")
or not _IsValidRefName(ref_name)
):
return None
packed[ref_name] = oid
st = cls._FormatStat(os.fstat(packed_fd.fileno()))
except (OSError, UnicodeDecodeError):
return None
return (packed, st)
def _TryReadFilesBackend(self) -> bool:
"""Load HEAD, packed-refs, and loose refs directly when safe.
Returns:
True if the repository uses a standard files ref backend and all
refs and stat fingerprints were read cleanly without invoking git.
"""
cfg_stat = self._CheckFilesBackendConfig()
if cfg_stat is None:
return False
mtime: Dict[str, Optional[_StatKey]] = {
"config": cfg_stat,
"reftable": None,
}
phyref: Dict[str, str] = {}
symref: Dict[str, str] = {}
parsed_head = self._ParseLooseRefFile(os.path.join(self._gitdir, HEAD))
if parsed_head is None:
return False
head_is_sym, head_val, head_stat = parsed_head
mtime[HEAD] = head_stat
if head_is_sym:
symref[HEAD] = head_val
else:
phyref[HEAD] = head_val
packed_path = os.path.join(self._gitdir, "packed-refs")
packed_stat: Optional[_StatKey] = None
if os.path.lexists(packed_path):
packed_res = self._ReadPackedRefs(packed_path)
if packed_res is None:
return False
packed_map, packed_stat = packed_res
phyref.update(packed_map)
mtime["packed-refs"] = packed_stat
refs_dir = os.path.join(self._gitdir, "refs")
if platform_utils.islink(refs_dir):
return False
refs_stat = self._StatPath(refs_dir)
if refs_stat is None or not stat.S_ISDIR(refs_stat[0]):
return False
mtime["refs"] = refs_stat
try:
to_scan = [("refs", "refs", refs_dir)]
while to_scan:
ref_prefix, rel_os_prefix, abs_dir = to_scan.pop()
with os.scandir(abs_dir) as entries:
for entry in entries:
if entry.is_symlink():
return False
ref_name = f"{ref_prefix}/{entry.name}"
if not _IsValidRefName(ref_name):
return False
rel_os = os.path.join(rel_os_prefix, entry.name)
if entry.is_dir(follow_symlinks=False):
if ref_name in phyref:
return False
# DirEntry.stat() leaves st_ino == 0 on Windows,
# whereas _StatPath() uses os.lstat() and populates
# st_ino. Stat via _StatPath() so _NeedUpdate() and
# _HasExternalRefChanges() compare matching keys.
d_stat = self._StatPath(entry.path)
if d_stat is None or not stat.S_ISDIR(d_stat[0]):
return False
mtime[rel_os] = d_stat
to_scan.append((ref_name, rel_os, entry.path))
elif entry.is_file(follow_symlinks=False):
parsed = self._ParseLooseRefFile(entry.path)
if parsed is None:
return False
is_sym, val, f_stat = parsed
mtime[rel_os] = f_stat
if is_sym:
phyref.pop(ref_name, None)
symref[ref_name] = val
else:
symref.pop(ref_name, None)
phyref[ref_name] = val
else:
return False
except OSError:
return False
# Guard against a concurrent `git pack-refs --prune` writing
# packed-refs and unlinking loose refs mid-scan.
if self._StatPath(packed_path) != packed_stat:
return False
self._phyref = phyref
self._symref = symref
self._mtime = mtime
self._files_authoritative = True
return True
def _RefreshMtimes(self) -> None:
"""Record current modification times for ref storage paths."""
self._mtime = {}
self._TrackMtime(HEAD)
self._TrackMtime("config")
self._TrackMtime("packed-refs")
self._TrackMtime(HEAD, track_missing=True)
self._TrackMtime("config", track_missing=True)
self._TrackMtime("packed-refs", track_missing=True)
self._TrackMtime("refs", track_missing=True)
self._TrackMtime("reftable", track_missing=True)
self._TrackTreeMtimes("refs")
self._TrackTreeMtimes("reftable")
@staticmethod
def _FormatStat(st: os.stat_result) -> _StatKey:
# ctime is left out: tools that only touch metadata, such as backup
# agents and file system crawlers, move it without changing a ref.
return (st.st_mode, st.st_mtime_ns, st.st_size, st.st_ino)
@classmethod
def _StatPath(cls, path: str) -> Optional[_StatKey]:
try:
st = os.lstat(path)
except OSError:
return None
mount_tag = getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", -1)
is_reparse_dir = getattr(st, "st_reparse_tag", 0) == mount_tag
if stat.S_ISLNK(st.st_mode) or is_reparse_dir:
# Track the symlink target so mutations to symlinked refs or
# packed-refs invalidate the fallback snapshot, while keeping
# S_IFLNK in mode and negative size when dangling.
link_mode = stat.S_IFLNK | stat.S_IMODE(st.st_mode)
try:
target_st = os.stat(path)
except OSError:
return (link_mode, st.st_mtime_ns, -1 - st.st_size, st.st_ino)
return (
link_mode,
target_st.st_mtime_ns,
target_st.st_size,
target_st.st_ino,
)
return cls._FormatStat(st)
@staticmethod
def _IsNullRef(ref_id: str) -> bool:
"""Check if a ref_id is a null object ID."""
return ref_id and all(ch == "0" for ch in ref_id)
return bool(ref_id) and all(ch == "0" for ch in ref_id)
def _ReadRefs(self) -> bool:
"""Read all references using git for-each-ref.
@@ -299,32 +667,45 @@ class GitRefs:
if ref_id:
self._phyref[name] = ref_id
def _TrackMtime(self, name: str) -> None:
"""Track the modification time of a specific gitdir path."""
path = os.path.join(self._gitdir, name)
try:
self._mtime[name] = os.path.getmtime(path)
except OSError:
return
def _TrackMtime(self, name: str, track_missing: bool = False) -> None:
"""Track the stat fingerprint of a specific gitdir path."""
st = self._StatPath(os.path.join(self._gitdir, name))
if st is not None or track_missing:
self._mtime[name] = st
def _TrackTreeMtimes(self, root: str) -> None:
"""Recursively track modification times for a directory tree."""
root_path = os.path.join(self._gitdir, root)
try:
if not platform_utils.isdir(root_path):
self._TrackMtime(root)
return
except OSError:
return
visited: Set[Tuple[int, int]] = set()
to_scan = [root]
while to_scan:
name = to_scan.pop()
self._TrackMtime(name)
path = os.path.join(self._gitdir, name)
if not platform_utils.isdir(path):
try:
dir_st = os.stat(path)
except OSError:
continue
if not stat.S_ISDIR(dir_st.st_mode):
continue
if dir_st.st_ino:
dir_key = (dir_st.st_dev, dir_st.st_ino)
if dir_key in visited:
continue
visited.add(dir_key)
for child in platform_utils.listdir(path):
try:
children = platform_utils.listdir(path)
except OSError:
continue
for child in children:
child_name = os.path.join(name, child)
child_path = os.path.join(self._gitdir, child_name)
if platform_utils.isdir(child_path):
+4
View File
@@ -4664,6 +4664,8 @@ class Project:
msg = "manifest set to %s" % self.revisionId
dst = self.revisionId + "^0"
active_git.UpdateRef(ref, dst, message=msg, detach=True)
if active_git is self.bare_git:
self.bare_ref.UpdateRef(ref)
else:
remote = self.GetRemote()
dst = remote.ToLocal(self.revisionExpr)
@@ -4671,6 +4673,8 @@ class Project:
msg = "manifest set to %s" % self.revisionExpr
if detach:
active_git.UpdateRef(ref, dst, message=msg, detach=True)
if active_git is self.bare_git:
self.bare_ref.UpdateRef(ref)
else:
active_git.symbolic_ref("-m", msg, ref, dst)
if active_git is self.bare_git:
+334
View File
@@ -16,7 +16,9 @@
import os
from pathlib import Path
import stat
import subprocess
import time
from typing import Any, List
from unittest import mock
@@ -25,6 +27,7 @@ import utils_for_test
import git_command
import git_refs
import platform_utils
def _run(repo: str, *args: str) -> str:
@@ -406,3 +409,334 @@ def test_update_symref_propagates_chained_symrefs(tmp_path: Path) -> None:
assert refs.get("refs/remotes/m/main") == c2
assert refs.get("refs/remotes/m/alias") == c2
assert refs.get("HEAD") == c2
def test_files_backend_reads_loose_and_packed_without_git(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Files backend reads packed-refs, loose refs, and symrefs in Python."""
repo = _init_repo(tmp_path, reftable=False)
branch = _run(repo, "symbolic-ref", "--short", "HEAD")
c1 = _run(repo, "rev-parse", "HEAD")
_run(repo, "tag", "-a", "-m", "v1", "v1.0")
tag_oid = _run(repo, "rev-parse", "refs/tags/v1.0")
_run(repo, "branch", "packed-only", c1)
_run(repo, "branch", "overridden", c1)
_run(repo, "pack-refs", "--all")
Path(os.path.join(repo, "a")).write_text("2", encoding="utf-8")
_run(repo, "add", "a")
_run(repo, "commit", "-q", "-m", "second")
c2 = _run(repo, "rev-parse", "HEAD")
_run(repo, "update-ref", "refs/heads/overridden", c2)
_run(repo, "symbolic-ref", "refs/remotes/m/main", "refs/heads/overridden")
monkeypatch.delenv("GIT_DIR", raising=False)
monkeypatch.delenv("GIT_COMMON_DIR", raising=False)
seen_cmds: List[List[str]] = []
class SpyGitCommand(git_command.GitCommand):
def __init__(
self,
proj: Any,
cmdv: List[str],
*args: Any,
**kwargs: Any,
) -> None:
seen_cmds.append(list(cmdv))
super().__init__(proj, cmdv, *args, **kwargs)
monkeypatch.setattr(git_command, "GitCommand", SpyGitCommand)
refs = git_refs.GitRefs(os.path.join(repo, ".git"))
assert refs.get("HEAD") == c2
assert refs.is_files_authoritative
with mock.patch.object(
refs,
"_LoadAll",
side_effect=AssertionError("expected warm GitRefs snapshot"),
):
assert refs.get(f"refs/heads/{branch}") == c2
assert refs.get("refs/heads/packed-only") == c1
assert refs.get("refs/heads/overridden") == c2
assert refs.get("refs/remotes/m/main") == c2
assert refs.get("refs/tags/v1.0") == tag_oid
assert seen_cmds == []
# A concurrent .lock file triggers clean fallback to git for-each-ref.
lock_file = Path(repo, ".git", "refs", "heads", "packed-only.lock")
lock_file.write_text("", encoding="utf-8")
refs2 = git_refs.GitRefs(os.path.join(repo, ".git"))
assert refs2.get("refs/heads/packed-only") == c1
assert not refs2.is_files_authoritative
assert any(cmd and cmd[0] == "for-each-ref" for cmd in seen_cmds)
def test_try_resolve_ref_loose_packed_symref_and_missing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""TryResolveRef resolves loose/packed/symref refs without full scan."""
repo = _init_repo(tmp_path, reftable=False)
c1 = _run(repo, "rev-parse", "HEAD")
_run(repo, "branch", "packed-branch", c1)
_run(repo, "pack-refs", "--all")
Path(os.path.join(repo, "a")).write_text("2", encoding="utf-8")
_run(repo, "add", "a")
_run(repo, "commit", "-q", "-m", "second")
c2 = _run(repo, "rev-parse", "HEAD")
_run(repo, "update-ref", "refs/remotes/origin/main", c2)
_run(
repo,
"symbolic-ref",
"refs/remotes/m/main",
"refs/remotes/origin/main",
)
monkeypatch.delenv("GIT_DIR", raising=False)
monkeypatch.delenv("GIT_COMMON_DIR", raising=False)
gitdir = os.path.join(repo, ".git")
refs = git_refs.GitRefs(gitdir)
assert refs.TryResolveRef("refs/remotes/origin/main") == c2
assert refs.TryResolveRef("refs/heads/packed-branch") == c1
assert refs.TryResolveRef("refs/remotes/m/main") == c2
assert refs.TryResolveRef("refs/remotes/origin/missing") == ""
assert not refs.is_loaded
# A .lock file forces None so callers fall back to git.
lock_file = Path(gitdir, "refs", "remotes", "origin", "main.lock")
lock_file.write_text("", encoding="utf-8")
assert refs.TryResolveRef("refs/remotes/origin/main") is None
def test_detects_new_packed_refs_and_same_mtime_replace(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""GitRefs reloads on newly created packed-refs and same-mtime replaces."""
monkeypatch.delenv("GIT_DIR", raising=False)
monkeypatch.delenv("GIT_COMMON_DIR", raising=False)
repo = os.path.join(tmp_path, "fresh")
utils_for_test.init_git_tree(repo, ref_format="files")
gitdir = os.path.join(repo, ".git")
refs = git_refs.GitRefs(gitdir)
assert refs.all == {}
c1 = "1" * 40
c2 = "2" * 40
Path(gitdir, "packed-refs").write_text(
f"# pack-refs with: peeled fully-peeled sorted \n"
f"{c1} refs/remotes/origin/main\n",
encoding="utf-8",
)
assert refs.get("refs/remotes/origin/main") == c1
loose = Path(gitdir, "refs", "remotes", "origin", "topic")
loose.parent.mkdir(parents=True, exist_ok=True)
loose.write_text(c1 + "\n", encoding="utf-8")
assert refs.get("refs/remotes/origin/topic") == c1
# Replace the ref the way git does, by renaming a new file over it, then
# put the mtimes back so that only the new inode shows.
file_st = os.stat(loose)
dir_st = os.stat(loose.parent)
replacement = Path(gitdir, "topic.new")
replacement.write_text(c2 + "\n", encoding="utf-8")
os.replace(replacement, loose)
os.utime(loose, ns=(file_st.st_atime_ns, file_st.st_mtime_ns))
os.utime(loose.parent, ns=(dir_st.st_atime_ns, dir_st.st_mtime_ns))
assert refs.get("refs/remotes/origin/topic") == c2
@pytest.mark.skipif(
platform_utils.isWindows(),
reason="st_ctime is the creation time on Windows",
)
def test_ctime_only_change_keeps_snapshot_warm(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A ref file whose ctime alone moves does not invalidate the snapshot."""
monkeypatch.delenv("GIT_DIR", raising=False)
monkeypatch.delenv("GIT_COMMON_DIR", raising=False)
repo = _init_repo(tmp_path, reftable=False)
branch = _run(repo, "symbolic-ref", "--short", "HEAD")
head = _run(repo, "rev-parse", "HEAD")
gitdir = os.path.join(repo, ".git")
refs = git_refs.GitRefs(gitdir)
assert refs.get(f"refs/heads/{branch}") == head
# Backup agents and file system crawlers tag files with xattrs, which moves
# ctime and nothing else. Flipping a mode bit and putting it back does the
# same; repeat it until the timestamp granularity lets the change show.
loose = os.path.join(gitdir, "refs", "heads", branch)
before = os.lstat(loose)
mode = stat.S_IMODE(before.st_mode)
for _ in range(1000):
os.chmod(loose, mode ^ stat.S_IXUSR)
os.chmod(loose, mode)
if os.lstat(loose).st_ctime_ns != before.st_ctime_ns:
break
time.sleep(0.001)
else:
pytest.fail("chmod did not move ctime")
with mock.patch.object(
refs,
"_LoadAll",
side_effect=AssertionError("expected warm GitRefs snapshot"),
):
assert refs.get(f"refs/heads/{branch}") == head
@pytest.mark.parametrize("reftable", [False, True])
def test_update_ref_keeps_snapshot_warm(
tmp_path: Path, reftable: bool, monkeypatch: pytest.MonkeyPatch
) -> None:
"""UpdateRef stays warm on files backend and reloads on reftable."""
if reftable and not utils_for_test.supports_reftable():
pytest.skip("reftable not supported")
monkeypatch.delenv("GIT_DIR", raising=False)
monkeypatch.delenv("GIT_COMMON_DIR", raising=False)
repo = _init_repo(tmp_path, reftable=reftable)
branch = _run(repo, "symbolic-ref", "--short", "HEAD")
c1 = _run(repo, "rev-parse", "HEAD")
_run(repo, "symbolic-ref", "refs/remotes/m/main", f"refs/heads/{branch}")
Path(os.path.join(repo, "a")).write_text("2", encoding="utf-8")
_run(repo, "add", "a")
_run(repo, "commit", "-q", "-m", "second")
c2 = _run(repo, "rev-parse", "HEAD")
refs = git_refs.GitRefs(os.path.join(repo, ".git"))
assert refs.symref("refs/remotes/m/main") == f"refs/heads/{branch}"
assert refs.get("refs/remotes/m/main") == c2
_run(repo, "update-ref", "--no-deref", "refs/remotes/m/main", c1)
refs.UpdateRef("refs/remotes/m/main")
if reftable:
assert not refs.is_loaded
assert refs.symref("refs/remotes/m/main") == ""
assert refs.get("refs/remotes/m/main") == c1
else:
with mock.patch.object(
refs,
"_LoadAll",
side_effect=AssertionError("expected warm GitRefs snapshot"),
):
assert refs.symref("refs/remotes/m/main") == ""
assert refs.get("refs/remotes/m/main") == c1
# Setting GIT_COMMON_DIR invalidates instead of reading self._gitdir.
monkeypatch.setenv("GIT_COMMON_DIR", os.path.join(repo, ".git"))
refs.UpdateRef("refs/remotes/m/main")
assert not refs.is_loaded
def test_symlinked_ref_fallback_reloads_and_breaks_cycles(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Symlink fallback reloads on target changes and breaks dir cycles."""
monkeypatch.delenv("GIT_DIR", raising=False)
monkeypatch.delenv("GIT_COMMON_DIR", raising=False)
repo = _init_repo(tmp_path, reftable=False)
c1 = _run(repo, "rev-parse", "HEAD")
Path(os.path.join(repo, "a")).write_text("2", encoding="utf-8")
_run(repo, "add", "a")
_run(repo, "commit", "-q", "-m", "second")
c2 = _run(repo, "rev-parse", "HEAD")
gitdir = os.path.join(repo, ".git")
ext_dir = tmp_path / "ext_refs"
ext_dir.mkdir()
target_ref = ext_dir / "linked"
target_ref.write_text(c1 + "\n", encoding="utf-8")
link_ref = os.path.join(gitdir, "refs", "heads", "linked")
loop_dir = os.path.join(gitdir, "refs", "heads", "loop")
refs_root = os.path.join(gitdir, "refs")
used_junction = False
try:
os.symlink(str(target_ref), link_ref)
os.symlink(refs_root, loop_dir, target_is_directory=True)
tracked_ref = "refs/heads/linked"
except OSError:
if not platform_utils.isWindows():
raise
used_junction = True
ext_link_dir = os.path.join(gitdir, "refs", "remotes", "ext")
os.makedirs(os.path.dirname(ext_link_dir), exist_ok=True)
subprocess.run(
["cmd", "/c", "mklink", "/J", ext_link_dir, str(ext_dir)],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
subprocess.run(
["cmd", "/c", "mklink", "/J", loop_dir, refs_root],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
tracked_ref = "refs/remotes/ext/linked"
try:
# Remove loop_dir before running git for-each-ref, then verify cycle
# handling directly on _TrackTreeMtimes so git itself does not walk
# deep cyclic paths.
if used_junction:
os.rmdir(loop_dir)
else:
os.unlink(loop_dir)
refs = git_refs.GitRefs(gitdir)
assert refs.get(tracked_ref) == c1
assert not refs.is_files_authoritative
replacement = ext_dir / "linked.new"
replacement.write_text(c2 + "\n", encoding="utf-8")
st = os.stat(target_ref)
os.replace(replacement, target_ref)
os.utime(target_ref, ns=(st.st_atime_ns, st.st_mtime_ns))
assert refs.get(tracked_ref) == c2
if used_junction:
subprocess.run(
["cmd", "/c", "mklink", "/J", loop_dir, refs_root],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
else:
os.symlink(refs_root, loop_dir, target_is_directory=True)
refs._TrackTreeMtimes("refs")
assert os.path.join("refs", "heads", "loop") in refs._mtime
finally:
if used_junction:
for junc in (
loop_dir,
os.path.join(gitdir, "refs", "remotes", "ext"),
):
if os.path.lexists(junc):
os.rmdir(junc)
# Also exercise S_ISLNK target and dangling-link tracking directly so
# unprivileged Windows runs cover POSIX symlink stat transitions too.
lnk = stat.S_IFLNK | 0o777
reg = stat.S_IFREG | 0o644
fake_link = os.stat_result((lnk, 10, 1, 1, 0, 0, 8, 1, 1, 1))
fake_t1 = os.stat_result((reg, 20, 1, 1, 0, 0, 41, 1, 2, 1))
fake_t2 = os.stat_result((reg, 21, 1, 1, 0, 0, 41, 1, 2, 1))
with mock.patch("os.lstat", return_value=fake_link):
with mock.patch("os.stat", return_value=fake_t1):
s_t1 = git_refs.GitRefs._StatPath("sym")
with mock.patch("os.stat", return_value=fake_t2):
s_t2 = git_refs.GitRefs._StatPath("sym")
with mock.patch("os.stat", side_effect=FileNotFoundError):
s_dangling = git_refs.GitRefs._StatPath("sym")
assert s_t1 != s_t2
assert s_t1 != s_dangling
assert s_dangling is not None and not stat.S_ISDIR(s_dangling[0])