From 75aacb1e9087d767a58794d48983cd668d251be2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 23 Aug 2026 00:21:33 +0900 Subject: [PATCH 1/5] =?UTF-8?q?chore:=20PLAN32-entrypoint=20Draft=20PR=20?= =?UTF-8?q?=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From e90a02477f8b5e2be76d8ed96747f7e2201f32fa Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 23 Aug 2026 00:33:38 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(entrypoint):=20=E8=A4=87=E6=95=B0?= =?UTF-8?q?=E3=83=AA=E3=83=9D=E3=82=B8=E3=83=88=E3=83=AA=E3=81=AE=20clone?= =?UTF-8?q?=20=E3=81=A8=20workspace=20=E6=9B=B8=E3=81=8D=E5=87=BA=E3=81=97?= =?UTF-8?q?=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN32 Task 3。GIT_USER/GIT_REPO による単一 clone を廃し、ホストが渡す clone プラン (DEVBASE_REPOS = base64 TSV) を 1 行ずつ処理して /work 配下へ 複数リポジトリを clone する。branch 指定の checkout、init.sh の実行可否、 primary ディレクトリへの cd、複数 repo 用 workspace ファイルの書き出しを それぞれ関数に分けた。 clone / checkout / init.sh の失敗は warning に留めて次のリポジトリへ進む。 1 つ落ちただけで起動できないと、他リポジトリでの作業まで止まるため。 TSV の分解に `IFS=$'\t' read` は使っていない。タブは IFS の空白扱いで連続 する区切りが 1 つに畳まれ、branch 未指定の行で init がずれるため、パラメータ 展開で 1 フィールドずつ切り出している。 関数定義だけを source できるようにし (DEVBASE_ENTRYPOINT_LIB_ONLY)、 ローカルの bare リポジトリを clone 元にした単体テストを追加した。 Co-Authored-By: Claude Opus 5 (1M context) --- containers/base/entrypoint.sh | 157 ++++++++++++--- tests/containers/__init__.py | 0 tests/containers/test_entrypoint_repos.py | 227 ++++++++++++++++++++++ 3 files changed, 358 insertions(+), 26 deletions(-) create mode 100644 tests/containers/__init__.py create mode 100644 tests/containers/test_entrypoint_repos.py diff --git a/containers/base/entrypoint.sh b/containers/base/entrypoint.sh index 77db99e..824e3cd 100644 --- a/containers/base/entrypoint.sh +++ b/containers/base/entrypoint.sh @@ -2,6 +2,131 @@ set -e +# =================================================================== +# PLAN32: 複数リポジトリの clone / workspace 生成 +# =================================================================== +# ホスト側 (devbase up) が projects//project.yml を正規化し、clone プランを +# base64 TSV (DEVBASE_REPOS) としてコンテナへ渡す。ここでは 1 行ずつ読んで clone +# するだけなので、コンテナイメージへ YAML/JSON パーサ依存を増やさずに済む。 +# +# DEVBASE_REPOS : base64(TSV)。1 行 = `urldirbranchinit` +# branch は空可、init は 1/0 +# DEVBASE_PRIMARY_DIR : 起動後に cd する /work 配下のディレクトリ名 +# DEVBASE_WORKSPACE : 書き出す *.code-workspace の絶対パス (複数 repo 時) +# DEVBASE_WORKSPACE_B64 : その中身 (base64 JSON) +# +# 関数定義だけを読み込みたいテストからは +# `DEVBASE_ENTRYPOINT_LIB_ONLY=1 . entrypoint.sh` で source する。 + +# clone プランを復号して 1 行 1 repo で出力する (未設定なら何も出さない)。 +devbase_repo_plan_lines() { + [ -n "${DEVBASE_REPOS:-}" ] || return 0 + printf '%s' "$DEVBASE_REPOS" | base64 -d +} + +# clone プランの各リポジトリを / へ clone する。 +# +# 個々の失敗 (clone / checkout / init.sh) は warning に留めて次の repo へ進む。 +# 1 つ落ちただけでコンテナが起動しないと、他リポジトリでの作業まで止まるため。 +devbase_clone_repos() { + local work_root="${1:-/work}" + local plan url dir branch init target + + if ! plan="$(devbase_repo_plan_lines 2>/dev/null)"; then + echo "Warning: Failed to decode DEVBASE_REPOS (skipping repository setup)" + return 0 + fi + if [ -z "$plan" ]; then + echo "No repositories configured (DEVBASE_REPOS is empty)" + return 0 + fi + + mkdir -p "$work_root" + local line rest index=0 + # TSV の分解に `IFS=$'\t' read` は使えない。タブは IFS の空白扱いなので連続する + # 区切りが 1 つに畳まれ、branch 未指定 (空フィールド) の行で init がずれる。 + while IFS= read -r line; do + [ -n "$line" ] || continue + index=$((index + 1)) + case "$line" in + *$'\t'*$'\t'*$'\t'*) ;; + *) + echo "Warning: Ignoring malformed clone plan entry (line $index)" + continue + ;; + esac + url="${line%%$'\t'*}"; rest="${line#*$'\t'}" + dir="${rest%%$'\t'*}"; rest="${rest#*$'\t'}" + branch="${rest%%$'\t'*}"; init="${rest#*$'\t'}" + + [ -n "$url" ] && [ -n "$dir" ] || continue + target="$work_root/$dir" + + if [ -d "$target/.git" ]; then + echo "Repository already exists: $dir" + else + echo "Cloning repository: $url -> $target" + if ! git clone "$url" "$target"; then + echo "Warning: Failed to clone repository: $url" + continue + fi + fi + + if [ -n "$branch" ]; then + if ! git -C "$target" checkout "$branch"; then + echo "Warning: Failed to checkout branch '$branch' in $dir" + fi + fi + + if [ "$init" = "1" ] && [ -f "$target/init.sh" ]; then + echo "Running init.sh in $dir" + (cd "$target" && ./init.sh) || echo "Warning: init.sh failed in $dir" + fi + done < "$dest.tmp" 2>/dev/null; then + mv "$dest.tmp" "$dest" + echo "Workspace file written: $dest" + else + rm -f "$dest.tmp" + echo "Warning: Failed to write workspace file: $dest" + fi +} + +# primary リポジトリのディレクトリへ移動する (ログイン直後の作業場所)。 +devbase_enter_primary_dir() { + local work_root="${1:-/work}" + local target="$work_root/${DEVBASE_PRIMARY_DIR:-}" + + if [ -z "${DEVBASE_PRIMARY_DIR:-}" ]; then + return 0 + fi + if [ -d "$target" ]; then + cd "$target" + echo "Current directory: $(pwd)" + else + echo "Warning: Primary directory does not exist: $target" + fi +} + +# テストは関数定義だけを使う (source 時のみ有効な return で以降を読み飛ばす)。 +if [ -n "${DEVBASE_ENTRYPOINT_LIB_ONLY:-}" ]; then + return 0 2>/dev/null || exit 0 +fi + # Setup authentication credentials from environment variables USERNAME="${USERNAME:-ubuntu}" @@ -261,32 +386,12 @@ done echo "AI agent settings symlinks setup completed" # ======================================== -# Git operations (optional, don't fail if they error) -if [ -n "$GIT_USER" ] && [ -n "$GIT_REPO" ]; then - # Clone repository only if it doesn't exist - GIT_HOST="${GIT_HOST:-github.com}" - if [ ! -d "$GIT_REPO" ]; then - echo "Cloning repository: $GIT_HOST/$GIT_USER/$GIT_REPO" - git clone "https://$GIT_HOST/$GIT_USER/$GIT_REPO.git" || echo "Warning: Failed to clone repository" - else - echo "Repository already exists: $GIT_REPO" - fi - # Run init.sh from cloned repository root if it exists - [ -f "$GIT_REPO/init.sh" ] && (cd "$GIT_REPO" && ./init.sh) || true -fi - -# Move to repository directory if it exists -echo "Current directory before cd: $(pwd)" -if [ -n "$GIT_REPO" ]; then - echo "GIT_REPO=$GIT_REPO" - if [ -d "$GIT_REPO" ]; then - echo "Directory $GIT_REPO exists, changing to it" - cd "$GIT_REPO" - echo "Current directory after cd: $(pwd)" - else - echo "Directory $GIT_REPO does not exist in $(pwd)" - fi -fi +# Repository setup (PLAN32: 1 project = 複数リポジトリ) +# 個々の失敗はコンテナ起動を止めない (関数内で warning 扱い)。 +DEVBASE_WORK_ROOT="${DEVBASE_WORK_ROOT:-/work}" +devbase_clone_repos "$DEVBASE_WORK_ROOT" +devbase_write_workspace +devbase_enter_primary_dir "$DEVBASE_WORK_ROOT" # Signal that entrypoint setup is complete touch /tmp/entrypoint-ready diff --git a/tests/containers/__init__.py b/tests/containers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/containers/test_entrypoint_repos.py b/tests/containers/test_entrypoint_repos.py new file mode 100644 index 0000000..c8d73e8 --- /dev/null +++ b/tests/containers/test_entrypoint_repos.py @@ -0,0 +1,227 @@ +"""entrypoint.sh の複数リポジトリ clone と workspace 書き出し (PLAN32) + +``containers/base/entrypoint.sh`` を ``DEVBASE_ENTRYPOINT_LIB_ONLY=1`` で source し、 +関数だけを読み込んで bash から直接呼び出す。clone 元にはローカルの bare リポジトリ +(``file://``) を使うため、ネットワークにも Docker にも依存しない。 +""" + +from __future__ import annotations + +import base64 +import json +import subprocess +from pathlib import Path + +import pytest + +ENTRYPOINT = Path(__file__).resolve().parents[2] / "containers" / "base" / "entrypoint.sh" + + +def encode_plan(rows) -> str: + """host 側が渡す wire format (base64 TSV) をテスト側で組み立てる。 + + ``rows`` は ``(url, dir, branch, init)`` のタプル列。init は ``"1"`` / ``"0"``。 + """ + text = "\n".join("\t".join(row) for row in rows) + return base64.b64encode(text.encode()).decode() + + +def run_entrypoint_fn(script: str, env: dict, cwd: Path) -> subprocess.CompletedProcess: + """entrypoint.sh の関数だけを読み込んで ``script`` を実行する。""" + full = f'set -e\nDEVBASE_ENTRYPOINT_LIB_ONLY=1 . "{ENTRYPOINT}"\n{script}\n' + return subprocess.run( + ["bash", "-c", full], cwd=cwd, env={"PATH": "/usr/bin:/bin:/usr/local/bin", **env}, + capture_output=True, text=True, + ) + + +def make_origin(tmp_path: Path, name: str, *, branches=(), files=None) -> str: + """clone 元の bare リポジトリを作り ``file://`` URL を返す。""" + work = tmp_path / "origins" / name + work.mkdir(parents=True) + git = ["git", "-C", str(work)] + subprocess.run(["git", "init", "-q", "-b", "main", str(work)], check=True) + subprocess.run([*git, "config", "user.email", "t@example.com"], check=True) + subprocess.run([*git, "config", "user.name", "t"], check=True) + for rel, content in (files or {"README.md": name}).items(): + path = work / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + if rel.endswith(".sh"): + path.chmod(0o755) + subprocess.run([*git, "add", "-A"], check=True) + subprocess.run([*git, "commit", "-qm", "init"], check=True) + for branch in branches: + subprocess.run([*git, "branch", branch], check=True) + + bare = tmp_path / "origins" / f"{name}.git" + subprocess.run(["git", "clone", "-q", "--bare", str(work), str(bare)], check=True) + return f"file://{bare}" + + +@pytest.fixture +def work(tmp_path: Path) -> Path: + d = tmp_path / "work" + d.mkdir() + return d + + +# --------------------------------------------------------------------------- +# clone +# --------------------------------------------------------------------------- + +def test_clones_every_repository_in_the_plan(tmp_path, work): + plan = encode_plan([ + (make_origin(tmp_path, "app"), "app", "", "0"), + (make_origin(tmp_path, "docs"), "docs", "", "0"), + (make_origin(tmp_path, "infra"), "cdk", "", "0"), + ]) + + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": plan}, tmp_path) + + assert result.returncode == 0, result.stderr + assert (work / "app" / "README.md").read_text() == "app" + assert (work / "docs" / "README.md").read_text() == "docs" + assert (work / "cdk" / "README.md").read_text() == "infra" + + +def test_checks_out_the_requested_branch(tmp_path, work): + plan = encode_plan([ + (make_origin(tmp_path, "app", branches=("develop",)), "app", "develop", "0"), + ]) + + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": plan}, tmp_path) + + assert result.returncode == 0, result.stderr + branch = subprocess.run(["git", "-C", str(work / "app"), "rev-parse", + "--abbrev-ref", "HEAD"], capture_output=True, text=True) + assert branch.stdout.strip() == "develop" + + +def test_runs_init_script_only_when_enabled(tmp_path, work): + files = {"README.md": "app", "init.sh": "#!/bin/bash\ntouch ./init-was-run\n"} + plan = encode_plan([ + (make_origin(tmp_path, "with-init", files=files), "with-init", "", "1"), + (make_origin(tmp_path, "no-init", files=files), "no-init", "", "0"), + ]) + + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": plan}, tmp_path) + + assert result.returncode == 0, result.stderr + assert (work / "with-init" / "init-was-run").exists() + assert not (work / "no-init" / "init-was-run").exists() + + +def test_a_failing_clone_does_not_stop_the_others(tmp_path, work): + plan = encode_plan([ + (f"file://{tmp_path}/does-not-exist.git", "missing", "", "0"), + (make_origin(tmp_path, "app"), "app", "", "0"), + ]) + + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": plan}, tmp_path) + + assert result.returncode == 0, result.stderr + assert (work / "app" / "README.md").exists() + assert not (work / "missing").exists() + assert "Warning" in result.stdout + result.stderr + + +def test_a_failing_checkout_does_not_stop_the_others(tmp_path, work): + plan = encode_plan([ + (make_origin(tmp_path, "app"), "app", "no-such-branch", "0"), + (make_origin(tmp_path, "docs"), "docs", "", "0"), + ]) + + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": plan}, tmp_path) + + assert result.returncode == 0, result.stderr + assert (work / "docs" / "README.md").exists() + + +def test_existing_clone_is_kept(tmp_path, work): + url = make_origin(tmp_path, "app") + plan = encode_plan([(url, "app", "", "0")]) + run_entrypoint_fn(f'devbase_clone_repos "{work}"', {"DEVBASE_REPOS": plan}, tmp_path) + (work / "app" / "local-change.txt").write_text("keep me") + + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": plan}, tmp_path) + + assert result.returncode == 0, result.stderr + assert (work / "app" / "local-change.txt").read_text() == "keep me" + + +def test_no_plan_is_not_an_error(tmp_path, work): + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', {}, tmp_path) + + assert result.returncode == 0, result.stderr + assert list(work.iterdir()) == [] + + +def test_broken_plan_is_reported_without_failing_startup(tmp_path, work): + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": "not-base64!!"}, tmp_path) + + assert result.returncode == 0, result.stderr + assert "Warning" in result.stdout + result.stderr + + +# --------------------------------------------------------------------------- +# workspace +# --------------------------------------------------------------------------- + +def test_writes_the_workspace_file(tmp_path, work): + document = {"folders": [{"path": "/work/app"}, {"path": "/work/docs"}]} + encoded = base64.b64encode(json.dumps(document).encode()).decode() + dest = work / "sample.code-workspace" + + result = run_entrypoint_fn("devbase_write_workspace", { + "DEVBASE_WORKSPACE": str(dest), + "DEVBASE_WORKSPACE_B64": encoded, + }, tmp_path) + + assert result.returncode == 0, result.stderr + assert json.loads(dest.read_text()) == document + + +def test_workspace_is_skipped_when_not_configured(tmp_path, work): + result = run_entrypoint_fn("devbase_write_workspace", {}, tmp_path) + + assert result.returncode == 0, result.stderr + assert list(work.iterdir()) == [] + + +# --------------------------------------------------------------------------- +# primary への cd +# --------------------------------------------------------------------------- + +def test_primary_dir_is_the_landing_directory(tmp_path, work): + (work / "app").mkdir() + + result = run_entrypoint_fn(f'devbase_enter_primary_dir "{work}"; pwd', + {"DEVBASE_PRIMARY_DIR": "app"}, tmp_path) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip().endswith("/work/app") + + +def test_missing_primary_dir_does_not_fail_startup(tmp_path, work): + result = run_entrypoint_fn(f'devbase_enter_primary_dir "{work}"', + {"DEVBASE_PRIMARY_DIR": "app"}, tmp_path) + + assert result.returncode == 0, result.stderr + assert "Warning" in result.stdout + result.stderr + + +def test_old_git_repo_env_is_no_longer_honoured(tmp_path, work): + """PLAN32 は後方互換を持たない。GIT_USER/GIT_REPO では clone しない。""" + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"GIT_USER": "volareinc", "GIT_REPO": "carmo"}, tmp_path) + + assert result.returncode == 0, result.stderr + assert list(work.iterdir()) == [] From 022f18baaf689b60a998205e86028dfa5148f356 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 23 Aug 2026 00:43:03 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix(entrypoint):=20checkout=20=E3=81=AF=20c?= =?UTF-8?q?lone=20=E7=9B=B4=E5=BE=8C=E3=81=AE=E3=81=BF=20+=20=E5=A4=B1?= =?UTF-8?q?=E6=95=97=E6=99=82=E3=81=AF=E5=BD=93=E8=A9=B2=20repo=20?= =?UTF-8?q?=E3=82=92=E6=89=93=E3=81=A1=E5=88=87=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - checkout 失敗時に continue し、意図しない branch で init.sh が走らないようにする - 既存 clone には checkout しない。コンテナ再起動のたびにユーザの作業ブランチから 設定 branch へ引き戻される問題を回避する - テストの PATH ハードコードをやめ、実行環境を引き継ぐ (DEVBASE_*/GIT_* のみ除去) Co-Authored-By: Claude Opus 5 (1M context) --- containers/base/entrypoint.sh | 12 ++++-- tests/containers/test_entrypoint_repos.py | 50 ++++++++++++++++++++--- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/containers/base/entrypoint.sh b/containers/base/entrypoint.sh index 824e3cd..9bfdce4 100644 --- a/containers/base/entrypoint.sh +++ b/containers/base/entrypoint.sh @@ -30,7 +30,7 @@ devbase_repo_plan_lines() { # 1 つ落ちただけでコンテナが起動しないと、他リポジトリでの作業まで止まるため。 devbase_clone_repos() { local work_root="${1:-/work}" - local plan url dir branch init target + local plan url dir branch init target cloned if ! plan="$(devbase_repo_plan_lines 2>/dev/null)"; then echo "Warning: Failed to decode DEVBASE_REPOS (skipping repository setup)" @@ -62,6 +62,7 @@ devbase_clone_repos() { [ -n "$url" ] && [ -n "$dir" ] || continue target="$work_root/$dir" + cloned=0 if [ -d "$target/.git" ]; then echo "Repository already exists: $dir" else @@ -70,11 +71,16 @@ devbase_clone_repos() { echo "Warning: Failed to clone repository: $url" continue fi + cloned=1 fi - if [ -n "$branch" ]; then + # checkout は clone 直後だけ。既存 clone に対して毎回実行すると、コンテナ内で + # 作業ブランチへ切り替えたユーザが再起動のたびに引き戻されてしまう。 + # 失敗したら意図しない branch で init.sh を走らせないよう この repo は打ち切る。 + if [ "$cloned" = "1" ] && [ -n "$branch" ]; then if ! git -C "$target" checkout "$branch"; then - echo "Warning: Failed to checkout branch '$branch' in $dir" + echo "Warning: Failed to checkout branch '$branch' in $dir (skipping)" + continue fi fi diff --git a/tests/containers/test_entrypoint_repos.py b/tests/containers/test_entrypoint_repos.py index c8d73e8..6404beb 100644 --- a/tests/containers/test_entrypoint_repos.py +++ b/tests/containers/test_entrypoint_repos.py @@ -9,6 +9,7 @@ import base64 import json +import os import subprocess from pathlib import Path @@ -27,14 +28,26 @@ def encode_plan(rows) -> str: def run_entrypoint_fn(script: str, env: dict, cwd: Path) -> subprocess.CompletedProcess: - """entrypoint.sh の関数だけを読み込んで ``script`` を実行する。""" + """entrypoint.sh の関数だけを読み込んで ``script`` を実行する。 + + ``PATH`` を固定すると Homebrew の git しか無い環境で落ちるため、実行環境を + 引き継ぐ。ただし呼び出し側の DEVBASE_* / GIT_* が紛れ込むとテストの前提が + 崩れるので、そこだけ落としてから ``env`` を重ねる。 + """ + base = {k: v for k, v in os.environ.items() + if not k.startswith(("DEVBASE_", "GIT_"))} full = f'set -e\nDEVBASE_ENTRYPOINT_LIB_ONLY=1 . "{ENTRYPOINT}"\n{script}\n' return subprocess.run( - ["bash", "-c", full], cwd=cwd, env={"PATH": "/usr/bin:/bin:/usr/local/bin", **env}, + ["bash", "-c", full], cwd=cwd, env={**base, **env}, capture_output=True, text=True, ) +def current_branch(repo: Path) -> str: + return subprocess.run(["git", "-C", str(repo), "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, text=True).stdout.strip() + + def make_origin(tmp_path: Path, name: str, *, branches=(), files=None) -> str: """clone 元の bare リポジトリを作り ``file://`` URL を返す。""" work = tmp_path / "origins" / name @@ -95,9 +108,7 @@ def test_checks_out_the_requested_branch(tmp_path, work): {"DEVBASE_REPOS": plan}, tmp_path) assert result.returncode == 0, result.stderr - branch = subprocess.run(["git", "-C", str(work / "app"), "rev-parse", - "--abbrev-ref", "HEAD"], capture_output=True, text=True) - assert branch.stdout.strip() == "develop" + assert current_branch(work / "app") == "develop" def test_runs_init_script_only_when_enabled(tmp_path, work): @@ -143,6 +154,35 @@ def test_a_failing_checkout_does_not_stop_the_others(tmp_path, work): assert (work / "docs" / "README.md").exists() +def test_a_failing_checkout_skips_the_init_script(tmp_path, work): + """checkout に失敗した repo は打ち切る (意図しない branch で init.sh を走らせない)。""" + files = {"README.md": "app", "init.sh": "#!/bin/bash\ntouch ./init-was-run\n"} + plan = encode_plan([ + (make_origin(tmp_path, "app", files=files), "app", "no-such-branch", "1"), + ]) + + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": plan}, tmp_path) + + assert result.returncode == 0, result.stderr + assert not (work / "app" / "init-was-run").exists() + + +def test_existing_clone_keeps_the_branch_the_user_switched_to(tmp_path, work): + """再起動のたびに設定 branch へ引き戻さない (checkout は clone 直後のみ)。""" + url = make_origin(tmp_path, "app", branches=("develop", "feature-A")) + plan = encode_plan([(url, "app", "develop", "0")]) + run_entrypoint_fn(f'devbase_clone_repos "{work}"', {"DEVBASE_REPOS": plan}, tmp_path) + assert current_branch(work / "app") == "develop" + subprocess.run(["git", "-C", str(work / "app"), "checkout", "-q", "feature-A"], check=True) + + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": plan}, tmp_path) + + assert result.returncode == 0, result.stderr + assert current_branch(work / "app") == "feature-A" + + def test_existing_clone_is_kept(tmp_path, work): url = make_origin(tmp_path, "app") plan = encode_plan([(url, "app", "", "0")]) From 639c60e229f83136e065a0b8312922dbf85cb0ab Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 23 Aug 2026 00:46:14 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(entrypoint):=20clone=20=E3=83=97?= =?UTF-8?q?=E3=83=A9=E3=83=B3=E3=81=AE=E5=8C=BA=E5=88=87=E3=82=8A=E3=82=92?= =?UTF-8?q?=20US=20(0x1f)=20=E3=81=AB=E3=81=97=E3=81=A6=E7=A9=BA=E3=83=95?= =?UTF-8?q?=E3=82=A3=E3=83=BC=E3=83=AB=E3=83=89=E3=82=92=E4=BF=9D=E3=81=A6?= =?UTF-8?q?=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit タブ区切りは IFS の空白扱いで連続する区切りが 1 つに畳まれるため、branch 未指定の行で init の値がずれる。非空白の US (0x1f) にすると bash の自然な 読み方 (IFS='' read -r ...) がそのまま正しく動くので、パラメータ展開に よる手動分解をやめて read に戻した。あわせて列数・init 値の検証を入れ、 壊れた行は警告に留めて次の行へ進む。 符号化側 (lib/devbase/project/config.py) と同じ契約に揃えている。 Co-Authored-By: Claude Opus 5 (1M context) --- containers/base/entrypoint.sh | 34 ++++++++++------------- tests/containers/test_entrypoint_repos.py | 22 +++++++++++++-- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/containers/base/entrypoint.sh b/containers/base/entrypoint.sh index 9bfdce4..973fa11 100644 --- a/containers/base/entrypoint.sh +++ b/containers/base/entrypoint.sh @@ -6,11 +6,11 @@ set -e # PLAN32: 複数リポジトリの clone / workspace 生成 # =================================================================== # ホスト側 (devbase up) が projects//project.yml を正規化し、clone プランを -# base64 TSV (DEVBASE_REPOS) としてコンテナへ渡す。ここでは 1 行ずつ読んで clone +# base64 のレコード列 (DEVBASE_REPOS) としてコンテナへ渡す。ここでは 1 行ずつ読んで clone # するだけなので、コンテナイメージへ YAML/JSON パーサ依存を増やさずに済む。 # -# DEVBASE_REPOS : base64(TSV)。1 行 = `urldirbranchinit` -# branch は空可、init は 1/0 +# DEVBASE_REPOS : base64 の行区切りレコード。1 行 = url / dir / branch / init を +# US (0x1f) 区切りで並べたもの。branch は空可、init は 1/0 # DEVBASE_PRIMARY_DIR : 起動後に cd する /work 配下のディレクトリ名 # DEVBASE_WORKSPACE : 書き出す *.code-workspace の絶対パス (複数 repo 時) # DEVBASE_WORKSPACE_B64 : その中身 (base64 JSON) @@ -42,24 +42,18 @@ devbase_clone_repos() { fi mkdir -p "$work_root" - local line rest index=0 - # TSV の分解に `IFS=$'\t' read` は使えない。タブは IFS の空白扱いなので連続する - # 区切りが 1 つに畳まれ、branch 未指定 (空フィールド) の行で init がずれる。 - while IFS= read -r line; do - [ -n "$line" ] || continue + local extra index=0 + # フィールド区切りは US (0x1f)。タブだと IFS の空白扱いで連続する区切りが 1 つに + # 畳まれ、branch 未指定 (空フィールド) の行で init の値がずれる。 + while IFS=$'\x1f' read -r url dir branch init extra; do + # 末尾の空行 (符号化側が付ける末尾改行) は読み飛ばす + [ -n "$url$dir$branch$init$extra" ] || continue index=$((index + 1)) - case "$line" in - *$'\t'*$'\t'*$'\t'*) ;; - *) - echo "Warning: Ignoring malformed clone plan entry (line $index)" - continue - ;; - esac - url="${line%%$'\t'*}"; rest="${line#*$'\t'}" - dir="${rest%%$'\t'*}"; rest="${rest#*$'\t'}" - branch="${rest%%$'\t'*}"; init="${rest#*$'\t'}" - - [ -n "$url" ] && [ -n "$dir" ] || continue + if [ -z "$url" ] || [ -z "$dir" ] || [ -n "$extra" ] || + { [ "$init" != "1" ] && [ "$init" != "0" ]; }; then + echo "Warning: Ignoring malformed clone plan entry (line $index)" + continue + fi target="$work_root/$dir" cloned=0 diff --git a/tests/containers/test_entrypoint_repos.py b/tests/containers/test_entrypoint_repos.py index 6404beb..6c7fca9 100644 --- a/tests/containers/test_entrypoint_repos.py +++ b/tests/containers/test_entrypoint_repos.py @@ -19,11 +19,13 @@ def encode_plan(rows) -> str: - """host 側が渡す wire format (base64 TSV) をテスト側で組み立てる。 + """host 側が渡す wire format をテスト側で組み立てる。 + フィールド区切りは US (0x1f)、行区切りは LF で末尾にも LF を付ける + (``lib/devbase/project/config.py`` の ``encode_repo_plan`` と同じ契約)。 ``rows`` は ``(url, dir, branch, init)`` のタプル列。init は ``"1"`` / ``"0"``。 """ - text = "\n".join("\t".join(row) for row in rows) + text = "".join("\x1f".join(row) + "\n" for row in rows) return base64.b64encode(text.encode()).decode() @@ -203,6 +205,22 @@ def test_no_plan_is_not_an_error(tmp_path, work): assert list(work.iterdir()) == [] +def test_entry_with_missing_fields_is_skipped(tmp_path, work): + """列数が合わない行は clone せず警告に留め、正しい行の処理は続ける""" + good = make_origin(tmp_path, "app") + broken = "\x1f".join([f"file://{tmp_path}/x.git", "x"]) # branch / init が無い + plan = base64.b64encode( + (broken + "\n" + "\x1f".join([good, "app", "", "0"]) + "\n").encode()).decode() + + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": plan}, tmp_path) + + assert result.returncode == 0, result.stderr + assert "Warning" in result.stdout + result.stderr + assert not (work / "x").exists() + assert (work / "app" / "README.md").exists() + + def test_broken_plan_is_reported_without_failing_startup(tmp_path, work): result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', {"DEVBASE_REPOS": "not-base64!!"}, tmp_path) From 73536cba99ac088addf75475678b69ee076f0b4e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 23 Aug 2026 00:54:47 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs(PLAN32):=20clone=20=E3=83=97=E3=83=A9?= =?UTF-8?q?=E3=83=B3=E3=81=AE=20wire=20format=20=E5=A5=91=E7=B4=84?= =?UTF-8?q?=E3=82=92=20US=20(0x1f)=20=E5=8C=BA=E5=88=87=E3=82=8A=E3=81=B8?= =?UTF-8?q?=E7=B5=B1=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plan と PR 本文が base64 TSV を契約としていた一方、entrypoint と符号化側 (encode_repo_plan) は US (0x1f) 区切りへ移行済みで、契約と実装が食い違っていた。 タブは IFS の空白として扱われ連続する区切りが 1 つに畳まれるため、branch 未指定 (空フィールド) の行で init の値がずれる。非空白の US なら IFS=$'\x1f' read が そのまま 4 列として読める。実装は既に正しいので、契約側 (plan) を US へ揃える。 - issues/PLAN32_multi-repo-project.md: スキーマ節の wire format を US 区切り (行区切り LF / 末尾 LF あり) の記述へ更新。代替案表にタブ区切りを不採用案として 理由付きで追加 - containers/base/entrypoint.sh: 行区切りと末尾 LF、符号化側の所在をコメントに明記 - tests/containers/test_entrypoint_repos.py: 実物の encode_repo_plan の出力を entrypoint に通す結合テストを追加 (config.py は Task 1 の別 PR なので importorskip で未導入ブランチでは skip) Co-Authored-By: Claude Opus 5 (1M context) --- containers/base/entrypoint.sh | 4 +- issues/PLAN32_multi-repo-project.md | 25 ++++++++---- tests/containers/test_entrypoint_repos.py | 46 +++++++++++++++++++++++ 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/containers/base/entrypoint.sh b/containers/base/entrypoint.sh index 973fa11..3c2190a 100644 --- a/containers/base/entrypoint.sh +++ b/containers/base/entrypoint.sh @@ -10,7 +10,9 @@ set -e # するだけなので、コンテナイメージへ YAML/JSON パーサ依存を増やさずに済む。 # # DEVBASE_REPOS : base64 の行区切りレコード。1 行 = url / dir / branch / init を -# US (0x1f) 区切りで並べたもの。branch は空可、init は 1/0 +# US (0x1f) 区切りで並べたもの。branch は空可、init は 1/0。 +# 行区切りは LF で末尾にも LF が付く (符号化側の契約: +# lib/devbase/project/config.py の encode_repo_plan) # DEVBASE_PRIMARY_DIR : 起動後に cd する /work 配下のディレクトリ名 # DEVBASE_WORKSPACE : 書き出す *.code-workspace の絶対パス (複数 repo 時) # DEVBASE_WORKSPACE_B64 : その中身 (base64 JSON) diff --git a/issues/PLAN32_multi-repo-project.md b/issues/PLAN32_multi-repo-project.md index 8f4cbd4..5f04ebf 100644 --- a/issues/PLAN32_multi-repo-project.md +++ b/issues/PLAN32_multi-repo-project.md @@ -73,8 +73,9 @@ | --- | --- | --- | --- | | 設定ファイル名 `project.yml` (repos + devbase 設定を集約) | `repos` に加え `scale` / `open_editor` / `work_dir` を持つ | **採用** | issue の「`CONTAINER_SCALE` は yaml が相応しい」に沿う。設定の置き場所が 1 つに決まり「どっちに書くか」の迷いが消える | | 設定ファイル名 `repos.yml` (repo 定義のみ) | scale 等は `env` に残す | 不採用 | devbase 設定とコンテナ環境変数が `env` に同居したままで、issue の振り分け要求を半分しか満たさない | -| repo リストの transport: **base64 TSV** を `DEVBASE_REPOS` で渡す | `url\tdir\tbranch\tinit` の行を base64 化 | **採用** | entrypoint 側が `base64 -d` + `while read` だけで解釈でき、jq/python への依存を増やさない (lfm など base 非継承イメージでも安全)。base64 なので compose の `$` 展開・改行事故も起きない | -| transport: base64 JSON + jq | entrypoint で `jq` パース | 不採用 | `jq` は base image にはあるが lfm 系の派生イメージで保証できない。TSV で足りる | +| repo リストの transport: **base64 の US 区切りレコード列** を `DEVBASE_REPOS` で渡す | `urldirbranchinit` の行 (`` = 0x1f) を base64 化 | **採用** | entrypoint 側が `base64 -d` + `IFS=$'\x1f' read` だけで解釈でき、jq/python への依存を増やさない (lfm など base 非継承イメージでも安全)。base64 なので compose の `$` 展開・改行事故も起きない | +| transport: base64 JSON + jq | entrypoint で `jq` パース | 不採用 | `jq` は base image にはあるが lfm 系の派生イメージで保証できない。US 区切りの平テキストで足りる | +| transport: 区切り文字にタブ (当初案) | `url\tdir\tbranch\tinit` の行を base64 化 | 不採用 | タブは IFS の**空白扱い**で連続する区切りが 1 つに畳まれる。`branch` 未指定 (空フィールド) の行で `init` の値がずれるため、`IFS=$'\t' read` で素直に 4 列として読めない。非空白の US (0x1f) なら空フィールドがそのまま残る | | transport: `project.yml` を bind mount して entrypoint で解釈 | コンテナ内で YAML を読む | 不採用 | project ディレクトリは現在マウントしていない。マウント経路の追加とコンテナ内 YAML パーサ依存の 2 つを同時に増やす | | workspace ファイル: **host で JSON を組み立て base64 で渡し entrypoint が書き出す** | `DEVBASE_WORKSPACE_B64` | **採用** | 生成ロジックを Python 側 (テスト可能) に置ける。entrypoint は `base64 -d > file` の 1 行 | | workspace ファイル: entrypoint で shell 組み立て | printf で JSON を書く | 不採用 | テストできない場所にエスケープ処理を置くことになる | @@ -88,7 +89,7 @@ | project | `projects//` 1 ディレクトリ = 1 compose プロジェクト = 1 dev コンテナ (群) | | repo | project が `/work` 配下へ clone する git リポジトリ。今回から**複数** | | primary repo | ログイン時の `cd` 先、および repo 1 件時にエディタが開くフォルダ。既定は `repos` の先頭 | -| clone プラン | `project.yml` を正規化した内部表現。base64 TSV で `DEVBASE_REPOS` としてコンテナへ渡る | +| clone プラン | `project.yml` を正規化した内部表現。base64 の US 区切りレコード列として `DEVBASE_REPOS` でコンテナへ渡る | | plugin repo | project 定義を配布するリポジトリ (`volareinc/devbase-ext` 等)。`projects/*` はここへの symlink | ## 不変条件 @@ -139,15 +140,23 @@ repos: - `dir` 重複はエラー。`primary: true` が 2 件以上はエラー。`repos` が空はエラー。 - 未知キーはエラー (typo を黙って無視しない)。 -wire format (`DEVBASE_REPOS`, base64 TSV / 1 行 1 repo, タブ区切り): +wire format (`DEVBASE_REPOS`, base64 / 1 行 1 repo, US (0x1f) 区切り): ``` -https://github.com/volareinc/carmo.gitcarmomain1 -https://gitlab.com/uttaro_dev/uttarov2.gitsystem0 +https://github.com/volareinc/carmo.gitcarmomain1 +https://gitlab.com/uttaro_dev/uttarov2.gitsystem0 ``` +- フィールド区切りは **US = unit separator (0x1f)**。非空白なので `IFS=$'\x1f' read -r url dir branch init` + がそのまま 4 列として読め、`branch` 未指定 (空フィールド) の行でも `init` の値がずれない。 + タブだと IFS の空白扱いで連続する区切りが 1 つに畳まれるため採用しない。 +- 行区切りは LF。**末尾にも LF を付ける**。`while read` は EOF 直前の改行なし行を読み捨てる実装が + あるため、末尾 LF が無いと最後の行 (repo 1 件ならその唯一の行) が丸ごと落ちる。 +- 各フィールドは符号化側で検証済み。空白・制御文字 (US / LF を含む) は通さないので、 + エスケープ規則は持たない。 + 列: `url`, `dir`, `branch` (空可), `init` (`1`/`0`)。primary は別変数 `DEVBASE_PRIMARY_DIR` で渡す -(TSV の列を増やさず、entrypoint の `cd` 先判定を単純に保つ)。 +(列を増やさず、entrypoint の `cd` 先判定を単純に保つ)。 ## 修正対象 @@ -179,7 +188,7 @@ plugin リポジトリ (別 PR): - **対象ファイル:** `lib/devbase/project/config.py`, `tests/project/test_config.py` - **変更内容:** `ProjectConfig` / `RepoSpec` dataclass、YAML 読み込み・`defaults` 継承・正規化・検証、 - `encode_repo_plan()` / `decode_repo_plan()` (base64 TSV)。`project.yml` 不在・不正時は移行手順を含む + `encode_repo_plan()` / `decode_repo_plan()` (base64 / US 区切り)。`project.yml` 不在・不正時は移行手順を含む `ConfigError` を送出。この PR では**呼び出し元を差し替えない** (挙動変更なし)。 - **満たす受け入れ条件:** AC6 の一部 (エラー文言)、AC2/AC3 のデータ表現 - **進め方:** テスト駆動。正常系 (defaults 継承 / dir 明示 / host 混在 / primary 指定) と diff --git a/tests/containers/test_entrypoint_repos.py b/tests/containers/test_entrypoint_repos.py index 6c7fca9..30a6373 100644 --- a/tests/containers/test_entrypoint_repos.py +++ b/tests/containers/test_entrypoint_repos.py @@ -283,3 +283,49 @@ def test_old_git_repo_env_is_no_longer_honoured(tmp_path, work): assert result.returncode == 0, result.stderr assert list(work.iterdir()) == [] + + +# --------------------------------------------------------------------------- +# 符号化側との結合 +# --------------------------------------------------------------------------- + +def test_the_real_encoder_output_is_consumed_as_is(tmp_path, work): + """host 側の ``encode_repo_plan`` の出力を entrypoint がそのまま読めること。 + + wire format (US 区切り / 末尾 LF) の契約は符号化側と entrypoint の 2 箇所に + 分かれているため、テスト用の ``encode_plan`` だけを見ていると片側だけ変わった + ときに気付けない。実物の producer を通した plan で clone まで確認する。 + + ``lib/devbase/project/config.py`` は別 PR (Task 1) で入るので、まだ無い + ブランチでは skip する。 + """ + config = pytest.importorskip( + "devbase.project.config", + reason="lib/devbase/project/config.py はまだこのブランチに無い (Task 1)") + + repos = [ + config.RepoSpec(host="github.com", owner="o", repo="app", + dir="app", branch=None, init=False, primary=True), + config.RepoSpec(host="github.com", owner="o", repo="docs", + dir="docs", branch="develop", init=False, primary=False), + ] + # url はローカルの bare リポジトリへ差し替える (ネットワークに触らない) + urls = { + "app": make_origin(tmp_path, "app"), + "docs": make_origin(tmp_path, "docs", branches=("develop",)), + } + plan = config.encode_repo_plan(repos) + decoded = base64.b64decode(plan).decode() + for spec in repos: + decoded = decoded.replace(spec.url, urls[spec.dir]) + plan = base64.b64encode(decoded.encode()).decode() + + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": plan}, tmp_path) + + assert result.returncode == 0, result.stderr + assert "malformed" not in result.stdout + result.stderr + # branch 未指定の repo が畳まれず、後続の repo もずれずに読めていること + assert (work / "app" / "README.md").exists() + assert (work / "docs" / "README.md").exists() + assert current_branch(work / "docs") == "develop"