"""Lightweight git metadata helpers for state detection.""" from __future__ import annotations import logging from pathlib import Path logger = logging.getLogger(__name__) _GIT_DIR_POINTER_PREFIX = "gitdir: " """Prefix used by worktree-style `.git` files to point at the real git dir.""" _GIT_HEAD_REF_PREFIX = "ref: " """Prefix used by `HEAD` when it points at a named ref instead of a commit.""" _GIT_REF_PREFIXES = ("refs/heads/", "refs/remotes/", "refs/tags/", "refs/") """Known git ref prefixes stripped when formatting a branch-like display name.""" _git_dir_cache: dict[str, Path] = {} """Positive-only cache of resolved git metadata directories keyed by lookup path.""" def _abbreviate_git_ref(ref: str) -> str: """Convert a full git ref into a short display name. Args: ref: Full git ref name from repository metadata. Returns: The abbreviated ref name suitable for display. """ for prefix in _GIT_REF_PREFIXES: if ref.startswith(prefix): return ref.removeprefix(prefix) return ref def _parse_git_dir_pointer(git_entry: Path) -> Path | None: """Resolve a `.git` file containing a `gitdir:` pointer. Args: git_entry: `.git` file to parse. Returns: The resolved git directory path, or `None` if the file is not a valid gitdir pointer. """ try: raw = git_entry.read_text(encoding="utf-8").strip() except OSError: logger.debug("Failed to read gitdir pointer from %s", git_entry, exc_info=True) return None if not raw.startswith(_GIT_DIR_POINTER_PREFIX): return None pointer = raw.removeprefix(_GIT_DIR_POINTER_PREFIX).strip() if not pointer: return None git_dir = Path(pointer) if not git_dir.is_absolute(): git_dir = git_entry.parent / git_dir return git_dir.resolve(strict=False) def _normalize_lookup_path(path: str | Path) -> Path: """Normalize a lookup path for git metadata discovery. Args: path: Directory or file path inside a repository. Returns: A normalized absolute path when possible, or the expanded path if full resolution fails. """ try: return Path(path).expanduser().resolve(strict=False) except OSError: return Path(path).expanduser() def _find_git_dir_uncached(path: Path) -> Path | None: """Locate the effective git metadata directory without using caches. Args: path: Normalized directory or file path inside a repository. Returns: The git metadata directory for the repository containing `path`, or `None` when no repository can be identified. """ current = path if not current.is_dir(): current = current.parent for directory in (current, *current.parents): git_entry = directory / ".git" if git_entry.is_dir(): return git_entry if git_entry.is_file(): git_dir = _parse_git_dir_pointer(git_entry) if git_dir is not None and git_dir.is_dir(): return git_dir return None return None def find_git_dir(path: str | Path) -> Path | None: """Locate the effective git metadata directory for a path. Args: path: Directory or file path inside a repository. Returns: The git metadata directory for the repository containing `path`, or `None` when no repository can be identified. """ current = _normalize_lookup_path(path) key = str(current) cached = _git_dir_cache.get(key) if cached is not None: return cached git_dir = _find_git_dir_uncached(current) if git_dir is not None: _git_dir_cache[key] = git_dir return git_dir def find_git_root(path: str | Path) -> Path | None: """Locate the repository root for a path. Args: path: Directory or file path inside a repository. Returns: The repository root containing `path`, or `None` when no repository can be identified. """ current = _normalize_lookup_path(path) if not current.is_dir(): current = current.parent for directory in (current, *current.parents): git_entry = directory / ".git" if git_entry.is_dir(): return directory if git_entry.is_file(): git_dir = _parse_git_dir_pointer(git_entry) if git_dir is not None and git_dir.is_dir(): return directory return None return None def read_git_branch_from_filesystem(path: str | Path) -> str | None: """Read the current git branch from repository metadata. Args: path: Directory or file path inside a repository. Returns: The abbreviated branch name, `HEAD` for detached HEAD, an empty string when `path` is not inside a git repository, or `None` when metadata exists but cannot be parsed confidently. """ git_dir = find_git_dir(path) if git_dir is None: return "" head_path = git_dir / "HEAD" try: head = head_path.read_text(encoding="utf-8").strip() except FileNotFoundError: logger.debug("Git HEAD file not found in %s", git_dir) return None except OSError: logger.debug("Failed to read git HEAD from %s", git_dir, exc_info=True) return None if not head: return "" if head.startswith(_GIT_HEAD_REF_PREFIX): ref = head.removeprefix(_GIT_HEAD_REF_PREFIX).strip() return _abbreviate_git_ref(ref) if ref else None return "HEAD" def read_git_branch_via_subprocess(path: str | Path) -> str: """Fall back to `git rev-parse` for unusual repository layouts. Args: path: Directory or file path inside a repository. Returns: The branch name reported by git, or an empty string on failure. """ import subprocess # noqa: S404 # stdlib subprocess fallback try: result = subprocess.run( ["git", "rev-parse", "--abbrev-ref", "HEAD"], # noqa: S607 capture_output=True, text=True, timeout=2, check=False, cwd=path, ) if result.returncode == 0: return result.stdout.strip() except FileNotFoundError: pass # git not installed except subprocess.TimeoutExpired: logger.debug("Git branch detection timed out") except OSError: logger.debug("Git branch detection failed", exc_info=True) return "" def resolve_git_branch(path: str | Path) -> str: """Resolve the current git branch with a filesystem-first strategy. Args: path: Directory or file path inside a repository. Returns: The current branch name, `HEAD` for detached HEAD, or an empty string when no branch can be determined. """ branch = read_git_branch_from_filesystem(path) if branch is not None: return branch return read_git_branch_via_subprocess(path)