diff --git a/issues/PLAN32_multi-repo-project.md b/issues/PLAN32_multi-repo-project.md index 5f04ebf..9d8a5ce 100644 --- a/issues/PLAN32_multi-repo-project.md +++ b/issues/PLAN32_multi-repo-project.md @@ -158,6 +158,15 @@ https://gitlab.com/uttaro_dev/uttarov2.gitsystem0 列: `url`, `dir`, `branch` (空可), `init` (`1`/`0`)。primary は別変数 `DEVBASE_PRIMARY_DIR` で渡す (列を増やさず、entrypoint の `cd` 先判定を単純に保つ)。 +entrypoint 側の読み方 (`containers/base/entrypoint.sh`): + +```bash +printf '%s' "$DEVBASE_REPOS" | base64 -d | + while IFS=$'\x1f' read -r url dir branch init; do + ... + done +``` + ## 修正対象 devbase 本体: diff --git a/lib/devbase/project/__init__.py b/lib/devbase/project/__init__.py new file mode 100644 index 0000000..bc07db6 --- /dev/null +++ b/lib/devbase/project/__init__.py @@ -0,0 +1,10 @@ +"""プロジェクト設定 (``projects//project.yml``) の読み込み。""" + +from .config import ( # noqa: F401 + ProjectConfig, + RepoSpec, + decode_repo_plan, + encode_repo_plan, + load_project_config, + parse_project_config, +) diff --git a/lib/devbase/project/config.py b/lib/devbase/project/config.py new file mode 100644 index 0000000..2c4023a --- /dev/null +++ b/lib/devbase/project/config.py @@ -0,0 +1,407 @@ +"""``projects//project.yml`` の読み込み・正規化・検証 (PLAN32)。 + +1 プロジェクト = 1 コンテナ = **複数リポジトリ**構成の設定ファイルを扱う。 +人間が編集する正は YAML であり、コンテナへは正規化した「clone プラン」を +base64 テキスト (:func:`encode_repo_plan`) にして渡す。YAML の解釈をホスト側の +Python に閉じ込めることで、entrypoint (bash) は ``base64 -d`` と ``while read`` +だけで済み、コンテナイメージへ YAML パーサ依存を持ち込まずに済む。 + +スキーマ:: + + version: 1 # 必須 + scale: 1 # 任意。旧 CONTAINER_SCALE + open_editor: true # 任意。旧 DEVBASE_OPEN_EDITOR + work_dir: /work/carmo # 任意。既定は primary repo の /work/ + defaults: # 任意。repos の各要素へ継承させる既定値 + host: github.com + owner: volareinc + repos: + - repo: carmo # 必須 + primary: true # 任意。未指定なら先頭要素が primary + - repo: carmo-batch + dir: batch # 任意。/work 配下の clone 先名 (既定 repo 名) + branch: develop # 任意。clone 後に checkout + init: false # 任意 (既定 true)。clone 後の ./init.sh 実行有無 + +旧方式 (``env`` の ``GIT_USER`` / ``GIT_REPO``) への後方互換は持たない。 +``project.yml`` が無いプロジェクトは移行手順を案内して :class:`ConfigError` +を送出する (黙って単一 repo として動かすと、移行漏れが検出できないため)。 +""" + +from __future__ import annotations + +import base64 +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any, Iterable, Mapping, Optional, Sequence, Tuple + +import yaml + +from devbase.errors import ConfigError + +#: 設定ファイル名 (プロジェクトディレクトリ直下) +PROJECT_CONFIG_FILENAME = "project.yml" + +#: 対応するスキーマ版 +SUPPORTED_VERSION = 1 + +_TOP_LEVEL_KEYS = frozenset( + {"version", "scale", "open_editor", "work_dir", "defaults", "repos"}) +_REPO_KEYS = frozenset({"host", "owner", "repo", "dir", "branch", "init", "primary"}) +#: ``defaults`` に書けるのは repo ごとに異なるとは限らない項目だけ。 +#: ``dir`` / ``primary`` は repo 固有 (継承すると必ず重複・複数 primary になる)。 +_DEFAULTS_KEYS = frozenset({"host", "owner", "branch", "init"}) + +_DEFAULT_HOST = "github.com" + +#: wire format のフィールド区切り。US (unit separator, ``\x1f``) を使う。 +#: タブは bash の既定 ``IFS`` と同じ空白類に分類され、``IFS=$'\t' read`` では +#: 連続する区切りが 1 つに畳まれてしまうため、空フィールド (branch 未指定) が +#: 消えて以降の列がずれる。US は空白類ではないので空フィールドが保持される。 +_WIRE_FIELD_SEPARATOR = "\x1f" + + +@dataclass(frozen=True) +class RepoSpec: + """正規化済みの 1 リポジトリ分の clone 指定。""" + + host: str + owner: str + repo: str + dir: str + branch: Optional[str] + init: bool + primary: bool + + @property + def url(self) -> str: + """clone 先 URL。認証は既存の git 資格情報機構に委ねる (URL に含めない)。""" + return f"https://{self.host}/{self.owner}/{self.repo}.git" + + +@dataclass(frozen=True) +class RepoPlanEntry: + """wire format を復号した 1 行分 (entrypoint が受け取る情報と同じ)。""" + + url: str + dir: str + branch: Optional[str] + init: bool + + +@dataclass(frozen=True) +class ProjectConfig: + """``project.yml`` 1 ファイル分の正規化済み設定。""" + + version: int + repos: Tuple[RepoSpec, ...] + scale: Optional[int] = None + open_editor: Optional[bool] = None + work_dir: Optional[str] = None + + @property + def primary(self) -> RepoSpec: + """``cd`` 先・エディタの既定フォルダになる repo (常にちょうど 1 件)。""" + return next(repo for repo in self.repos if repo.primary) + + def resolved_work_dir(self) -> str: + """コンテナ内で開く既定フォルダ。明示指定が無ければ primary repo の dir。""" + return self.work_dir or f"/work/{self.primary.dir}" + + +# --------------------------------------------------------------------------- +# 読み込み +# --------------------------------------------------------------------------- + +def config_path(project_dir: Path) -> Path: + """プロジェクトディレクトリ内の ``project.yml`` のパス。""" + return Path(project_dir) / PROJECT_CONFIG_FILENAME + + +def load_project_config(project_dir: Path) -> ProjectConfig: + """``/project.yml`` を読み込む。 + + Raises: + ConfigError: ファイルが無い / YAML が壊れている / スキーマ違反。 + 旧 ``env`` 形式へのフォールバックはしない (PLAN32 は後方互換なし)。 + """ + path = config_path(project_dir) + if not path.is_file(): + raise ConfigError( + f"{path} がありません。PLAN32 以降、プロジェクトのリポジトリ構成は " + f"{PROJECT_CONFIG_FILENAME} で指定します。" + "旧 env 形式 (GIT_USER / GIT_REPO) からの移行は " + "`devbase project migrate-config` を実行してください。" + ) + + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + except UnicodeDecodeError as e: + raise ConfigError( + f"{path} を UTF-8 として読めません ({e})。" + f"{PROJECT_CONFIG_FILENAME} は UTF-8 で保存してください。") from e + except OSError as e: + raise ConfigError(f"{path} を読み込めません: {e}") from e + except yaml.YAMLError as e: + raise ConfigError(f"{path} の YAML を解釈できません: {e}") from e + + if raw is None: + raise ConfigError(f"{path} が空です。version と repos が必要です。") + if not isinstance(raw, Mapping): + raise ConfigError(f"{path} の最上位はマッピングである必要があります。") + + return parse_project_config(raw, source=str(path)) + + +def parse_project_config(data: Mapping[str, Any], source: str) -> ProjectConfig: + """読み込み済みのマッピングを正規化・検証する (I/O を伴わない)。 + + Args: + data: YAML を読み込んだマッピング + source: エラーメッセージに出す出所 (ファイルパス等) + """ + _reject_unknown_keys(data, _TOP_LEVEL_KEYS, source, "最上位") + + version = data.get("version") + # YAML では ``true`` が ``1``、``1.0`` が float として読まれ、どちらも + # ``== 1`` を満たしてしまう。整数のスキーマ版という契約を保つため、値の + # 一致だけでなく型そのものを厳密に見る (``type(...) is int`` は bool を + # 部分型として受理しない)。 + if type(version) is not int or version != SUPPORTED_VERSION: + raise ConfigError( + f"{source}: version は {SUPPORTED_VERSION} である必要があります " + f"(現在: {version!r})") + + defaults = data.get("defaults") + if defaults is None: + defaults = {} + if not isinstance(defaults, Mapping): + raise ConfigError(f"{source}: defaults はマッピングである必要があります。") + _reject_unknown_keys(defaults, _DEFAULTS_KEYS, source, "defaults") + + raw_repos = data.get("repos") + if not isinstance(raw_repos, Sequence) or isinstance(raw_repos, (str, bytes)): + raise ConfigError(f"{source}: repos はリストである必要があります。") + if not raw_repos: + raise ConfigError(f"{source}: repos が空です。1 件以上指定してください。") + + repos = [_parse_repo(entry, defaults, source, i) + for i, entry in enumerate(raw_repos)] + _validate_dirs(repos, source) + repos = _assign_primary(repos, source) + + return ProjectConfig( + version=SUPPORTED_VERSION, + repos=tuple(repos), + scale=_parse_scale(data.get("scale"), source), + open_editor=_parse_open_editor(data.get("open_editor"), source), + work_dir=_parse_work_dir(data.get("work_dir"), source), + ) + + +# --------------------------------------------------------------------------- +# wire format (entrypoint との契約) +# --------------------------------------------------------------------------- + +def encode_repo_plan(repos: Iterable[RepoSpec]) -> str: + """clone プランを base64 テキストへ符号化する。 + + entrypoint (bash) との契約: + + - 1 行 1 repo で ``urldirbranchinit``。```` は unit separator + (``\x1f``)、``init`` は ``1``/``0``、``branch`` 未指定は**空フィールド**。 + - フィールド区切りが空白類 (タブ) ではないため、``IFS=$'\x1f' read -r url + dir branch init`` で空フィールドが畳まれず、素直に 4 列として読める。 + - 行区切りは LF。**末尾にも LF を付ける**。``while read`` は EOF 直前の + 改行なし行を読み捨てる実装があるため、末尾 LF が無いと最後の行 (repo が + 1 件ならその唯一の行) が丸ごと落ちる。 + - 各フィールドは :func:`_require_token` で検証済みで、空白・制御文字 + (タブ・改行・US を含む) を一切含まない。よって区切り文字とフィールド値が + 衝突することはなく、エスケープも不要。 + + base64 にするのは、compose の変数展開 (``$``) や改行を含む値で構成ファイルが + 壊れないようにするため。primary は列に含めず ``DEVBASE_PRIMARY_DIR`` で別に + 渡す (entrypoint の ``cd`` 先判定を単純に保つ)。 + + 典型的な consumer:: + + printf '%s' "$DEVBASE_REPOS" | base64 -d | + while IFS=$'\x1f' read -r url dir branch init; do + ... + done + """ + lines = [ + _WIRE_FIELD_SEPARATOR.join( + [repo.url, repo.dir, repo.branch or "", "1" if repo.init else "0"]) + for repo in repos + ] + text = "".join(f"{line}\n" for line in lines) + return base64.b64encode(text.encode()).decode() + + +def decode_repo_plan(encoded: str) -> Tuple[RepoPlanEntry, ...]: + """:func:`encode_repo_plan` の逆変換 (契約テストと診断用)。 + + 末尾 LF や空行は無視するので、末尾 LF の有無は round trip に影響しない。 + """ + try: + text = base64.b64decode(encoded, validate=True).decode() + except (ValueError, UnicodeDecodeError) as e: + raise ConfigError(f"clone プランを復号できません: {e}") from e + + entries = [] + for line in text.splitlines(): + if not line: + continue + fields = line.split(_WIRE_FIELD_SEPARATOR) + if len(fields) != 4: + raise ConfigError(f"clone プランの列数が不正です: {line!r}") + url, directory, branch, init = fields + # init 列は wire format 上 ``1``/``0`` だけ。それ以外を False へ丸めると + # 壊れた値や将来の未知値が「init しない」として黙って通ってしまう。 + if init not in ("0", "1"): + raise ConfigError( + f"clone プランの init 列は 1 か 0 である必要があります: {line!r}") + entries.append(RepoPlanEntry( + url=url, dir=directory, branch=branch or None, init=init == "1")) + return tuple(entries) + + +# --------------------------------------------------------------------------- +# 内部: 検証 +# --------------------------------------------------------------------------- + +def _reject_unknown_keys(data: Mapping[str, Any], allowed: frozenset, + source: str, where: str) -> None: + """未知キーは黙って無視せずエラーにする (typo が設定漏れとして表れないように)。""" + unknown = sorted(str(key) for key in data if key not in allowed) + if unknown: + raise ConfigError( + f"{source}: {where}に未知のキーがあります: {', '.join(unknown)} " + f"(使えるキー: {', '.join(sorted(allowed))})") + + +def _parse_repo(entry: Any, defaults: Mapping[str, Any], source: str, + index: int) -> RepoSpec: + where = f"repos[{index}]" + if not isinstance(entry, Mapping): + raise ConfigError(f"{source}: {where} はマッピングである必要があります。") + _reject_unknown_keys(entry, _REPO_KEYS, source, where) + + merged = {**defaults, **entry} + + repo = _require_token(merged.get("repo"), "repo", source, where, + allow_slash=False) + owner = _require_token(merged.get("owner"), "owner", source, where, + allow_slash=True) + host = _require_token(merged.get("host", _DEFAULT_HOST), "host", source, where, + allow_slash=False) + + directory = merged.get("dir", repo) + directory = _require_token(directory, "dir", source, where, allow_slash=False) + if directory in (".", ".."): + raise ConfigError( + f"{source}: {where} の dir は /work 直下の名前である必要があります " + f"({directory!r})") + + branch = merged.get("branch") + if branch is not None: + branch = _require_token(branch, "branch", source, where, allow_slash=True) + + init = merged.get("init", True) + if not isinstance(init, bool): + raise ConfigError(f"{source}: {where} の init は真偽値です ({init!r})") + + primary = entry.get("primary", False) + if not isinstance(primary, bool): + raise ConfigError(f"{source}: {where} の primary は真偽値です ({primary!r})") + + return RepoSpec(host=host, owner=owner, repo=repo, dir=directory, + branch=branch, init=init, primary=primary) + + +def _require_token(value: Any, field: str, source: str, where: str, + allow_slash: bool) -> str: + """URL 組み立てと wire format を壊さない文字列であることを確かめる。 + + 空白・タブ・改行・制御文字は wire format (行区切り・US 区切り) を壊し、``/`` は + ``https:////.git`` の構造や ``/work/`` の階層を + 壊すため、項目ごとに許可を分ける (gitlab のサブグループやブランチ名の + ``feature/x`` は ``/`` を含むため許可する)。 + """ + # YAML は ``repo: 123`` を int として読むため、「未指定」「型が違う」「空」を + # 同じ "必須です" で片づけると「指定したのに必須と言われる」ことになる。 + # 特に branch のような省略可能なフィールドでは ``branch: ""`` に対して + # 「必須です」と返るのが矛盾して見える。 + if value is None: + raise ConfigError(f"{source}: {where} の {field} は必須です") + if not isinstance(value, str): + raise ConfigError( + f"{source}: {where} の {field} は文字列で指定してください " + f"({value!r})") + if not value: + raise ConfigError( + f"{source}: {where} の {field} に空文字は指定できません") + # ``isspace()`` だけでは NUL・DEL のような非空白の制御文字やゼロ幅空白が + # すり抜ける。URL 組み立て・``/work/``・wire format のいずれにとっても + # 害なので「印字できない文字」をまとめて弾く (通常の空白は印字可能なので + # ``isspace()`` 側で拾う)。 + if any(c.isspace() or not c.isprintable() for c in value): + raise ConfigError( + f"{source}: {where} の {field} に空白文字・制御文字は使えません " + f"({value!r})") + if not allow_slash and "/" in value: + raise ConfigError( + f"{source}: {where} の {field} に / は使えません ({value!r})") + if allow_slash and (value.startswith("/") or value.endswith("/")): + raise ConfigError( + f"{source}: {where} の {field} は / で始まる・終わることはできません " + f"({value!r})") + return value + + +def _validate_dirs(repos: Sequence[RepoSpec], source: str) -> None: + """同じ ``/work/`` を 2 つの repo が奪い合わないこと。""" + seen = set() + for repo in repos: + if repo.dir in seen: + raise ConfigError( + f"{source}: clone 先の dir が重複しています: {repo.dir!r}") + seen.add(repo.dir) + + +def _assign_primary(repos: Sequence[RepoSpec], source: str) -> list: + """primary をちょうど 1 件に確定する (未指定なら先頭)。""" + explicit = [repo for repo in repos if repo.primary] + if len(explicit) > 1: + names = ", ".join(repo.dir for repo in explicit) + raise ConfigError( + f"{source}: primary: true は 1 件だけ指定できます ({names})") + if explicit: + return list(repos) + first, *rest = repos + return [replace(first, primary=True), *rest] + + +def _parse_scale(value: Any, source: str) -> Optional[int]: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise ConfigError(f"{source}: scale は 1 以上の整数です ({value!r})") + return value + + +def _parse_open_editor(value: Any, source: str) -> Optional[bool]: + if value is None: + return None + if not isinstance(value, bool): + raise ConfigError(f"{source}: open_editor は真偽値です ({value!r})") + return value + + +def _parse_work_dir(value: Any, source: str) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + raise ConfigError(f"{source}: work_dir は文字列です ({value!r})") + return value.strip() diff --git a/tests/project/__init__.py b/tests/project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/project/test_config.py b/tests/project/test_config.py new file mode 100644 index 0000000..7d99ff6 --- /dev/null +++ b/tests/project/test_config.py @@ -0,0 +1,516 @@ +"""project.yml の読み込み・正規化・検証と clone プランの wire format""" + +from __future__ import annotations + +import base64 +import shutil +import subprocess + +import pytest + +from devbase.errors import ConfigError +from devbase.project.config import ( + decode_repo_plan, + encode_repo_plan, + load_project_config, + parse_project_config, +) + + +def write_project_yml(tmp_path, text: str): + (tmp_path / "project.yml").write_text(text, encoding="utf-8") + return tmp_path + + +# --------------------------------------------------------------------------- +# 正常系 +# --------------------------------------------------------------------------- + +def test_single_repo_defaults(): + """最小構成: host は github.com、dir は repo 名、init は有効、先頭が primary""" + config = parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + (repo,) = config.repos + assert repo.host == "github.com" + assert repo.owner == "volareinc" + assert repo.repo == "carmo" + assert repo.dir == "carmo" + assert repo.branch is None + assert repo.init is True + assert repo.primary is True + assert repo.url == "https://github.com/volareinc/carmo.git" + assert config.primary is repo + + +def test_defaults_are_inherited_and_overridable(): + config = parse_project_config({ + "version": 1, + "defaults": {"host": "github.com", "owner": "uttaro-dev2"}, + "repos": [ + {"repo": "uttarov2", "host": "gitlab.com", "owner": "uttaro_dev", "dir": "system"}, + {"repo": "uttarov2-doc"}, + {"repo": "uttarov2migration", "branch": "develop", "init": False}, + ], + }, source="project.yml") + + system, doc, migration = config.repos + assert system.url == "https://gitlab.com/uttaro_dev/uttarov2.git" + assert system.dir == "system" + assert doc.url == "https://github.com/uttaro-dev2/uttarov2-doc.git" + assert doc.dir == "uttarov2-doc" + assert migration.branch == "develop" + assert migration.init is False + + +def test_primary_can_be_chosen_explicitly(): + config = parse_project_config({ + "version": 1, + "defaults": {"owner": "volareinc"}, + "repos": [{"repo": "carmo-doc"}, {"repo": "carmo", "primary": True}], + }, source="project.yml") + + assert config.primary.repo == "carmo" + assert [r.primary for r in config.repos] == [False, True] + + +def test_optional_settings_are_read(): + config = parse_project_config({ + "version": 1, + "scale": 3, + "open_editor": False, + "work_dir": "/work/carmo/app", + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + assert config.scale == 3 + assert config.open_editor is False + assert config.work_dir == "/work/carmo/app" + + +def test_optional_settings_default_to_none(): + """未指定の設定は None。既定値の解釈は呼び出し側 (env / 既定値) に委ねる""" + config = parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + assert config.scale is None + assert config.open_editor is None + assert config.work_dir is None + + +def test_work_dir_defaults_to_primary_repo_dir(): + config = parse_project_config({ + "version": 1, + "defaults": {"owner": "volareinc"}, + "repos": [{"repo": "carmo-doc"}, {"repo": "carmo", "primary": True}], + }, source="project.yml") + + assert config.resolved_work_dir() == "/work/carmo" + + +def test_resolved_work_dir_prefers_explicit_value(): + config = parse_project_config({ + "version": 1, + "work_dir": "/work/carmo/app", + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + assert config.resolved_work_dir() == "/work/carmo/app" + + +def test_load_project_config_reads_file(tmp_path): + write_project_yml(tmp_path, """ +version: 1 +scale: 1 +defaults: + owner: KK-Generation +repos: + - repo: project-trygroup-prd + - repo: project-trygroup-prd-customer +""") + + config = load_project_config(tmp_path) + + assert config.scale == 1 + assert [r.dir for r in config.repos] == [ + "project-trygroup-prd", "project-trygroup-prd-customer"] + + +# --------------------------------------------------------------------------- +# 異常系 (後方互換は無いので、曖昧な設定は黙って通さない) +# --------------------------------------------------------------------------- + +def test_missing_file_is_an_error_with_migration_hint(tmp_path): + (tmp_path / "env").write_text("GIT_USER=volareinc\nGIT_REPO=carmo\n") + + with pytest.raises(ConfigError) as excinfo: + load_project_config(tmp_path) + + message = str(excinfo.value) + assert "project.yml" in message + assert "migrate-config" in message + + +def test_missing_owner_is_an_error(): + with pytest.raises(ConfigError, match="owner"): + parse_project_config({"version": 1, "repos": [{"repo": "carmo"}]}, + source="project.yml") + + +def test_missing_repo_is_an_error(): + with pytest.raises(ConfigError, match="repo"): + parse_project_config({"version": 1, "repos": [{"owner": "volareinc"}]}, + source="project.yml") + + +def test_duplicated_dir_is_an_error(): + with pytest.raises(ConfigError, match="dir"): + parse_project_config({ + "version": 1, + "defaults": {"owner": "volareinc"}, + "repos": [{"repo": "carmo"}, {"repo": "carmo-batch", "dir": "carmo"}], + }, source="project.yml") + + +def test_multiple_primary_is_an_error(): + with pytest.raises(ConfigError, match="primary"): + parse_project_config({ + "version": 1, + "defaults": {"owner": "volareinc"}, + "repos": [{"repo": "carmo", "primary": True}, + {"repo": "carmo-batch", "primary": True}], + }, source="project.yml") + + +def test_empty_repos_is_an_error(): + with pytest.raises(ConfigError, match="repos"): + parse_project_config({"version": 1, "repos": []}, source="project.yml") + + +def test_unknown_key_is_an_error(): + """typo を黙って無視しない""" + with pytest.raises(ConfigError, match="brunch"): + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo", "brunch": "main"}], + }, source="project.yml") + + +def test_unknown_top_level_key_is_an_error(): + with pytest.raises(ConfigError, match="container_scale"): + parse_project_config({ + "version": 1, + "container_scale": 2, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + +def test_unsupported_version_is_an_error(): + with pytest.raises(ConfigError, match="version"): + parse_project_config({ + "version": 2, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + +def test_missing_version_is_an_error(): + with pytest.raises(ConfigError, match="version"): + parse_project_config({ + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + +@pytest.mark.parametrize("bad_version", [True, False, 1.0, "1"]) +def test_non_integer_version_is_an_error(bad_version): + """YAML の ``true`` / ``1.0`` は ``== 1`` を満たすため型で明示的に弾く""" + with pytest.raises(ConfigError, match="version"): + parse_project_config({ + "version": bad_version, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + +@pytest.mark.parametrize("bad_defaults", [[], False, 0, "host", ["host"]]) +def test_non_mapping_defaults_is_an_error(bad_defaults): + """falsy な非マッピングを空マッピング扱いで黙って受理しない""" + with pytest.raises(ConfigError, match="defaults"): + parse_project_config({ + "version": 1, + "defaults": bad_defaults, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + +def test_null_defaults_is_treated_as_empty(): + """``defaults:`` と書いただけ (null) は未指定と同じ扱い""" + config = parse_project_config({ + "version": 1, + "defaults": None, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + assert config.repos[0].host == "github.com" + + +@pytest.mark.parametrize("bad_dir", ["../escape", "nested/dir", ".", "..", "/abs"]) +def test_dir_must_stay_directly_under_work(bad_dir): + """/work の外へ抜ける dir を許すと clone 先が予測できなくなる""" + with pytest.raises(ConfigError, match="dir"): + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo", "dir": bad_dir}], + }, source="project.yml") + + +@pytest.mark.parametrize("field,value", [ + ("owner", "vola reinc"), + ("repo", "car\tmo"), + ("host", "github.com/extra"), + ("branch", "main\nrm -rf"), + ("repo", "car\x1fmo"), # US — wire format の列区切りそのもの +]) +def test_fields_reject_whitespace_and_separators(field, value): + """wire format (US 区切り・LF 行区切り) と URL 組み立てを壊す値を弾く""" + spec = {"owner": "volareinc", "repo": "carmo"} + spec[field] = value + with pytest.raises(ConfigError, match=field): + parse_project_config({"version": 1, "repos": [spec]}, source="project.yml") + + +@pytest.mark.parametrize("value", [ + "car\x00mo", # NUL — bash の read / git のどちらにとっても異物 + "car\x07mo", # BEL + "car\x7fmo", # DEL + "car\u200bmo", # ゼロ幅空白 — 目視できないまま URL に混ざる +]) +def test_fields_reject_non_whitespace_control_characters(value): + """``isspace()`` ではすり抜ける制御文字・ゼロ幅空白も弾く + + :func:`encode_repo_plan` の docstring が「制御文字を一切含まない」と + 宣言している以上、空白判定だけでは契約を満たせない。 + """ + with pytest.raises(ConfigError, match="repo"): + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": value}], + }, source="project.yml") + + +def test_scale_must_be_a_positive_integer(): + with pytest.raises(ConfigError, match="scale"): + parse_project_config({ + "version": 1, "scale": 0, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + +def test_open_editor_must_be_boolean(): + with pytest.raises(ConfigError, match="open_editor"): + parse_project_config({ + "version": 1, "open_editor": "yes", + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + +def test_broken_yaml_is_an_error(tmp_path): + write_project_yml(tmp_path, "version: 1\nrepos: [") + + with pytest.raises(ConfigError, match="project.yml"): + load_project_config(tmp_path) + + +def test_non_utf8_file_is_an_error_with_encoding_hint(tmp_path): + """Shift-JIS 等で保存されたファイルは UTF-8 での保存を案内する""" + (tmp_path / "project.yml").write_bytes( + "version: 1 # 日本語コメント\n".encode("cp932")) + + with pytest.raises(ConfigError, match="UTF-8"): + load_project_config(tmp_path) + + +def test_yaml_root_must_be_a_mapping(tmp_path): + write_project_yml(tmp_path, "- repo: carmo") + + with pytest.raises(ConfigError, match="project.yml"): + load_project_config(tmp_path) + + +# --------------------------------------------------------------------------- +# wire format (entrypoint との契約) +# --------------------------------------------------------------------------- + +#: wire format のフィールド区切り (unit separator)。 +US = "\x1f" + +#: entrypoint (bash) が想定する読み取り方をそのまま再現する consumer。 +#: 素朴な ``while read`` で 4 列が欠けずに読めることを、実際の bash で確かめる。 +_BASH_CONSUMER = r""" +set -eu +printf '%s' "$PLAN" | base64 -d | + while IFS=$'\x1f' read -r url dir branch init; do + printf '[%s][%s][%s][%s]\n' "$url" "$dir" "$branch" "$init" + done +""" + + +def run_bash_consumer(encoded: str) -> list: + """符号化済み clone プランを bash の ``while read`` で読ませて行を返す。""" + result = subprocess.run( + ["bash", "-c", _BASH_CONSUMER], + env={"PLAN": encoded, "PATH": "/usr/bin:/bin:/usr/local/bin"}, + capture_output=True, text=True, check=True, + ) + return result.stdout.splitlines() + + +requires_bash = pytest.mark.skipif( + shutil.which("bash") is None, reason="bash が無い環境ではスキップ") + + +def test_encode_repo_plan_is_base64_unit_separated(): + config = parse_project_config({ + "version": 1, + "defaults": {"owner": "uttaro-dev2"}, + "repos": [ + {"repo": "uttarov2", "host": "gitlab.com", "owner": "uttaro_dev", "dir": "system"}, + {"repo": "uttarov2-doc", "branch": "develop", "init": False}, + ], + }, source="project.yml") + + encoded = encode_repo_plan(config.repos) + decoded = base64.b64decode(encoded).decode() + + assert decoded == ( + f"https://gitlab.com/uttaro_dev/uttarov2.git{US}system{US}{US}1\n" + f"https://github.com/uttaro-dev2/uttarov2-doc.git{US}uttarov2-doc" + f"{US}develop{US}0\n" + ) + + +def test_encoded_plan_ends_with_newline(): + """末尾 LF が無いと素朴な ``while read`` consumer が最後の行を落とす""" + config = parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + decoded = base64.b64decode(encode_repo_plan(config.repos)).decode() + + assert decoded.endswith("\n") + + +@requires_bash +def test_bash_consumer_reads_every_column_including_empty_branch(): + """branch 未指定 (空フィールド) でも init が branch にずれ込まないこと + + 区切りをタブにすると bash の IFS 空白扱いで連続区切りが 1 つに畳まれ、 + ``1`` が branch に入って init が空になる。US (\x1f) なら空フィールドが残る。 + """ + config = parse_project_config({ + "version": 1, + "defaults": {"owner": "volareinc"}, + "repos": [ + {"repo": "carmo"}, + {"repo": "carmo-batch", "branch": "develop", "init": False}, + ], + }, source="project.yml") + + lines = run_bash_consumer(encode_repo_plan(config.repos)) + + assert lines == [ + "[https://github.com/volareinc/carmo.git][carmo][][1]", + "[https://github.com/volareinc/carmo-batch.git][carmo-batch][develop][0]", + ] + + +@requires_bash +def test_bash_consumer_does_not_drop_the_last_line_for_a_single_repo(): + """末尾 LF が無いと 1 repo 構成では唯一の行がループ本体に入らない""" + config = parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + lines = run_bash_consumer(encode_repo_plan(config.repos)) + + assert lines == ["[https://github.com/volareinc/carmo.git][carmo][][1]"] + + +def test_repo_plan_round_trips(): + config = parse_project_config({ + "version": 1, + "defaults": {"owner": "volareinc"}, + "repos": [{"repo": "carmo"}, {"repo": "carmo-batch", "branch": "main"}], + }, source="project.yml") + + restored = decode_repo_plan(encode_repo_plan(config.repos)) + + assert [(e.url, e.dir, e.branch, e.init) for e in restored] == [ + ("https://github.com/volareinc/carmo.git", "carmo", None, True), + ("https://github.com/volareinc/carmo-batch.git", "carmo-batch", "main", True), + ] + + +@pytest.mark.parametrize("bad_init", ["", "2", "true", "0 "]) +def test_decode_rejects_init_column_outside_one_and_zero(bad_init): + """壊れた値・将来の未知値を「init しない」として黙って通さない""" + line = f"https://github.com/volareinc/carmo.git{US}carmo{US}{US}{bad_init}" + encoded = base64.b64encode(line.encode()).decode() + + with pytest.raises(ConfigError, match="init"): + decode_repo_plan(encoded) + + +def test_numeric_repo_name_reports_a_type_error_not_a_missing_field(): + """YAML が int として読む ``repo: 123`` に「必須です」と言わない + + 「指定したのに必須と言われる」を避けるため、未指定と型不一致を書き分ける。 + """ + with pytest.raises(ConfigError, match="repo は文字列で指定してください"): + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": 123}], + }, source="project.yml") + + +def test_unspecified_repo_reports_a_missing_field(): + with pytest.raises(ConfigError, match="repo は必須です"): + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": None}], + }, source="project.yml") + + +def test_empty_repo_reports_an_empty_value_not_a_missing_field(): + """``repo: ""`` は「指定はされている」ので「必須です」とは言わない""" + with pytest.raises(ConfigError, match="repo に空文字は指定できません"): + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": ""}], + }, source="project.yml") + + +def test_empty_optional_branch_is_rejected_as_empty_not_as_missing(): + """branch は省略可能なので ``branch: ""`` に「必須です」と返すと矛盾する""" + with pytest.raises(ConfigError, match="branch に空文字は指定できません"): + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo", "branch": ""}], + }, source="project.yml") + + +def test_encoded_plan_has_no_shell_or_compose_hazards(): + """compose の変数展開・改行で壊れないこと (base64 なので英数と = のみ)""" + config = parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + encoded = encode_repo_plan(config.repos) + + assert encoded.strip() == encoded + assert all(c.isalnum() or c in "+/=" for c in encoded)