From 38680ffd3112d79aabb9d44753d6b53eccc04bf6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 23 Aug 2026 01:21:17 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(project):=20env=20=E3=81=8B=E3=82=89?= =?UTF-8?q?=20project.yml=20=E3=81=B8=E3=81=AE=E5=A4=89=E6=8F=9B=E3=82=B3?= =?UTF-8?q?=E3=83=9E=E3=83=B3=E3=83=89=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN32 Task 4。devbase project migrate-config で旧 env 形式 (GIT_USER / GIT_REPO / GIT_HOST / WORK_DIR / CONTAINER_SCALE / DEVBASE_OPEN_EDITOR) を project.yml へ機械的に変換する。配布中のプロジェクト 定義は 3 つの plugin リポジトリに 136 件あり、手で書き換えると取りこぼしが 混じるため。 - 変換対象キーは allowlist で限定し、それ以外 (ENABLE_SSH 等) は env に残す - 既存の project.yml は上書きしない。手で複数 repo 構成へ整えたものを壊さず、 env の旧キー掃除だけ行うので何度実行しても同じ状態に収束する - 旧キーを説明していた直前のコメント行も一緒に落とす。キーだけ消すと何を 説明しているか分からない行が残るため - 全部消えて空になった env にはファイルの役割を書いた雛形を残す (compose が env_file で参照するためファイル自体は消せない) - 生成した YAML はローダで検証してから書き出す - --dry-run で生成内容を確認でき、--projects-dir で devbase へリンクしていない plugin リポジトリ内の projects も直接変換できる Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/cli.py | 20 ++ lib/devbase/commands/project.py | 54 +++++ lib/devbase/project/migrate.py | 227 ++++++++++++++++++ tests/commands/test_project_migrate_config.py | 84 +++++++ tests/project/test_migrate.py | 197 +++++++++++++++ 5 files changed, 582 insertions(+) create mode 100644 lib/devbase/project/migrate.py create mode 100644 tests/commands/test_project_migrate_config.py create mode 100644 tests/project/test_migrate.py diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 063e2a5..1371120 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -242,6 +242,21 @@ def _add_project_parser(subparsers): # 取らない (wrapper の _PROJECT_NAME_SUBCOMMANDS にも含めない)。 _add_list_subparser(pj_sub) + # `migrate-config` は旧 env 形式から project.yml への変換 (PLAN32)。lifecycle + # ではないため wrapper の _PROJECT_NAME_SUBCOMMANDS には含めない。 + pj_migrate = pj_sub.add_parser( + 'migrate-config', + help='Convert legacy env (GIT_USER/GIT_REPO/...) into project.yml') + pj_migrate.add_argument('names', nargs='*', metavar='NAME', + help='Limit to the given projects (default: all)') + pj_migrate.add_argument('--dry-run', action='store_true', + help='Show what would change without writing') + # plugin リポジトリには devbase へリンクしていない projects/ もある + # (`repos///projects`)。一括移行のため直接指定できるようにする。 + pj_migrate.add_argument('--projects-dir', metavar='DIR', default=None, + help='Directory holding the projects ' + '(default: $DEVBASE_ROOT/projects)') + def _add_list_subparser(sub): """`list` サブコマンドを登録する (project list / top-level list 共通)。 @@ -762,6 +777,11 @@ def _dispatch(cmd, args): devbase_root = _require_devbase_root() from devbase.commands.project import cmd_project_list return cmd_project_list(devbase_root, args) + # `project migrate-config` も lifecycle ではなく projects/ 全体の変換。 + if getattr(args, 'subcommand', None) == 'migrate-config': + devbase_root = _require_devbase_root() + from devbase.commands.project import cmd_project_migrate_config + return cmd_project_migrate_config(devbase_root, args) from devbase.commands.container import cmd_project return cmd_project(args) diff --git a/lib/devbase/commands/project.py b/lib/devbase/commands/project.py index 37af37c..c7d6c1d 100644 --- a/lib/devbase/commands/project.py +++ b/lib/devbase/commands/project.py @@ -13,6 +13,7 @@ from __future__ import annotations import os +import sys from pathlib import Path from devbase.log import get_logger @@ -169,3 +170,56 @@ def cmd_project_list(devbase_root: Path, args) -> int: from devbase.tui import run as tui_run return tui_run(Path(devbase_root), args) + + +def cmd_project_migrate_config(devbase_root: Path, args) -> int: + """`devbase project migrate-config [name...] [--dry-run]` (PLAN32)。 + + 旧 ``env`` 形式 (``GIT_USER`` / ``GIT_REPO`` 等) を ``project.yml`` へ変換する。 + ``projects/`` は plugin リポジトリへの symlink なので、書き換わるのは + plugin リポジトリ側の実体ファイルになる。どのパスを触ったかを必ず表示する。 + + ``--projects-dir`` で別のディレクトリを指定できる。plugin リポジトリには + devbase へリンクしていない projects (``repos///projects``) も + あり、それらを一括で移行するため。 + """ + from devbase.project.migrate import migrate_project, migrate_projects + + override = getattr(args, 'projects_dir', None) + projects_dir = Path(override) if override else Path(devbase_root) / 'projects' + if not projects_dir.is_dir(): + print(f"projects ディレクトリがありません: {projects_dir}", file=sys.stderr) + return 1 + + names = list(getattr(args, 'names', None) or []) + dry_run = bool(getattr(args, 'dry_run', False)) + + if names: + missing = [name for name in names if not (projects_dir / name).is_dir()] + if missing: + print(f"プロジェクトが見つかりません: {', '.join(missing)}", + file=sys.stderr) + return 1 + results = [migrate_project(projects_dir / name, dry_run=dry_run) + for name in names] + else: + results = migrate_projects(projects_dir, dry_run=dry_run) + + if dry_run: + print("=== dry-run: ファイルは書き換えません ===") + + counts = {} + for result in results: + counts[result.status] = counts.get(result.status, 0) + 1 + detail = f" — {result.reason}" if result.reason else "" + print(f"[{result.status}] {result.name} ({result.path}){detail}") + if dry_run and result.project_yml: + print(_indent(result.project_yml)) + + print("--- " + " / ".join(f"{status}={count}" + for status, count in sorted(counts.items()))) + return 1 if counts.get('failed') else 0 + + +def _indent(text: str, prefix: str = ' ') -> str: + return ''.join(prefix + line + '\n' for line in text.splitlines()) diff --git a/lib/devbase/project/migrate.py b/lib/devbase/project/migrate.py new file mode 100644 index 0000000..0b584b1 --- /dev/null +++ b/lib/devbase/project/migrate.py @@ -0,0 +1,227 @@ +"""旧 ``env`` 形式から ``project.yml`` への移行 (PLAN32)。 + +PLAN32 で ``GIT_USER`` / ``GIT_REPO`` / ``GIT_HOST`` / ``WORK_DIR`` / +``CONTAINER_SCALE`` / ``DEVBASE_OPEN_EDITOR`` は ``project.yml`` へ移り、``env`` は +「コンテナへ渡す環境変数」だけを持つ。配布中のプロジェクト定義は 3 つの plugin +リポジトリに 136 件あり、手で書き換えると取りこぼしが混じるため機械的に変換する。 + +``env`` から読むキーは allowlist で限定し、それ以外 (``ENABLE_SSH`` 等) は +``env`` にそのまま残す。既に ``project.yml`` があるプロジェクトは**上書きしない** +(手で複数 repo 構成へ整えたものを壊さないため)。``env`` の旧キー掃除だけは行う +ので、何度実行しても同じ状態に収束する。 +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Tuple + +from devbase.errors import ConfigError +from devbase.log import get_logger +from devbase.project.config import ( + PROJECT_CONFIG_FILENAME, + parse_project_config, +) + +logger = get_logger(__name__) + +#: ``project.yml`` へ移すキー (これ以外は env に残す) +MIGRATED_KEYS: Tuple[str, ...] = ( + "GIT_HOST", "GIT_USER", "GIT_REPO", "WORK_DIR", + "CONTAINER_SCALE", "DEVBASE_OPEN_EDITOR", +) + +_DEFAULT_HOST = "github.com" +_TRUTHY = {"1", "true", "yes", "on"} + +#: 旧キーを全部落として空になった env に残す説明 (compose が参照するのでファイルは消せない) +_EMPTY_ENV_TEXT = ( + "# コンテナへ渡す環境変数を書く。\n" + "# devbase 自身の設定 (リポジトリ・scale・エディタ) は project.yml にある。\n" +) + +_ASSIGNMENT = re.compile(r'^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$') +_VAR_REF = re.compile(r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)') + + +@dataclass +class MigrationResult: + """1 プロジェクト分の移行結果 (``--dry-run`` でも同じものを返す)。""" + + name: str + path: Path + status: str # migrated / already / skipped / failed + reason: str = "" + project_yml: str = "" + env: str = "" + changed_env: bool = False + + +def migrate_project(project_dir: Path, dry_run: bool = False) -> MigrationResult: + """1 プロジェクトを ``project.yml`` 方式へ移行する。""" + project_dir = Path(project_dir).resolve() + name = project_dir.name + env_path = project_dir / "env" + config_path = project_dir / PROJECT_CONFIG_FILENAME + + if not env_path.is_file(): + return MigrationResult(name, project_dir, "skipped", + reason=f"env ファイルがありません ({env_path})") + + env_text = env_path.read_text(encoding="utf-8") + values = _parse_env(env_text) + new_env_text = _strip_migrated_keys(env_text) + env_changed = new_env_text != env_text + + if config_path.is_file(): + if env_changed and not dry_run: + env_path.write_text(new_env_text, encoding="utf-8") + return MigrationResult( + name, project_dir, "already", + reason=f"{PROJECT_CONFIG_FILENAME} が既にあります (上書きしません)", + env=new_env_text, changed_env=env_changed) + + missing = [key for key in ("GIT_USER", "GIT_REPO") if not values.get(key)] + if missing: + return MigrationResult( + name, project_dir, "skipped", + reason=f"env に {' / '.join(missing)} がないため変換できません") + + document = _build_project_yml(values) + try: + parse_project_config(_load_yaml(document), source=str(config_path)) + except ConfigError as e: + return MigrationResult(name, project_dir, "failed", + reason=str(e), project_yml=document) + + if not dry_run: + config_path.write_text(document, encoding="utf-8") + if env_changed: + env_path.write_text(new_env_text, encoding="utf-8") + + return MigrationResult(name, project_dir, "migrated", + project_yml=document, env=new_env_text, + changed_env=env_changed) + + +def migrate_projects(projects_dir: Path, dry_run: bool = False) -> List[MigrationResult]: + """``projects/`` 配下の全プロジェクトを移行する (名前順)。 + + ``projects/`` は plugin リポジトリへの symlink であることが多い。 + :func:`migrate_project` が実体パスへ解決するため、書き換わるのは plugin + リポジトリ側のファイルになる (そこが定義の正であるため意図どおり)。 + """ + projects_dir = Path(projects_dir) + entries = sorted( + (entry for entry in projects_dir.iterdir() if entry.is_dir()), + key=lambda entry: entry.name) + return [migrate_project(entry, dry_run=dry_run) for entry in entries] + + +# --------------------------------------------------------------------------- +# 内部 +# --------------------------------------------------------------------------- + +def _load_yaml(text: str) -> dict: + import yaml + return yaml.safe_load(text) + + +def _parse_env(text: str) -> Dict[str, str]: + """``KEY=VALUE`` を読み、``$VAR`` は先行行の値で展開する (wrapper と同じ規則)。""" + values: Dict[str, str] = {} + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + m = _ASSIGNMENT.match(line) + if not m: + continue + key, raw = m.group(1), m.group(2).strip() + literal = len(raw) >= 2 and raw[0] == raw[-1] == "'" + if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in ("'", '"'): + raw = raw[1:-1] + if not literal: + raw = _VAR_REF.sub( + lambda m: values.get(m.group(1) or m.group(2), ""), raw) + values[key] = raw + return values + + +def _strip_migrated_keys(text: str) -> str: + """移行したキーの行を落とす (直前に付いていた説明コメントも一緒に落とす)。 + + キーを消してコメントだけ残ると、何を説明しているのか分からない行になるため。 + 全部消えて空になった場合は、``env`` の役割を書いた雛形を残す (compose が + ``env_file`` で参照するのでファイル自体は消せない)。 + """ + kept: List[str] = [] + pending_comments: List[str] = [] + + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("#"): + pending_comments.append(line) + continue + if not stripped: + kept.extend(pending_comments) + pending_comments = [] + kept.append(line) + continue + + m = _ASSIGNMENT.match(line) + if m and m.group(1) in MIGRATED_KEYS: + pending_comments = [] # 直前の説明コメントごと落とす + continue + kept.extend(pending_comments) + pending_comments = [] + kept.append(line) + + kept.extend(pending_comments) + body = "\n".join(kept).strip("\n") + if not body.strip(): + return _EMPTY_ENV_TEXT + return body + "\n" + + +def _build_project_yml(values: Dict[str, str]) -> str: + """``env`` の値から ``project.yml`` のテキストを組み立てる。 + + ``yaml.dump`` ではなく手で組み立てるのは、キーの並び順とコメントを人が読む + 順序で固定したいため (この結果を人が編集して repo を足していく)。 + """ + repo = values["GIT_REPO"] + owner = values["GIT_USER"] + host = values.get("GIT_HOST") or _DEFAULT_HOST + lines = [ + "# devbase プロジェクト設定 (PLAN32)", + "# リポジトリを増やすときは repos に要素を足す。", + "version: 1", + ] + + scale = values.get("CONTAINER_SCALE") + if scale: + lines.append(f"scale: {scale}") + + open_editor = values.get("DEVBASE_OPEN_EDITOR") + if open_editor: + enabled = open_editor.strip().lower() in _TRUTHY + lines.append(f"open_editor: {'true' if enabled else 'false'}") + + work_dir = values.get("WORK_DIR") + if work_dir and work_dir != f"/work/{repo}": + lines.append(f"work_dir: {work_dir}") + + lines.append("repos:") + if host != _DEFAULT_HOST: + lines.append(f" - host: {host}") + lines.append(f" owner: {owner}") + else: + lines.append(f" - owner: {owner}") + lines.append(f" repo: {repo}") + return "\n".join(lines) + "\n" + + +__all__ = ["MIGRATED_KEYS", "MigrationResult", "migrate_project", "migrate_projects"] diff --git a/tests/commands/test_project_migrate_config.py b/tests/commands/test_project_migrate_config.py new file mode 100644 index 0000000..c7125eb --- /dev/null +++ b/tests/commands/test_project_migrate_config.py @@ -0,0 +1,84 @@ +"""`devbase project migrate-config` (PLAN32 Task 4)""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from devbase.commands.project import cmd_project_migrate_config + +LEGACY_ENV = ("GIT_USER=volareinc\nGIT_REPO=carmo\nWORK_DIR=/work/$GIT_REPO\n" + "CONTAINER_SCALE=1\nDEVBASE_OPEN_EDITOR=1\n") + + +@pytest.fixture +def devbase_root(tmp_path): + projects = tmp_path / "projects" + projects.mkdir() + for name in ("carmo", "adminer"): + directory = projects / name + directory.mkdir() + (directory / "env").write_text(LEGACY_ENV, encoding="utf-8") + return tmp_path + + +def args(**kw): + return SimpleNamespace(**{"dry_run": False, "names": [], "projects_dir": None, **kw}) + + +def test_migrates_every_project(devbase_root, capsys): + assert cmd_project_migrate_config(devbase_root, args()) == 0 + + for name in ("carmo", "adminer"): + assert (devbase_root / "projects" / name / "project.yml").is_file() + out = capsys.readouterr().out + assert "migrated" in out + + +def test_dry_run_writes_nothing(devbase_root, capsys): + assert cmd_project_migrate_config(devbase_root, args(dry_run=True)) == 0 + + assert not (devbase_root / "projects" / "carmo" / "project.yml").exists() + # 生成される内容を確認できること + assert "version: 1" in capsys.readouterr().out + + +def test_named_projects_only(devbase_root): + cmd_project_migrate_config(devbase_root, args(names=["carmo"])) + + assert (devbase_root / "projects" / "carmo" / "project.yml").is_file() + assert not (devbase_root / "projects" / "adminer" / "project.yml").exists() + + +def test_unknown_project_is_an_error(devbase_root, capsys): + assert cmd_project_migrate_config(devbase_root, args(names=["nope"])) == 1 + assert "nope" in capsys.readouterr().err + + +def test_failed_conversion_is_reported_as_an_error(devbase_root, capsys): + (devbase_root / "projects" / "carmo" / "env").write_text( + "GIT_USER=vol areinc\nGIT_REPO=carmo\n", encoding="utf-8") + + assert cmd_project_migrate_config(devbase_root, args()) == 1 + assert "failed" in capsys.readouterr().out.lower() + + +def test_projects_dir_override(tmp_path, devbase_root): + """plugin リポジトリ内の未リンク projects も直接変換できる""" + plugin_projects = tmp_path / "plugin" / "projects" + (plugin_projects / "appliv").mkdir(parents=True) + (plugin_projects / "appliv" / "env").write_text(LEGACY_ENV, encoding="utf-8") + + assert cmd_project_migrate_config( + devbase_root, args(projects_dir=str(plugin_projects))) == 0 + + assert (plugin_projects / "appliv" / "project.yml").is_file() + assert not (devbase_root / "projects" / "carmo" / "project.yml").exists() + + +def test_missing_projects_dir_is_an_error(devbase_root, tmp_path, capsys): + assert cmd_project_migrate_config( + devbase_root, args(projects_dir=str(tmp_path / "nope"))) == 1 + assert "projects" in capsys.readouterr().err diff --git a/tests/project/test_migrate.py b/tests/project/test_migrate.py new file mode 100644 index 0000000..4fc2d0e --- /dev/null +++ b/tests/project/test_migrate.py @@ -0,0 +1,197 @@ +"""env → project.yml の移行 (PLAN32 Task 4)""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from devbase.project.config import load_project_config +from devbase.project.migrate import migrate_project, migrate_projects + +LEGACY_ENV = """GIT_USER=volareinc +GIT_REPO=carmo +WORK_DIR=/work/$GIT_REPO +CONTAINER_SCALE=1 +# up/list 完了後に dev コンテナへ接続した VS Code を自動で開く (PLAN31_3) +DEVBASE_OPEN_EDITOR=1 +""" + + +def project(tmp_path: Path, name: str = "carmo", env: str = LEGACY_ENV) -> Path: + directory = tmp_path / name + directory.mkdir() + (directory / "env").write_text(env, encoding="utf-8") + return directory + + +def test_creates_project_yml_from_env(tmp_path): + directory = project(tmp_path) + + result = migrate_project(directory) + + assert result.status == "migrated" + config = load_project_config(directory) + assert [(r.host, r.owner, r.repo, r.dir) for r in config.repos] == [ + ("github.com", "volareinc", "carmo", "carmo")] + assert config.scale == 1 + assert config.open_editor is True + assert config.work_dir is None # 既定 (/work/) と同じなら書かない + + +def test_removes_migrated_keys_from_env(tmp_path): + directory = project(tmp_path, env=LEGACY_ENV + "ENABLE_SSH=true\n") + + migrate_project(directory) + + env_text = (directory / "env").read_text(encoding="utf-8") + assert "ENABLE_SSH=true" in env_text + for key in ("GIT_USER", "GIT_REPO", "WORK_DIR", "CONTAINER_SCALE", + "DEVBASE_OPEN_EDITOR"): + assert key not in env_text + + +def test_drops_the_comment_that_documented_a_removed_key(tmp_path): + directory = project(tmp_path) + + migrate_project(directory) + + assert "PLAN31_3" not in (directory / "env").read_text(encoding="utf-8") + + +def test_keeps_the_env_file_even_when_it_becomes_empty(tmp_path): + """compose が env_file で参照するため、空になってもファイルは残す""" + directory = project(tmp_path) + + migrate_project(directory) + + env_file = directory / "env" + assert env_file.is_file() + assert env_file.read_text(encoding="utf-8").strip().startswith("#") + + +def test_non_default_host_and_work_dir_are_kept(tmp_path): + directory = project(tmp_path, env=( + "GIT_USER=uttaro_dev\nGIT_REPO=uttarov2\nGIT_HOST=gitlab.com\n" + "WORK_DIR=/work/$GIT_REPO/src\nCONTAINER_SCALE=1\n")) + + migrate_project(directory) + + config = load_project_config(directory) + assert config.repos[0].host == "gitlab.com" + assert config.work_dir == "/work/uttarov2/src" + + +def test_open_editor_off_is_preserved(tmp_path): + directory = project(tmp_path, env=( + "GIT_USER=volareinc\nGIT_REPO=carmo\nDEVBASE_OPEN_EDITOR=0\n")) + + migrate_project(directory) + + assert load_project_config(directory).open_editor is False + + +def test_absent_optional_keys_are_not_written(tmp_path): + directory = project(tmp_path, env="GIT_USER=volareinc\nGIT_REPO=carmo\n") + + migrate_project(directory) + + config = load_project_config(directory) + assert config.scale is None + assert config.open_editor is None + + +def test_dry_run_changes_nothing(tmp_path): + directory = project(tmp_path) + + result = migrate_project(directory, dry_run=True) + + assert result.status == "migrated" + assert result.project_yml.startswith("#") or "version: 1" in result.project_yml + assert not (directory / "project.yml").exists() + assert (directory / "env").read_text(encoding="utf-8") == LEGACY_ENV + + +def test_running_twice_is_a_no_op(tmp_path): + directory = project(tmp_path) + migrate_project(directory) + first = (directory / "project.yml").read_text(encoding="utf-8") + + result = migrate_project(directory) + + assert result.status == "already" + assert (directory / "project.yml").read_text(encoding="utf-8") == first + + +def test_existing_project_yml_is_never_overwritten(tmp_path): + """手で整えた設定 (複数 repo 等) を移行が壊さない""" + directory = project(tmp_path) + (directory / "project.yml").write_text( + "version: 1\nrepos:\n - owner: volareinc\n repo: carmo\n" + " - owner: volareinc\n repo: carmo-batch\n", encoding="utf-8") + + result = migrate_project(directory) + + assert result.status == "already" + assert len(load_project_config(directory).repos) == 2 + # env に残っていた旧キーは掃除される + assert "GIT_REPO" not in (directory / "env").read_text(encoding="utf-8") + + +def test_project_without_repo_keys_is_skipped(tmp_path): + directory = project(tmp_path, env="ENABLE_SSH=true\n") + + result = migrate_project(directory) + + assert result.status == "skipped" + assert "GIT_USER" in result.reason + assert not (directory / "project.yml").exists() + + +def test_missing_env_file_is_skipped(tmp_path): + directory = tmp_path / "carmo" + directory.mkdir() + + result = migrate_project(directory) + + assert result.status == "skipped" + + +def test_generated_config_is_validated(tmp_path): + """検証に通らない値 (空白混じり等) は書き出さずに失敗として報告する""" + directory = project(tmp_path, env="GIT_USER=vol areinc\nGIT_REPO=carmo\n") + + result = migrate_project(directory) + + assert result.status == "failed" + assert not (directory / "project.yml").exists() + + +def test_migrate_projects_walks_every_project(tmp_path): + projects = tmp_path / "projects" + projects.mkdir() + for name in ("a", "b"): + project(projects, name) + (projects / "c").mkdir() # env なし → skipped + + results = migrate_projects(projects) + + assert {r.name: r.status for r in results} == { + "a": "migrated", "b": "migrated", "c": "skipped"} + assert load_project_config(projects / "a").repos[0].repo == "carmo" + + +def test_migrate_projects_follows_symlinks_to_the_plugin_repo(tmp_path): + """projects/ は plugin repo への symlink。実体側を書き換える""" + plugin_repo = tmp_path / "plugin-repo" + plugin_repo.mkdir() + real = project(plugin_repo) + projects = tmp_path / "projects" + projects.mkdir() + (projects / "carmo").symlink_to(real, target_is_directory=True) + + results = migrate_projects(projects) + + assert [r.status for r in results] == ["migrated"] + assert (real / "project.yml").is_file() + assert not (projects / "carmo" / "project.yml").is_symlink() From 8f2860a9cfc82385b61f719d6ebd68556b0ff974 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 23 Aug 2026 01:32:06 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(project):=20migrate-config=20=E3=82=92?= =?UTF-8?q?=20atomic=20=E6=9B=B8=E3=81=8D=E8=BE=BC=E3=81=BF=E3=81=A8?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E9=9A=94=E9=9B=A2=E3=81=A7=E5=A0=85=E7=89=A2?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cross-review round 1 (codex / gemini) の指摘対応。 - 既存 project.yml が壊れている場合は load_project_config で検出し、env を 一切変更せず failed として返す。旧キーは唯一の復旧元であり、設定を読めない 状態で掃除すると構成が完全に失われるため - project.yml / env を同一ディレクトリの一時ファイルへ書いて os.replace する atomic write に変更。ディスクフルや中断で truncate されると「壊れた project.yml + 旧キーの無い env」から復旧できなくなる。symlink 自体を 置き換えないよう realpath 解決し、既存ファイルのパーミッションを引き継ぐ - migrate_project を薄いガードで包み、OSError / UnicodeDecodeError / yaml.YAMLError / ConfigError を failed の MigrationResult に畳む。136 件の 一括移行で 1 件の I/O・デコード失敗が全体を止めないようにする - _load_yaml は yaml.YAMLError を ConfigError にラップする。env に GIT_REPO="carmo のような閉じられていない引用符があると生成 YAML が壊れ、 その 1 件で一括移行がクラッシュしていた Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/project/migrate.py | 88 ++++++++++++++++++++++++++++++---- tests/project/test_migrate.py | 62 ++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 9 deletions(-) diff --git a/lib/devbase/project/migrate.py b/lib/devbase/project/migrate.py index 0b584b1..34f1141 100644 --- a/lib/devbase/project/migrate.py +++ b/lib/devbase/project/migrate.py @@ -13,15 +13,20 @@ from __future__ import annotations +import os import re +import tempfile from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Tuple +import yaml + from devbase.errors import ConfigError from devbase.log import get_logger from devbase.project.config import ( PROJECT_CONFIG_FILENAME, + load_project_config, parse_project_config, ) @@ -60,8 +65,22 @@ class MigrationResult: def migrate_project(project_dir: Path, dry_run: bool = False) -> MigrationResult: - """1 プロジェクトを ``project.yml`` 方式へ移行する。""" - project_dir = Path(project_dir).resolve() + """1 プロジェクトを ``project.yml`` 方式へ移行する。 + + 136 件を 1 回で回すため、**1 件の失敗で全体を止めない**。不正な UTF-8・権限 + エラー・書き込み失敗といった想定内の例外はここで ``failed`` の + :class:`MigrationResult` に畳み、残りのプロジェクトの移行と集計を続行する。 + """ + project_dir = Path(project_dir) + try: + return _migrate_project(project_dir.resolve(), dry_run=dry_run) + except (OSError, UnicodeDecodeError, yaml.YAMLError, ConfigError) as e: + logger.warning("migrate-config: %s の移行に失敗しました: %s", project_dir, e) + return MigrationResult(project_dir.name, project_dir, "failed", reason=str(e)) + + +def _migrate_project(project_dir: Path, dry_run: bool) -> MigrationResult: + """:func:`migrate_project` の本体 (例外はそのまま送出し、呼び出し側で畳む)。""" name = project_dir.name env_path = project_dir / "env" config_path = project_dir / PROJECT_CONFIG_FILENAME @@ -76,8 +95,17 @@ def migrate_project(project_dir: Path, dry_run: bool = False) -> MigrationResult env_changed = new_env_text != env_text if config_path.is_file(): + # 既存 project.yml が壊れている / 読めない場合は env を触らない。旧キーは + # 唯一の復旧元なので、設定を読めない状態で掃除すると構成が完全に消える。 + try: + load_project_config(project_dir) + except ConfigError as e: + return MigrationResult( + name, project_dir, "failed", + reason=(f"既存 {PROJECT_CONFIG_FILENAME} を読めないため env を" + f"変更しませんでした: {e}")) if env_changed and not dry_run: - env_path.write_text(new_env_text, encoding="utf-8") + _atomic_write(env_path, new_env_text) return MigrationResult( name, project_dir, "already", reason=f"{PROJECT_CONFIG_FILENAME} が既にあります (上書きしません)", @@ -91,15 +119,19 @@ def migrate_project(project_dir: Path, dry_run: bool = False) -> MigrationResult document = _build_project_yml(values) try: - parse_project_config(_load_yaml(document), source=str(config_path)) + parse_project_config(_load_yaml(document, config_path), + source=str(config_path)) except ConfigError as e: return MigrationResult(name, project_dir, "failed", reason=str(e), project_yml=document) if not dry_run: - config_path.write_text(document, encoding="utf-8") + # project.yml を完全に永続化してから env を掃除する。逆順や非 atomic な + # 書き込みだと、中断・ディスクフル時に「壊れた project.yml + 旧キーの無い + # env」が残り、再実行しても復旧できなくなる。 + _atomic_write(config_path, document) if env_changed: - env_path.write_text(new_env_text, encoding="utf-8") + _atomic_write(env_path, new_env_text) return MigrationResult(name, project_dir, "migrated", project_yml=document, env=new_env_text, @@ -124,9 +156,47 @@ def migrate_projects(projects_dir: Path, dry_run: bool = False) -> List[Migratio # 内部 # --------------------------------------------------------------------------- -def _load_yaml(text: str) -> dict: - import yaml - return yaml.safe_load(text) +def _load_yaml(text: str, source: Path) -> dict: + """生成した YAML を読み戻す。壊れていたら :class:`ConfigError` に揃える。 + + ``env`` に ``GIT_REPO="carmo`` のような閉じられていない引用符があると、値が + そのまま YAML へ流れて ``yaml.YAMLError`` になる。ローダと同じ + :class:`ConfigError` に変換して、その 1 件だけを ``failed`` に倒す。 + """ + try: + data = yaml.safe_load(text) + except yaml.YAMLError as e: + raise ConfigError(f"{source} 用に生成した YAML を解釈できません: {e}") from e + if not isinstance(data, dict): + raise ConfigError( + f"{source} 用に生成した YAML がマッピングになりません " + "(env の値に YAML の構文が混ざっている可能性があります)") + return data + + +def _atomic_write(path: Path, text: str) -> None: + """同一ディレクトリの一時ファイルへ書いてから ``os.replace`` で差し替える。 + + 直接 ``write_text`` すると、ディスクフルや中断でファイルが truncate された + まま残りうる。移行では ``project.yml`` が壊れたまま ``env`` の旧キーだけが + 消えると復旧元が無くなるため、読み手からは常に旧内容か新内容のどちらかしか + 見えない atomic な差し替えにする。 + """ + path = Path(os.path.realpath(path)) # symlink 自体を置き換えない + mode = path.stat().st_mode & 0o777 if path.exists() else 0o644 + fd, tmp_name = tempfile.mkstemp( + dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp") + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(tmp_path, mode) + os.replace(tmp_path, path) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise def _parse_env(text: str) -> Dict[str, str]: diff --git a/tests/project/test_migrate.py b/tests/project/test_migrate.py index 4fc2d0e..22e7461 100644 --- a/tests/project/test_migrate.py +++ b/tests/project/test_migrate.py @@ -195,3 +195,65 @@ def test_migrate_projects_follows_symlinks_to_the_plugin_repo(tmp_path): assert [r.status for r in results] == ["migrated"] assert (real / "project.yml").is_file() assert not (projects / "carmo" / "project.yml").is_symlink() + + +def test_broken_yaml_from_env_fails_only_that_project(tmp_path): + """閉じられていない引用符などで生成 YAML が壊れても一括移行は止まらない""" + projects = tmp_path / "projects" + projects.mkdir() + project(projects, "broken", env='GIT_USER=volareinc\nGIT_REPO="carmo\n') + project(projects, "sound") + + results = migrate_projects(projects) + + assert {r.name: r.status for r in results} == { + "broken": "failed", "sound": "migrated"} + assert not (projects / "broken" / "project.yml").exists() + # 変換できなかった側の env は旧キーを保持する (復旧元を残す) + assert "GIT_REPO" in (projects / "broken" / "env").read_text(encoding="utf-8") + + +def test_unreadable_env_is_reported_as_failed(tmp_path): + """不正な UTF-8 の env が例外のまま伝播して残りの移行を止めない""" + projects = tmp_path / "projects" + projects.mkdir() + bad = projects / "bad" + bad.mkdir() + (bad / "env").write_bytes(b"GIT_USER=vol\xffareinc\nGIT_REPO=carmo\n") + project(projects, "sound") + + results = migrate_projects(projects) + + assert {r.name: r.status for r in results} == { + "bad": "failed", "sound": "migrated"} + + +def test_broken_existing_project_yml_keeps_env_untouched(tmp_path): + """既存 project.yml が壊れているとき env の旧キー (復旧元) は消さない""" + directory = project(tmp_path) + (directory / "project.yml").write_text( + "version: 1\nrepos:\n - owner: volareinc\n", encoding="utf-8") + + result = migrate_project(directory) + + assert result.status == "failed" + assert "GIT_REPO=carmo" in (directory / "env").read_text(encoding="utf-8") + + +def test_writes_are_atomic(tmp_path, monkeypatch): + """書き込み中に落ちても既存ファイルが truncate されない""" + directory = project(tmp_path) + original = (directory / "env").read_text(encoding="utf-8") + + def boom(*args, **kwargs): + raise OSError("No space left on device") + + monkeypatch.setattr("devbase.project.migrate.os.replace", boom) + + result = migrate_project(directory) + + assert result.status == "failed" + assert not (directory / "project.yml").exists() + assert (directory / "env").read_text(encoding="utf-8") == original + # 一時ファイルは後始末される + assert sorted(p.name for p in directory.iterdir()) == ["env"] From d4ee1b90e6fa38deaf13d43911f50d3cd2aee4e5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 23 Aug 2026 01:43:36 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(project):=20migrate-config=20=E3=81=A7?= =?UTF-8?q?=E6=96=87=E5=AD=97=E5=88=97=E5=80=A4=E3=81=AE=20YAML=20?= =?UTF-8?q?=E6=9A=97=E9=BB=99=E5=9E=8B=E5=A4=89=E6=8F=9B=E3=82=92=E9=98=B2?= =?UTF-8?q?=E3=81=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GIT_REPO=123 / GIT_REPO=on のように旧 env では有効な文字列が、生成した project.yml では YAML 1.1 の暗黙タグで int / bool / date として読まれ、 ローダの「文字列で指定してください」に当たって移行が失敗していた。 host / owner / repo / work_dir は yaml.safe_dump にスカラー出力を任せ、 引用が必要な値だけを引用する。carmo-web のような通常の値は素のままなので、 既に移行済みのファイルと生成物の見た目は変わらない。 併せて、GIT_REPO="carmo のように env 側の引用符が閉じていない値は、 引用符込みのリポジトリ名として通ってしまわないよう malformed な env として failed に倒す。 Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/project/migrate.py | 41 +++++++++++++++++++++++++++++----- tests/project/test_migrate.py | 27 +++++++++++++++++++++- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/lib/devbase/project/migrate.py b/lib/devbase/project/migrate.py index 34f1141..b29b303 100644 --- a/lib/devbase/project/migrate.py +++ b/lib/devbase/project/migrate.py @@ -38,6 +38,9 @@ "CONTAINER_SCALE", "DEVBASE_OPEN_EDITOR", ) +#: ``project.yml`` へ**文字列スカラー**として書き出すキー (残りは数値・真偽値) +_STRING_KEYS: Tuple[str, ...] = ("GIT_HOST", "GIT_USER", "GIT_REPO", "WORK_DIR") + _DEFAULT_HOST = "github.com" _TRUTHY = {"1", "true", "yes", "on"} @@ -117,6 +120,18 @@ def _migrate_project(project_dir: Path, dry_run: bool) -> MigrationResult: name, project_dir, "skipped", reason=f"env に {' / '.join(missing)} がないため変換できません") + # host / owner / repo / 作業ディレクトリに引用符が残るのは、``GIT_REPO="carmo`` + # のように env 側の引用符が閉じていない場合だけ。YAML では正しく引用して + # 書けてしまう (= 引用符込みのリポジトリ名として通ってしまう) ため、 + # 生成前に malformed な env として弾く。 + quoted = [key for key in _STRING_KEYS + if any(c in values.get(key, "") for c in "\"'")] + if quoted: + return MigrationResult( + name, project_dir, "failed", + reason=(f"env の {' / '.join(quoted)} の引用符が閉じていません " + "(値に引用符が残っています)")) + document = _build_project_yml(values) try: parse_project_config(_load_yaml(document, config_path), @@ -156,10 +171,24 @@ def migrate_projects(projects_dir: Path, dry_run: bool = False) -> List[Migratio # 内部 # --------------------------------------------------------------------------- +def _yaml_scalar(value: str) -> str: + """文字列を YAML の暗黙型変換に食われないスカラーとして書き出す。 + + ``GIT_REPO=123`` / ``GIT_REPO=on`` / ``GIT_REPO=2026-08-22`` は env では + ただの文字列だが、素で埋め込むと YAML 1.1 の暗黙タグで int / bool / date に + なり、ローダの「文字列で指定してください」で移行が失敗する。 + ``yaml.safe_dump`` に判断を任せることで、引用が要る値だけが引用され、 + ``carmo-web`` のような通常の値は素のまま (= 生成物の見た目は変わらない)。 + """ + return yaml.safe_dump( + value, default_flow_style=True, width=10 ** 6, allow_unicode=True, + ).strip().removesuffix("...").strip() + + def _load_yaml(text: str, source: Path) -> dict: """生成した YAML を読み戻す。壊れていたら :class:`ConfigError` に揃える。 - ``env`` に ``GIT_REPO="carmo`` のような閉じられていない引用符があると、値が + ``env`` に ``CONTAINER_SCALE="1`` のような閉じられていない引用符があると、値が そのまま YAML へ流れて ``yaml.YAMLError`` になる。ローダと同じ :class:`ConfigError` に変換して、その 1 件だけを ``failed`` に倒す。 """ @@ -282,15 +311,15 @@ def _build_project_yml(values: Dict[str, str]) -> str: work_dir = values.get("WORK_DIR") if work_dir and work_dir != f"/work/{repo}": - lines.append(f"work_dir: {work_dir}") + lines.append(f"work_dir: {_yaml_scalar(work_dir)}") lines.append("repos:") if host != _DEFAULT_HOST: - lines.append(f" - host: {host}") - lines.append(f" owner: {owner}") + lines.append(f" - host: {_yaml_scalar(host)}") + lines.append(f" owner: {_yaml_scalar(owner)}") else: - lines.append(f" - owner: {owner}") - lines.append(f" repo: {repo}") + lines.append(f" - owner: {_yaml_scalar(owner)}") + lines.append(f" repo: {_yaml_scalar(repo)}") return "\n".join(lines) + "\n" diff --git a/tests/project/test_migrate.py b/tests/project/test_migrate.py index 22e7461..3718356 100644 --- a/tests/project/test_migrate.py +++ b/tests/project/test_migrate.py @@ -197,8 +197,33 @@ def test_migrate_projects_follows_symlinks_to_the_plugin_repo(tmp_path): assert not (projects / "carmo" / "project.yml").is_symlink() +def test_string_values_that_look_like_yaml_scalars_stay_strings(tmp_path): + """``GIT_REPO=123`` のように YAML の暗黙型に当たる値も文字列として移行する""" + directory = project( + tmp_path, + env="GIT_HOST=on\nGIT_USER=123\nGIT_REPO=2026\nWORK_DIR=/work/no\n") + + result = migrate_project(directory) + + assert result.status == "migrated" + config = load_project_config(directory) + assert [(r.host, r.owner, r.repo) for r in config.repos] == [ + ("on", "123", "2026")] + assert config.work_dir == "/work/no" + + +def test_plain_values_are_written_without_quotes(tmp_path): + """引用が要らない値は素のまま書く (既に移行済みのファイルと同じ見た目)""" + directory = project(tmp_path) + + document = migrate_project(directory, dry_run=True).project_yml + + assert " - owner: volareinc\n" in document + assert " repo: carmo\n" in document + + def test_broken_yaml_from_env_fails_only_that_project(tmp_path): - """閉じられていない引用符などで生成 YAML が壊れても一括移行は止まらない""" + """閉じられていない引用符の env は、その 1 件だけ failed になり一括移行は止まらない""" projects = tmp_path / "projects" projects.mkdir() project(projects, "broken", env='GIT_USER=volareinc\nGIT_REPO="carmo\n')