project: read HEAD directly in-memory to avoid subprocesses

Benchmark:
* repo upload frameworks/base: 2.84s -> 0.50s (-82.3%, 5.6x faster)
* repo upload (3,045 projects): 7.66s -> 5.24s (-31.6%, 2.42s saved)

From repo's inception through v2.56, GetHead() directly read the
`.git/HEAD` file in Python. In commit 52bab0ba ("project: Use git
rev-parse to read HEAD"), this was replaced with git subprocess calls
on the premise that git provides a dedicated command. However, in
large multi-project workspaces (such as Android with 3,000+ projects),
spawning thousands of git processes introduced severe latency
regressions during `repo upload` and `repo status`.

Furthermore, switching to subprocesses broke detached HEADs and
unborn branches (fixed in commits 7f7d70ef and 8c3585f3 by re-adding
the v2.56 file-reading logic as an error recovery fallback).

This patch restores fast in-memory reading as the primary path, while
adding modern defensive safeguards:
* Symbolic refs (`ref: refs/heads/...`): Strips whitespace and tabs
  and returns the ref directly in memory.
* Detached HEAD: Validates 40-char SHA-1 and 64-char SHA-256 commit
  hashes via git_config.IsId(), normalizing to lowercase.
* Symlinks: Detects filesystem symlinks via os.path.islink() and
  safely falls back to git symbolic-ref.
* Fallback: Catches (OSError, AssertionError) and transparently falls
  back to native git commands for reftables, unexpected layouts, or
  filesystem errors. Unifies recovery fallback parsing with the fast
  path (CRLF/tabs, lowercase hashes, and consistent RelPath errors).

In addition, this change substantially expands test coverage in
tests/test_project.py, adding comprehensive unit tests for symbolic
refs, whitespace/tabs, CRLF line endings, SHA-1, SHA-256, uppercase
hash normalization, symlinks, corrupted worktrees, and fallback
robustness.

Test: ./run_tests tests/test_project.py
Change-Id: Ib5c2530117c6939e4b9293feda81aa745c003c6a
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/623001
Commit-Queue: James Hawkins <jhawkins@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
Tested-by: James Hawkins <jhawkins@google.com>
This commit is contained in:
James Hawkins authored and gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com committed 2026-08-28 16:03:06 -07:00
1 parent d7299422ae
commit 4fe87617ff
2 files changed
+281 -14

No files matched your search

+59 -14
View File
@@ -4464,8 +4464,52 @@ class Project:
return dotgit if subpath is None else os.path.join(dotgit, subpath)
@staticmethod
def _ParseHead(line: str) -> Optional[str]:
"""Parse the content of a .git/HEAD file.
Handles both symbolic refs (e.g. 'ref: refs/heads/...') and raw
commit IDs (40-hex SHA-1 or 64-hex SHA-256).
Returns:
The ref name (e.g. 'refs/heads/main') or lowercase commit hash
if valid, or None if empty or invalid.
"""
line = line.strip()
if line.startswith("ref:"):
ref = line[4:].strip()
# Ensure the ref is not empty, pure whitespace, or the
# "refs/heads/.invalid" placeholder used for unborn branches,
# empty repositories, or when the reftables backend is used
# (which will be the default in Git 3.0).
if not ref or ref == R_HEADS + ".invalid":
return None
return ref
else:
# Normalize commit IDs to canonical lowercase hexadecimal,
# matching the output format of `git rev-parse`.
line_lower = line.lower()
if IsId(line_lower):
return line_lower
return None
def GetHead(self):
"""Return the ref that HEAD points to."""
path = None
try:
# Catch AssertionError raised by GetDotgitPath when worktree
# .git pointer file is malformed (e.g. missing 'gitdir:').
path = self.GetDotgitPath(subpath=HEAD)
if not platform_utils.islink(path):
with open(
path, "r", encoding="utf-8", errors="replace"
) as fd:
ref = self._ParseHead(fd.readline())
if ref:
return ref
except (OSError, AssertionError):
pass
try:
return self.symbolic_ref("-q", HEAD, log_as_error=False)
except GitError:
@@ -4485,22 +4529,23 @@ class Project:
# Fallback to direct file reading for compatibility with broken
# repos, e.g. if HEAD points to an unborn branch.
path = self.GetDotgitPath(subpath=HEAD)
if not path:
raise NoManifestException(
self._project.RelPath(local=False), str(e)
)
try:
with open(path) as fd:
line = fd.readline()
with open(
path, "r", encoding="utf-8", errors="replace"
) as fd:
ref = self._ParseHead(fd.readline())
except OSError:
raise NoManifestException(path, str(e))
try:
line = line.decode()
except AttributeError:
pass
if line.startswith("ref: "):
ref = line[5:-1]
else:
ref = line[:-1]
if ref == R_HEADS + ".invalid":
raise NoManifestException(path, str(e))
raise NoManifestException(
self._project.RelPath(local=False), str(e)
)
if not ref:
raise NoManifestException(
self._project.RelPath(local=False), str(e)
)
return ref
def SetHead(self, ref, message=None):