Source code for fractal.util.git

"""Functions for local git operations via subprocess."""

from __future__ import annotations

import pathlib
import subprocess
import typing
from typing import Literal, Optional

__all__ = []


@typing.overload
def run(
    cmd: list[str],
    *,
    cwd: Optional[pathlib.Path] = None,
    check: Literal[True] = True,
) -> str: ...


@typing.overload
def run(
    cmd: list[str],
    *,
    cwd: Optional[pathlib.Path] = None,
    check: Literal[False],
) -> Optional[str]: ...


[docs] def run( cmd: list[str], *, cwd: Optional[pathlib.Path] = None, check: bool = True, ) -> Optional[str]: """Run a git command and return stripped stdout. Args: cmd: Git subcommand and arguments (without ``git`` prefix). cwd: Working directory for the command. check: Raise ``RuntimeError`` on non-zero exit. Returns: Stripped stdout string, or ``None`` on non-zero exit when ``check`` is ``False``. """ full_cmd = ['git'] if cwd: full_cmd.extend(['-C', f'{cwd}']) full_cmd.extend(cmd) # a missing git binary is treated like a failed command, so callers that # pass check=False (e.g. the leading rev-parse) degrade to a clean no-op try: result = subprocess.run( full_cmd, capture_output=True, text=True, ) except FileNotFoundError as e: if check: cmd_string = ' '.join(cmd) raise RuntimeError(f'git {cmd_string} failed: {e}') from e return None if result.returncode != 0: if check: cmd_string = ' '.join(cmd) error = result.stderr.strip() raise RuntimeError( f'git {cmd_string} failed (exit {result.returncode}): {error!r}' ) return None return result.stdout.strip()
[docs] def run_bytes( cmd: list[str], *, cwd: Optional[pathlib.Path] = None, ) -> Optional[bytes]: """Run a git command and return raw stdout bytes (``None`` on failure). The binary-safe, unstripped, non-raising counterpart to :func:`run`, for reading a file blob (``git show <ref>:<path>``) or a NUL-separated listing where text decoding and whitespace stripping would corrupt content -- a non-zero exit (e.g. the path absent at that revision) degrades to ``None``. Args: cmd: Git subcommand and arguments (without ``git`` prefix). cwd: Working directory for the command. Returns: Raw stdout bytes, or ``None`` on non-zero exit. """ full_cmd = ['git'] if cwd: full_cmd.extend(['-C', f'{cwd}']) full_cmd.extend(cmd) # a missing git binary is treated like a failed command, matching run's # check=False branch, so blob reads degrade to a clean no-op try: result = subprocess.run(full_cmd, capture_output=True) except FileNotFoundError: return None if result.returncode != 0: return None return result.stdout
[docs] def toplevel(path: pathlib.Path, *, check: bool = True) -> Optional[pathlib.Path]: """Return the worktree top-level directory for a path. Args: path: A directory inside the worktree. check: Raise ``RuntimeError`` on non-zero exit. Returns: The worktree top-level, or ``None`` on non-zero exit when ``check`` is ``False``. """ cmd = ['rev-parse', '--show-toplevel'] result = run(cmd, cwd=path, check=check) if result is None: return None return pathlib.Path(result)
[docs] def common_dir(path: pathlib.Path) -> pathlib.Path: """Return the main git repo root (resolves through worktrees). Args: path: A directory inside any of the repo's worktrees. Returns: The main repository root. """ cmd = ['rev-parse', '--git-common-dir'] result = run(cmd, cwd=path) common = (path / result).resolve() # the parent-of-.git derivation only fits the standard layout -- a gitfile # repo (submodule, --separate-git-dir) nests its git dir elsewhere and # records its checkout in core.worktree, which --show-toplevel resolves # from inside the git dir if common.name == '.git': return common.parent cmd = ['rev-parse', '--show-toplevel'] result = run(cmd, cwd=common) return pathlib.Path(result)
[docs] def branch(path: pathlib.Path, *, check: bool = True) -> Optional[str]: """Return the checked-out branch name for a worktree. Args: path: A directory inside the worktree. check: Raise ``RuntimeError`` on non-zero exit. Returns: The branch name, or ``None`` on non-zero exit when ``check`` is ``False``. """ cmd = ['rev-parse', '--abbrev-ref', 'HEAD'] return run(cmd, cwd=path, check=check)
[docs] def worktree_map(repo_dir: pathlib.Path) -> dict[str, pathlib.Path]: """Map each branch to its on-disk worktree path from one ``git worktree list``. The batched form of :func:`find_worktree` -- callers resolving many branches (e.g. a listing decorating a whole subtree) build the map once instead of spawning a ``git worktree list`` per branch. A worktree whose directory no longer exists (``rm -rf``'d out of band, still listed by git as ``prunable``) is dropped, so both the listing and the resolver agree a hand-removed node is gone. Args: repo_dir: Main repo root. Returns: Mapping of branch name to worktree path. """ cmd = ['worktree', 'list', '--porcelain'] output = run(cmd, cwd=repo_dir, check=False) result = {} worktree = None for line in (output or '').splitlines(): if line.startswith('worktree '): worktree = line[len('worktree ') :] elif line.startswith('branch ') and worktree is not None: name = line[len('branch ') :].removeprefix('refs/heads/') # a worktree counts only if its dir survives on disk -- git still # lists an rm-rf'd worktree (as prunable), but it is really gone worktree_path = pathlib.Path(worktree) if worktree_path.is_dir(): result[name] = worktree_path return result
[docs] def find_worktree(repo_dir: pathlib.Path, branch: str) -> Optional[pathlib.Path]: """Find the on-disk worktree path for a branch. A thin lookup over :func:`worktree_map`, so it shares the same on-disk probe -- a worktree ``rm -rf``'d out of band still lists (as ``prunable``) in git's porcelain, but its directory is gone, so it resolves as absent here rather than handing back a dead path. Args: repo_dir: Main repo root. branch: Branch name. Returns: Worktree path, or ``None`` if not found. """ return worktree_map(repo_dir).get(branch)
[docs] def prunable(repo_dir: pathlib.Path) -> bool: """Return whether git lists any prunable (dead-on-disk) worktree. A worktree ``rm -rf``'d out of band lingers in ``git worktree list`` as ``prunable`` until ``git worktree prune`` clears its metadata (and the lingering entry keeps its branch ref checked out, resisting deletion) -- callers use this to point at that one-shot cleanup. Args: repo_dir: Main repo root. Returns: Whether at least one listed worktree is prunable. """ cmd = ['worktree', 'list', '--porcelain'] output = run(cmd, cwd=repo_dir, check=False) return any(line.startswith('prunable ') for line in (output or '').splitlines())