"""push で送るファイルの選別と,手元のプロジェクトルートの探索.
リポジトリ内のコード一式を送り,キャッシュ・仮想環境・データ・実験結果・Claude の設定は
送らない(SPEC_03「push」).
"""
from __future__ import annotations
import os
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
# どの階層にあっても送らないディレクトリ名(.venv は Windows 用でサーバでは使えない)
EXCLUDED_DIR_NAMES = frozenset({".git", ".venv", "__pycache__", ".pytest_cache", ".ruff_cache"})
# プロジェクト直下でだけ除外するエントリ.data/ は臨床データ・合成データの置き場,runs/ は
# サーバ側の実験結果を上書きしないため,.claude/ は手元の Claude Code 設定のため送らない.
# 直下に限るのは src/tias_judge_model/data/(パッケージ)・tests/data/ を送るため
EXCLUDED_ROOT_ENTRIES = frozenset({"data", "runs", ".claude"})
_PACKAGE_DIR = Path("src") / "tias_judge_model"
@dataclass(frozen=True)
class PushEntry:
"""送信するファイル 1 件.
Attributes:
path: 手元の実ファイル.
arcname: リモートのプロジェクトルート基準の相対パス(POSIX 区切り).
size: バイト数.
"""
path: Path
arcname: str
size: int
def find_project_root(start: Path) -> Path:
"""start から上方向にたどり,このリポジトリのルートを探す.
Args:
start: 探索を始めるディレクトリ(通常はカレントディレクトリ).
Returns:
`pyproject.toml` と `src/tias_judge_model/` を持つディレクトリ.
Raises:
ValueError: 見つからない場合.
"""
start = start.resolve()
for directory in (start, *start.parents):
if (directory / "pyproject.toml").is_file() and (directory / _PACKAGE_DIR).is_dir():
return directory
raise ValueError(
f"{start} から上にプロジェクトルート(pyproject.toml と src/tias_judge_model/)が"
"見つからない.リポジトリ内で実行する"
)
def is_excluded(rel_path: str) -> bool:
"""プロジェクトルート基準の相対パスが push から除外されるかを返す.
Args:
rel_path: POSIX 区切りの相対パス(ファイルでもディレクトリでもよい).
Returns:
除外対象なら True.
"""
parts = PurePosixPath(rel_path).parts
if not parts:
return False
return parts[0] in EXCLUDED_ROOT_ENTRIES or any(part in EXCLUDED_DIR_NAMES for part in parts)
def _walk_files(root: Path) -> Iterator[PushEntry]:
"""root 配下のファイルを,除外規則を適用しつつ決定的な順で列挙する."""
for dirpath, dirnames, filenames in os.walk(root):
dir_rel = Path(dirpath).relative_to(root).as_posix()
prefix = "" if dir_rel == "." else f"{dir_rel}/"
dirnames[:] = sorted(d for d in dirnames if not is_excluded(prefix + d))
for filename in sorted(filenames):
rel = prefix + filename
path = Path(dirpath) / filename
if is_excluded(rel) or not path.is_file():
continue
yield PushEntry(path=path, arcname=rel, size=path.stat().st_size)
def collect_push_entries(root: Path) -> list[PushEntry]:
"""送信するファイルの一覧を作る.
Args:
root: プロジェクトルート.
Returns:
arcname 順に並べた送信対象.
"""
return sorted(_walk_files(root.resolve()), key=lambda entry: entry.arcname)