Newer
Older
TIASDataHub / src / tiasdata / repo.py
"""本リポジトリ(TIASDataHub の clone)の場所と git の状態.

データセットの定義(`datasets/`)はリポジトリで管理し,版には定義とコードの版(コミット)を
記録する(SPEC_03「版の作成」).そのため版の作成はリポジトリの clone から実行し,
未コミットの変更が無いことを確かめてから行う.
"""

import subprocess
import tomllib
from pathlib import Path
from typing import NamedTuple

_PROJECT_NAME = "tiasdata"


class RepoState(NamedTuple):
    """リポジトリの git の状態.

    Attributes:
        commit: `HEAD` のコミット(`git rev-parse HEAD`).
        uncommitted: 未コミットの変更(未追跡のファイルを含む.無視するファイルは除く)の数.
    """

    commit: str
    uncommitted: int


def find_repo_root() -> Path:
    """本リポジトリのルートを返す.

    このパッケージの位置から親のフォルダを辿り,`[project] name = "tiasdata"` の
    `pyproject.toml` があるフォルダをルートとみなす(`importers.reviews`・`datasets` が使う).

    Returns:
        リポジトリのルート.

    Raises:
        ValueError: ルートが見つからない(パッケージとして別の場所に入れて実行した等).
    """
    package_dir = Path(__file__).resolve().parent
    for parent in package_dir.parents:
        pyproject = parent / "pyproject.toml"
        if pyproject.is_file() and _is_tiasdata_project(pyproject):
            return parent
    raise ValueError(
        "TIASDataHub のリポジトリの場所が分からない."
        "データセットの操作はリポジトリの clone から実行する(uv run tiasdata dataset …)"
    )


def _is_tiasdata_project(pyproject: Path) -> bool:
    try:
        data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
    except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError):
        return False
    project = data.get("project")
    return isinstance(project, dict) and project.get("name") == _PROJECT_NAME


def _git(root: Path, *args: str) -> str:
    try:
        completed = subprocess.run(
            ["git", "-C", str(root), *args],
            capture_output=True,
            text=True,
            encoding="utf-8",
            check=False,
        )
    except OSError as e:
        raise ValueError(f"git を実行できない(git {args[0]}): {e.strerror}") from e
    if completed.returncode != 0:
        # git のエラーメッセージはリポジトリのパスを含みうるため出さない
        raise ValueError(
            f"git {args[0]} が失敗した(終了コード {completed.returncode})."
            "リポジトリの clone から実行しているか確かめる"
        )
    return completed.stdout


def read_repo_state(root: Path | None = None) -> RepoState:
    """リポジトリの `HEAD` のコミットと未コミットの変更の数を返す.

    Args:
        root: リポジトリのルート(省略時は `find_repo_root()`).

    Returns:
        git の状態.

    Raises:
        ValueError: リポジトリが見つからない,または git を実行できない.
    """
    directory = root if root is not None else find_repo_root()
    commit = _git(directory, "rev-parse", "HEAD").strip()
    # 未追跡のファイルも数える(定義ファイルを追加しただけでコミットしていない場合を止めるため)
    status = _git(directory, "status", "--porcelain", "--untracked-files=all")
    uncommitted = sum(1 for line in status.splitlines() if line.strip())
    return RepoState(commit=commit, uncommitted=uncommitted)