diff --git a/CHANGELOG.md b/CHANGELOG.md
index 28f8c19..ad2f048 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,23 @@
## [Unreleased]
### Added
+- **clone できなかったリポジトリを `devbase up` が知らせる**ようにしました。複数リポジトリ構成で
+ 一部のリポジトリに権限が無い (または名前が違う) 場合、これまでは entrypoint の警告が
+ `docker logs` にしか出ず、`up` の画面は成功したように見えていました。コンテナ起動後に
+ `/work` の実体を確認し、`project.yml` に書いたのに無いリポジトリを clone URL 付きで
+ 警告します。すべて揃っているときの出力は変わりません。clone の失敗が `up` を失敗させない
+ 点も従来どおりです。
+
+### Changed
+- **multi-root ワークスペースに、clone できたリポジトリだけを載せる**ようにしました。これまでは
+ `project.yml` の内容をそのまま書き出していたため、clone に失敗したリポジトリが VS Code の
+ エクスプローラに「開けないフォルダ」として並んでいました。
+
+> **Note:** ワークスペースの変更は `entrypoint.sh` の変更を含むため、反映には
+> `devbase container build` (必要に応じて `--no-cache`) によるイメージの再ビルドが要ります。
+> 再ビルドしていないイメージでは、これまでどおり全フォルダを載せたワークスペースが
+> 書き出されます (機能が黙って失われることはありません)。
+
- **`plugin.yml` の `requires.devbase` をインストール時に検証**するようにしました。要件を
満たさない Plugin は `devbase plugin install` が中止します。これまでは値を読むだけで
比較しておらず、`project.yml` 形式の Plugin を 2.x へ入れられてしまい、`devbase up` の
diff --git a/containers/base/entrypoint.sh b/containers/base/entrypoint.sh
index 3c2190a..442b13e 100644
--- a/containers/base/entrypoint.sh
+++ b/containers/base/entrypoint.sh
@@ -15,7 +15,12 @@ set -e
# lib/devbase/project/config.py の encode_repo_plan)
# DEVBASE_PRIMARY_DIR : 起動後に cd する /work 配下のディレクトリ名
# DEVBASE_WORKSPACE : 書き出す *.code-workspace の絶対パス (複数 repo 時)
-# DEVBASE_WORKSPACE_B64 : その中身 (base64 JSON)
+# DEVBASE_WORKSPACE_FOLDERS
+# : base64 の行区切りレコード。1 行 =
と folder オブジェクトの
+# JSON を US (0x1f) で並べたもの。clone できた dir の行だけを
+# 連結して workspace にする (PLAN37)
+# DEVBASE_WORKSPACE_B64 : 完成済みの workspace (base64 JSON)。DEVBASE_WORKSPACE_FOLDERS を
+# 渡さない古いホスト向けの fallback
#
# 関数定義だけを読み込みたいテストからは
# `DEVBASE_ENTRYPOINT_LIB_ONLY=1 . entrypoint.sh` で source する。
@@ -91,14 +96,63 @@ EOF
# 複数 repo をまとめて開くための *.code-workspace を書き出す。
#
-# 中身はホスト側で組み立てて base64 で渡ってくるので、ここでは復号して置くだけ。
-# JSON の組み立て (エスケープ) をシェルでやらない分、壊れにくい。
+# clone できなかった repo のフォルダを載せると、VS Code のエクスプローラに開けない
+# フォルダが並ぶ (PLAN37)。そこで DEVBASE_WORKSPACE_FOLDERS の各行を見て、
+# / が実在する行の JSON だけを連結する。
+#
+# 各 folder の JSON はホスト側が直列化済みなので、ここで組み立てるのは外枠 (
+# `{"folders": [` … `]}`) とカンマだけ。dir に " や \ が入っていてもシェルで
+# エスケープを考えずに済む。
devbase_write_workspace() {
+ local work_root="${1:-/work}"
[ -n "${DEVBASE_WORKSPACE:-}" ] || return 0
- [ -n "${DEVBASE_WORKSPACE_B64:-}" ] || return 0
local dest="$DEVBASE_WORKSPACE"
mkdir -p "$(dirname "$dest")"
+
+ if [ -z "${DEVBASE_WORKSPACE_FOLDERS:-}" ]; then
+ # 旧ホスト (PLAN37 前) から起動された場合。完成品をそのまま置く。
+ devbase_write_workspace_verbatim "$dest"
+ return 0
+ fi
+
+ local records dir folder extra first=1
+ if ! records="$(printf '%s' "$DEVBASE_WORKSPACE_FOLDERS" | base64 -d 2>/dev/null)"; then
+ echo "Warning: Failed to decode DEVBASE_WORKSPACE_FOLDERS"
+ devbase_write_workspace_verbatim "$dest"
+ return 0
+ fi
+
+ {
+ printf '{\n "folders": [\n'
+ while IFS=$'\x1f' read -r dir folder extra; do
+ [ -n "$dir$folder$extra" ] || continue
+ if [ -z "$dir" ] || [ -z "$folder" ] || [ -n "$extra" ]; then
+ echo "Warning: Ignoring malformed workspace folder record" >&2
+ continue
+ fi
+ if [ ! -d "$work_root/$dir" ]; then
+ echo "Warning: Skipping workspace folder (not cloned): $dir" >&2
+ continue
+ fi
+ [ "$first" = "1" ] || printf ',\n'
+ printf ' %s' "$folder"
+ first=0
+ done < "$dest.tmp"
+
+ mv "$dest.tmp" "$dest"
+ echo "Workspace file written: $dest"
+}
+
+# ホストが組み立て済みの workspace (DEVBASE_WORKSPACE_B64) をそのまま書き出す。
+devbase_write_workspace_verbatim() {
+ local dest="$1"
+ [ -n "${DEVBASE_WORKSPACE_B64:-}" ] || return 0
+
if printf '%s' "$DEVBASE_WORKSPACE_B64" | base64 -d > "$dest.tmp" 2>/dev/null; then
mv "$dest.tmp" "$dest"
echo "Workspace file written: $dest"
@@ -392,7 +446,7 @@ echo "AI agent settings symlinks setup completed"
# 個々の失敗はコンテナ起動を止めない (関数内で warning 扱い)。
DEVBASE_WORK_ROOT="${DEVBASE_WORK_ROOT:-/work}"
devbase_clone_repos "$DEVBASE_WORK_ROOT"
-devbase_write_workspace
+devbase_write_workspace "$DEVBASE_WORK_ROOT"
devbase_enter_primary_dir "$DEVBASE_WORK_ROOT"
# Signal that entrypoint setup is complete
diff --git a/docs/developer/architecture.md b/docs/developer/architecture.md
index c3af02e..5bb5f27 100644
--- a/docs/developer/architecture.md
+++ b/docs/developer/architecture.md
@@ -148,7 +148,7 @@ YAML の解釈をホスト側の Python に閉じ込めることで、コンテ
| モジュール | 役割 |
|-----------|------|
| `config.py` | `project.yml` の読み込み・`defaults` 継承・検証・正規化 (`ProjectConfig` / `RepoSpec`)。clone プランの符号化 (`encode_repo_plan`) |
-| `runtime.py` | コンテナへ渡す環境変数の組み立て (`DEVBASE_REPOS` / `DEVBASE_PRIMARY_DIR` / `DEVBASE_WORKSPACE*`)、multi-root ワークスペース JSON の生成、`scale` の読み書き |
+| `runtime.py` | コンテナへ渡す環境変数の組み立て (`DEVBASE_REPOS` / `DEVBASE_PRIMARY_DIR` / `DEVBASE_WORKSPACE*`)、multi-root ワークスペース JSON の生成 (`DEVBASE_WORKSPACE_FOLDERS` は folder ごとに直列化した形。entrypoint が clone できた repo だけを選べるようにするため)、`scale` の読み書き |
| `migrate.py` | 旧 `env` 形式 (`GIT_USER` / `GIT_REPO` 等) から `project.yml` への変換 |
```mermaid
diff --git a/docs/user/project-yml.md b/docs/user/project-yml.md
index 0dbfad4..a72edf2 100644
--- a/docs/user/project-yml.md
+++ b/docs/user/project-yml.md
@@ -97,6 +97,26 @@ clone URL は `https:////.git` で組み立てられます。
- `repos[]` の `host` / `owner` / `repo` / `dir` / `branch` に空白・制御文字が混ざっている
- `dir` が `/work` 直下から外れている(`../` や入れ子のパス、`.` / `..`)
+## clone できないリポジトリがあるとき
+
+primary には権限があるがサブリポジトリには権限がない、という構成は起こりえます。この場合
+**権限のあるリポジトリだけが clone され、コンテナは通常どおり起動します**。1 本 clone できない
+だけで開発環境ごと止めても、他のリポジトリでの作業まで巻き添えになるためです。
+
+| 起きること | 挙動 |
+|---|---|
+| clone の失敗 | 警告を出して次のリポジトリへ進む。`devbase up` は成功で終わる (終了コード 0) |
+| `devbase up` の出力 | `/work` に無いリポジトリを clone URL 付きで一覧表示する。揃っていれば何も出さない |
+| multi-root ワークスペース | clone できたリポジトリだけが `folders` に載る。開けないフォルダは並ばない |
+| primary が clone できなかった | 警告を出し、ログイン直後のカレントは `/work` になる |
+
+clone は**コンテナ起動のたびに試行される**ので、後から権限が付与されれば次の `devbase up` で
+取り込まれます。`project.yml` を直す必要はありません。
+
+権限が無い場合も存在しない場合も、GitHub は private リポジトリに対して同じ
+`Repository not found` (404) を返します。警告からは区別できないため、リポジトリ名の
+打ち間違いも同じ見え方になります。詳細は `devbase project logs ` で確認してください。
+
## `env` との使い分け
| 書く場所 | 内容 | 例 |
diff --git a/issues/PLAN37_clone-failure-visibility.md b/issues/PLAN37_clone-failure-visibility.md
new file mode 100644
index 0000000..476f986
--- /dev/null
+++ b/issues/PLAN37_clone-failure-visibility.md
@@ -0,0 +1,177 @@
+# PLAN37: clone に失敗したリポジトリを `devbase up` と workspace に正しく反映する
+
+## 関連リンク
+
+- 前提となる設計: `issues/PLAN32_multi-repo-project.md`(複数リポジトリ clone と multi-root workspace)
+- 実装: `containers/base/entrypoint.sh`、`lib/devbase/project/runtime.py`、`lib/devbase/commands/container.py`
+- 発端: multi-repo 構成で「primary には権限があるがサブリポジトリには権限がない」ケースの挙動調査
+
+## モード
+
+`standard` — 既存の振る舞い(clone 失敗はコンテナ起動を止めない)は変えず、その結果の**見せ方**を足す。
+公開コマンドは増えず、`project.yml` スキーマも変わらない。コンテナへ渡す内部 wire format を 1 つ追加する。
+
+## 目的と非目的
+
+達成したい状態:
+
+- `devbase up` の出力だけで、**どのリポジトリが `/work` に無いか**が分かる。`docker logs` を掘らなくてよい。
+- 生成される `*.code-workspace` に、**存在しないフォルダが並ばない**。
+
+やらないこと:
+
+- clone 失敗で `devbase up` を失敗させること。1 本落ちただけで開発環境ごと止めない方針(PLAN32)は維持する。
+- 権限エラーと typo の区別。GitHub は権限のない private リポジトリにも `Repository not found` (404) を返すため、
+ クライアント側では判別できない。表示は「`/work` に無い」という事実に留める。
+- clone のリトライ・認証まわりの改善。entrypoint は起動のたびに clone を試すので、権限付与後は次回 `up` で解決する。
+
+## 前提
+
+調査で確認した現状(2026-08-24、実機 `nyle-dx-dev-1` と実イメージの `/entrypoint.sh` で確認):
+
+- 前提 1: `devbase_clone_repos` は個々の clone / checkout / init.sh の失敗を warning に留めて次の repo へ進む。
+ 回帰テストは `tests/containers/test_entrypoint_repos.py:131` にある。primary が落ちても起動は続く(同 `:271`)。
+- 前提 2: 失敗はハングしない。コンテナは `Tty=false` で、GitHub は権限のない private リポジトリにも 404 を返すため、
+ git は認証プロンプトへ落ちずに 0.4 秒で `exit 128` する。`~/.git-credentials` も消えない(401 ではないので reject が走らない)。
+- 前提 3: `cmd_up` は `docker compose up -d` → ready 待ち → editor 起動の順で進み、entrypoint の標準出力を一切見ない。
+ そのため clone 失敗があっても `=== Deploy completed successfully ===` で終わる。
+- 前提 4: workspace の JSON はホスト側 (`build_workspace_document`) が `project.yml` から静的に組み立て、
+ `DEVBASE_WORKSPACE_B64` で渡している。clone の成否は反映されない。
+- 前提 5: entrypoint の変更は base イメージの再ビルドが要る(`devbase container build`)。ホストだけ更新した状態でも
+ **今までどおり動く**必要がある。
+
+## 受け入れ条件
+
+実機検証は `nyle-dx` プロジェクトに権限のないリポジトリ (`volareinc/no-such-repo-xyz123`) を
+一時的に足し、`devbase base` / プロジェクトイメージを再ビルドしたうえで `devbase up` を実行した。
+
+- [x] AC1: `project.yml` に 2 件書き、片方が clone できない構成で `devbase up` すると、標準出力に
+ 「`/work` に無いリポジトリ」の一覧(dir と clone URL)と、詳細の確認先が出る。
+
+ ```
+ [5/6] Waiting for containers to be ready...
+ All containers ready
+ Warning: Repositories missing in /work of dev-1 (clone may have failed):
+ Warning: - no-such-repo-xyz123 (https://github.com/volareinc/no-such-repo-xyz123.git)
+ Warning: Details: devbase project logs nyle-dx | grep Warning
+ === Deploy completed successfully ===
+ ```
+- [x] AC2: AC1 の状況でも `devbase up` の終了コードは 0 で、成功したリポジトリでは通常どおり作業できる。
+ 検証: 上記の実行で `exit=0`。`/work/nyle-dx` と `/work/ideabase` は通常どおり存在する。
+- [x] AC3: 全リポジトリが揃っているときは、`up` の出力は従来と変わらない。
+ 検証: 一時エントリを外して再実行 → `Repositories missing` の出力は 0 件、`exit=0`。
+- [x] AC4: clone に失敗したリポジトリは `*.code-workspace` の `folders` に含まれない。
+ 検証: 実機の `/work/nyle-dx.code-workspace` は `nyle-dx` と `ideabase` の 2 件のみ。
+- [x] AC5: 落としたフォルダは entrypoint のログに warning として残る。
+ 検証: `docker logs nyle-dx-dev-1` に
+ `Warning: Skipping workspace folder (not cloned): no-such-repo-xyz123`。
+- [x] AC6: 旧イメージ(新 wire format を知らない entrypoint)に新しいホストから `up` しても、workspace は
+ 従来どおり全フォルダ入りで書き出される。
+ 検証: 再ビルド前のイメージに焼かれていた**実物の** `/entrypoint.sh` へ新旧両方の環境変数を渡し、
+ `DEVBASE_WORKSPACE_B64` 経由で 3 フォルダすべてが書き出されることを確認。
+- [x] AC7: `pytest` が通る(1445 passed)。
+
+## 代替案と採否
+
+| 案 | 内容 | 採否 | 理由 |
+|---|---|---|---|
+| clone 結果をコンテナのログから grep してホストで表示 | `docker logs` を `Warning: Failed to clone` で検索 | 不採用 | ログはコンテナ再起動をまたいで積み上がり、いつの失敗か特定できない。「今 `/work` に有るか」を直接見る方が真実に近い |
+| **`/work` の実体を見て不足を報告する** | ready 待ちの後に `ls -A1 /work` を 1 回実行し、`project.yml` の dir 集合と突き合わせる | **採用** | 冪等で、既存 clone を引き継いだ場合も正しい。exec は instance あたり 1 回で済む |
+| dir ごとに `test -d` を exec | repo 件数 × instance 回の exec | 不採用 | 遅く、dir 名をシェルへ渡すためのクォートが増える。`ls` の 1 回で足りる |
+| workspace を entrypoint 側で JSON パースして絞る | `jq` / `python3` で folders をフィルタ | 不採用 | base 非継承イメージ (lfm 等) に依存を増やす。PLAN32 が避けた方針をそのまま踏襲する |
+| **folder ごとに直列化した JSON をホストから渡し、entrypoint は存在するものだけ連結する** | `DEVBASE_WORKSPACE_FOLDERS` = `` の行 | **採用** | `dir` に `"` や `\` が入ってもホスト側の `json.dumps` が処理済み。entrypoint は連結するだけで JSON パーサが要らない |
+| `DEVBASE_WORKSPACE_B64` を置き換える | 旧変数を削除する | 不採用 | ホストだけ更新してイメージが古い間、workspace が黙って書かれなくなる。旧変数は fallback として残す |
+
+## ドメイン用語
+
+| 用語 | 意味 |
+|---|---|
+| clone プラン | `project.yml` を正規化した内部表現。`DEVBASE_REPOS` でコンテナへ渡る(PLAN32) |
+| workspace フォルダレコード | 本 PLAN で追加する wire format。`` を 1 行とする LF 区切り、全体を base64 |
+| 欠落リポジトリ | `project.yml` に書かれているのに `/work/` が存在しないリポジトリ |
+
+## 不変条件
+
+- clone の失敗はコンテナ起動を止めない(PLAN32 から継続)。
+- ホスト側だけが `project.yml` と wire format の変換を行う。entrypoint は JSON を組み立てない。
+- 新しい警告は**異常時のみ**出す。全リポジトリが揃っているときの出力は変えない。
+
+## wire format
+
+追加する環境変数(`DEVBASE_WORKSPACE` / `DEVBASE_WORKSPACE_B64` は現状のまま残す):
+
+```
+DEVBASE_WORKSPACE_FOLDERS : base64。復号すると 1 行 1 フォルダの LF 区切り。
+ 1 行 =
+ 例: nyle-dx\x1f{"name": "nyle-dx", "path": "/work/nyle-dx"}
+```
+
+`dir` は空白・制御文字を含まないことが `project.yml` のローダで保証済みなので、US / LF がフィールドを割ることはない。
+JSON 側も `json.dumps` が制御文字をエスケープするため、1 行に収まる。
+
+## 修正対象
+
+- `lib/devbase/project/runtime.py` — `container_env` に `DEVBASE_WORKSPACE_FOLDERS` を追加
+- `containers/base/entrypoint.sh` — `devbase_write_workspace` が存在するフォルダだけを書き出す
+- `lib/devbase/commands/container.py` — ready 待ちの後に欠落リポジトリを報告する
+- `tests/project/test_runtime.py` / `tests/containers/test_entrypoint_repos.py` / `tests/commands/` — 回帰テスト
+- `docs/developer/architecture.md`、`docs/user/project-yml.md`、`CHANGELOG.md` — wire format と挙動の記述
+
+## タスク分解
+
+### Task 1: workspace フォルダレコードの生成(ホスト)
+
+- `container_env` が repo 2 件以上のとき `DEVBASE_WORKSPACE_FOLDERS` を追加する。`build_workspace_document` の
+ folders と同じ順序(primary 先頭)を使い、1 フォルダ = 1 レコードへ直列化する。
+- 単体テスト: レコード数・順序・`dir` とのペア・repo 1 件のときは付かないこと。
+
+### Task 2: 存在するフォルダだけを書き出す(entrypoint)
+
+- `devbase_write_workspace ` にして、`DEVBASE_WORKSPACE_FOLDERS` があればレコードを読み、
+ `/` が存在する行だけを `{"folders": [...]}` へ連結する。
+- 落とした行は `Warning: Skipping workspace folder (not cloned): ` として出す。
+- `DEVBASE_WORKSPACE_FOLDERS` が無ければ従来どおり `DEVBASE_WORKSPACE_B64` をそのまま書き出す(旧ホスト互換)。
+- 単体テスト: 欠落フォルダの除外、全滅時 (`folders: []`)、fallback、warning の出力。
+
+### Task 3: 欠落リポジトリの報告(ホスト)
+
+- `cmd_up` の ready 待ちの直後に、instance ごとに `docker compose exec -T dev- ls -A1 /work` を実行し、
+ `project.yml` の dir 集合との差を求める。
+- 欠落があれば `logger.warning` で dir と clone URL、`docker logs` の確認先を出す。欠落が無ければ何も出さない。
+- exec 自体が失敗した場合は黙って諦める(`up` を倒さない)。
+- 単体テスト: 欠落あり / 無し / exec 失敗の 3 経路。
+
+### Task 4: ドキュメントと CHANGELOG
+
+- `docs/developer/architecture.md` の `runtime.py` の説明に新 wire format を追記。
+- `docs/user/project-yml.md` に「権限が無いリポジトリがあるとどうなるか」を追記。
+- `CHANGELOG.md` に、entrypoint の変更を反映するには `devbase container build` が要る旨を明記。
+
+## 影響範囲
+
+| 対象 | 影響 |
+|---|---|
+| 既存プロジェクト(repo 1 件) | なし。`DEVBASE_WORKSPACE_FOLDERS` は 2 件以上でしか付かない |
+| 既存プロジェクト(repo 2 件以上・全て clone 成功) | なし。workspace の内容も出力も変わらない |
+| 旧イメージ + 新ホスト | workspace は fallback 経路で従来どおり。欠落リポジトリの報告はホスト側なので効く |
+| 新イメージ + 旧ホスト | `DEVBASE_WORKSPACE_FOLDERS` が無く fallback へ落ちるだけ |
+
+## リスクと対処
+
+| リスク | 対処 |
+|---|---|
+| `/work` は複数プロジェクトで共有されるため、別プロジェクトが同名 dir を作っていると「有る」と判定される | dir 名の衝突は共有ボリュームの既存の性質。報告は「`/work` に無い」だけを述べ、所有権は主張しない |
+| `ls -A1 /work` が巨大になる | 出力は名前だけで、共有ボリュームの実績でも数十件。1 instance につき 1 回に留める |
+| entrypoint のシェルで JSON を組み立てる | 組み立てるのは `{"folders": [` と `]}` の外枠と `,` だけ。値はホストが直列化済み |
+
+## 切り戻し手順
+
+1. `lib/devbase/commands/container.py` の報告呼び出しを外す(警告が消えるだけ)。
+2. workspace を元に戻す場合は `container_env` から `DEVBASE_WORKSPACE_FOLDERS` を落とす。entrypoint は
+ fallback で `DEVBASE_WORKSPACE_B64` を使うため、イメージを戻さなくても旧挙動に戻る。
+
+## 完了の定義
+
+- 受け入れ条件 AC1〜AC7 を満たす。
+- `pytest` が通り、新規テストが Task 1〜3 の各経路を押さえている。
+- ドキュメントと CHANGELOG が更新されている。
diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py
index 197df26..3f7b86f 100644
--- a/lib/devbase/commands/container.py
+++ b/lib/devbase/commands/container.py
@@ -20,6 +20,7 @@
get_dev_service_name,
)
from devbase.utils.docker import (
+ docker_compose,
docker_compose_down,
docker_compose_up,
wait_for_containers_ready,
@@ -633,6 +634,47 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool],
logger.warning("エディタの自動オープンに失敗しましたがデプロイは成功しています: %s", e)
+def _report_missing_repos(config, scale: int, dev_service_name: str,
+ project_name: str,
+ compose_file: Optional[Path] = None) -> None:
+ """``project.yml`` に書いたのに ``/work`` へ無いリポジトリを警告する (PLAN37)。
+
+ clone の失敗は entrypoint 側で warning に留めてコンテナ起動を続ける
+ (``containers/base/entrypoint.sh`` の ``devbase_clone_repos``)。その warning は
+ ``docker logs`` にしか出ないため、``up`` の画面だけを見ていると「成功した」と
+ 読めてしまう。ここで ``/work`` の実体を見て不足を伝える。
+
+ ログを grep せず実体を見るのは、ログがコンテナ再起動をまたいで積み上がり
+ 「いつの失敗か」を判別できないため。「今 ``/work`` に有るか」の方が真実に近い。
+
+ 問い合わせ自体の失敗 (コンテナが既に落ちている等) では何も言わない。``up`` は
+ ここまでで成功しており、付随情報のために倒す価値はない。
+ """
+ for index in range(1, scale + 1):
+ service = f"{dev_service_name}-{index}"
+ try:
+ result = docker_compose(
+ ['exec', '-T', service, 'ls', '-A1', '/work'],
+ compose_file=compose_file, check=True,
+ capture_output=True, silent_error=True)
+ except (subprocess.CalledProcessError, OSError):
+ continue
+
+ # dir には空白・制御文字が入らない (project.yml のローダが弾く) ので
+ # ls の 1 行 = 1 エントリ名として扱える。
+ present = set(result.stdout.split())
+ missing = [repo for repo in config.repos if repo.dir not in present]
+ if not missing:
+ continue
+
+ logger.warning("Repositories missing in /work of %s (clone may have failed):",
+ service)
+ for repo in missing:
+ logger.warning(" - %s (%s)", repo.dir, repo.url)
+ logger.warning(" Details: devbase project logs %s | grep Warning",
+ project_name)
+
+
def cmd_up(project_name: str = None, scale: int = None,
open_editor: Optional[bool] = None,
open_index: Optional[int] = None) -> int:
@@ -703,6 +745,10 @@ def cmd_up(project_name: str = None, scale: int = None,
timeout=60
)
+ # clone できなかった repo があれば伝える (揃っていれば何も出さない)。
+ _report_missing_repos(config, scale, dev_service_name, project_name,
+ compose_file=override_file)
+
# Run project-specific deploy script for each scaled instance
deploy_script = Path('./deploy')
if deploy_script.exists() and deploy_script.is_file():
diff --git a/lib/devbase/project/runtime.py b/lib/devbase/project/runtime.py
index 62270db..21968ec 100644
--- a/lib/devbase/project/runtime.py
+++ b/lib/devbase/project/runtime.py
@@ -37,9 +37,35 @@ def build_workspace_document(config: ProjectConfig) -> Dict[str, Any]:
primary repo を先頭に置く。エディタのエクスプローラは並び順どおりに出るため、
作業の起点になる repo が一番上に来る方が探しやすい。
"""
- repos = sorted(config.repos, key=lambda repo: not repo.primary)
- return {"folders": [{"name": repo.dir, "path": f"/work/{repo.dir}"}
- for repo in repos]}
+ return {"folders": [_workspace_folder(repo) for repo in _workspace_repos(config)]}
+
+
+def encode_workspace_folders(config: ProjectConfig) -> str:
+ r"""workspace の folder を 1 行 1 件へ直列化する (PLAN37 の wire format)。
+
+ ```` の行を LF で連ね、全体を base64 化する。
+ entrypoint は ```` で clone の成否を確かめ、生き残った行の JSON だけを
+ 連結して workspace を書き出す。**ホストが JSON を直列化しておくことが要点**で、
+ ``dir`` に ``"`` や ``\`` が入っていてもシェル側でエスケープを考えずに済む。
+
+ ``dir`` は ``project.yml`` のローダが空白・制御文字を弾いているため US / LF が
+ フィールドを割ることはなく、JSON 側も ``json.dumps`` が制御文字を
+ エスケープするので 1 行に収まる。
+ """
+ lines = []
+ for repo in _workspace_repos(config):
+ folder = json.dumps(_workspace_folder(repo), ensure_ascii=False)
+ lines.append(f"{repo.dir}\x1f{folder}\n")
+ return base64.b64encode("".join(lines).encode()).decode()
+
+
+def _workspace_repos(config: ProjectConfig):
+ """workspace へ並べる順 (primary が先頭)。"""
+ return sorted(config.repos, key=lambda repo: not repo.primary)
+
+
+def _workspace_folder(repo) -> Dict[str, str]:
+ return {"name": repo.dir, "path": f"/work/{repo.dir}"}
def container_env(config: ProjectConfig, project_name: str) -> Dict[str, str]:
@@ -47,8 +73,11 @@ def container_env(config: ProjectConfig, project_name: str) -> Dict[str, str]:
- ``DEVBASE_REPOS``: clone プラン (base64)
- ``DEVBASE_PRIMARY_DIR``: 起動後に ``cd`` する ``/work`` 配下のディレクトリ名
- - ``DEVBASE_WORKSPACE`` / ``DEVBASE_WORKSPACE_B64``: repo が 2 件以上のときだけ。
- 1 件のときは従来どおりフォルダを開かせたいので付けない。
+ - ``DEVBASE_WORKSPACE`` / ``DEVBASE_WORKSPACE_B64`` / ``DEVBASE_WORKSPACE_FOLDERS``:
+ repo が 2 件以上のときだけ。1 件のときは従来どおりフォルダを開かせたいので付けない。
+ entrypoint は ``DEVBASE_WORKSPACE_FOLDERS`` から clone できた repo だけを選んで
+ 書き出す (PLAN37)。``DEVBASE_WORKSPACE_B64`` は**この変数を知らない古いイメージ**の
+ ための完成品で、新しいホスト + 古いイメージでも workspace が消えないよう残している。
値は base64 と検証済みの名前だけなので、``$`` や改行を含まず compose の
変数展開に食われない。
@@ -62,6 +91,7 @@ def container_env(config: ProjectConfig, project_name: str) -> Dict[str, str]:
ensure_ascii=False, indent=2)
env["DEVBASE_WORKSPACE"] = workspace_path(project_name)
env["DEVBASE_WORKSPACE_B64"] = base64.b64encode(document.encode()).decode()
+ env["DEVBASE_WORKSPACE_FOLDERS"] = encode_workspace_folders(config)
return env
diff --git a/tests/commands/test_up_missing_repos.py b/tests/commands/test_up_missing_repos.py
new file mode 100644
index 0000000..56508aa
--- /dev/null
+++ b/tests/commands/test_up_missing_repos.py
@@ -0,0 +1,111 @@
+"""`devbase up` が clone できなかったリポジトリを伝える (PLAN37)。
+
+clone の失敗は entrypoint 側で warning に留まりコンテナ起動は続く。その warning は
+`docker logs` にしか出ないため、`up` の画面だけでは「揃っている」と読めてしまう。
+ここでは `/work` の実体を見て不足を報告する経路を固定する。
+"""
+
+from __future__ import annotations
+
+import logging
+import subprocess
+
+import pytest
+
+from devbase.commands import container
+from devbase.project.config import parse_project_config
+
+
+def config_of(*repos):
+ return parse_project_config(
+ {"version": 1, "defaults": {"owner": "volareinc"},
+ "repos": [{"repo": r} for r in repos]},
+ source="project.yml")
+
+
+@pytest.fixture
+def work_listing(monkeypatch):
+ """`docker compose exec ... ls -A1 /work` の応答を差し替える。"""
+ calls: list = []
+
+ def install(responses):
+ def fake(command, **kwargs):
+ calls.append(command)
+ service = command[2]
+ reply = responses[service]
+ if isinstance(reply, Exception):
+ raise reply
+ return subprocess.CompletedProcess(command, 0, stdout=reply, stderr="")
+
+ monkeypatch.setattr(container, 'docker_compose', fake)
+ return calls
+
+ return install
+
+
+def warnings_of(caplog):
+ return [r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING]
+
+
+def test_missing_repositories_are_named_with_their_clone_url(work_listing, caplog):
+ work_listing({"dev-1": "carmo\n"})
+
+ with caplog.at_level(logging.WARNING):
+ container._report_missing_repos(config_of("carmo", "carmo-batch"),
+ scale=1, dev_service_name="dev",
+ project_name="carmo")
+
+ messages = "\n".join(warnings_of(caplog))
+ assert "carmo-batch" in messages
+ assert "https://github.com/volareinc/carmo-batch.git" in messages
+
+
+def test_nothing_is_reported_when_every_repository_is_present(work_listing, caplog):
+ """揃っているときの出力は従来どおり (正常時にノイズを増やさない)。"""
+ work_listing({"dev-1": "carmo\ncarmo-batch\n"})
+
+ with caplog.at_level(logging.WARNING):
+ container._report_missing_repos(config_of("carmo", "carmo-batch"),
+ scale=1, dev_service_name="dev",
+ project_name="carmo")
+
+ assert warnings_of(caplog) == []
+
+
+def test_other_directories_in_the_shared_work_volume_are_ignored(work_listing, caplog):
+ """/work は他プロジェクトと共有される。関係ないディレクトリは判定に使わない。"""
+ work_listing({"dev-1": "carmo\ncarmo-batch\nuttaro-system\n.pnpm-store\n"})
+
+ with caplog.at_level(logging.WARNING):
+ container._report_missing_repos(config_of("carmo", "carmo-batch"),
+ scale=1, dev_service_name="dev",
+ project_name="carmo")
+
+ assert warnings_of(caplog) == []
+
+
+def test_every_instance_is_checked(work_listing, caplog):
+ """scale>1 では instance ごとに /work ボリュームが別なので全部見る。"""
+ calls = work_listing({"dev-1": "carmo\ncarmo-batch\n", "dev-2": "carmo\n"})
+
+ with caplog.at_level(logging.WARNING):
+ container._report_missing_repos(config_of("carmo", "carmo-batch"),
+ scale=2, dev_service_name="dev",
+ project_name="carmo")
+
+ assert [c[2] for c in calls] == ["dev-1", "dev-2"]
+ messages = "\n".join(warnings_of(caplog))
+ assert "dev-2" in messages
+ assert "dev-1" not in messages
+
+
+def test_a_failed_lookup_stays_silent(work_listing, caplog):
+ """問い合わせが失敗しても up は成功済み。付随情報のために騒がない。"""
+ work_listing({"dev-1": subprocess.CalledProcessError(1, "docker")})
+
+ with caplog.at_level(logging.WARNING):
+ container._report_missing_repos(config_of("carmo", "carmo-batch"),
+ scale=1, dev_service_name="dev",
+ project_name="carmo")
+
+ assert warnings_of(caplog) == []
diff --git a/tests/containers/test_entrypoint_repos.py b/tests/containers/test_entrypoint_repos.py
index 30a6373..47424ef 100644
--- a/tests/containers/test_entrypoint_repos.py
+++ b/tests/containers/test_entrypoint_repos.py
@@ -254,6 +254,105 @@ def test_workspace_is_skipped_when_not_configured(tmp_path, work):
assert list(work.iterdir()) == []
+# ---------------------------------------------------------------------------
+# workspace: clone できなかったフォルダを載せない (PLAN37)
+# ---------------------------------------------------------------------------
+
+def encode_folders(*dirs) -> str:
+ """host 側 (``encode_workspace_folders``) と同じ wire format を組み立てる。"""
+ text = "".join(
+ f'{d}\x1f{json.dumps({"name": d, "path": f"/work/{d}"})}\n' for d in dirs)
+ return base64.b64encode(text.encode()).decode()
+
+
+def write_workspace(work: Path, env: dict, tmp_path: Path):
+ dest = work / "sample.code-workspace"
+ result = run_entrypoint_fn(f'devbase_write_workspace "{work}"',
+ {"DEVBASE_WORKSPACE": str(dest), **env}, tmp_path)
+ return result, dest
+
+
+def test_workspace_lists_only_the_repositories_that_exist(tmp_path, work):
+ """clone に失敗した repo を載せると VS Code に開けないフォルダが並ぶため落とす。"""
+ (work / "app").mkdir()
+
+ result, dest = write_workspace(
+ work, {"DEVBASE_WORKSPACE_FOLDERS": encode_folders("app", "no-perm")}, tmp_path)
+
+ assert result.returncode == 0, result.stderr
+ assert json.loads(dest.read_text()) == {
+ "folders": [{"name": "app", "path": "/work/app"}]}
+
+
+def test_a_skipped_workspace_folder_is_reported(tmp_path, work):
+ """何が消えたのか後から分かるよう warning に残す。"""
+ (work / "app").mkdir()
+
+ result, _ = write_workspace(
+ work, {"DEVBASE_WORKSPACE_FOLDERS": encode_folders("app", "no-perm")}, tmp_path)
+
+ assert "no-perm" in result.stdout + result.stderr
+ assert "Warning" in result.stdout + result.stderr
+
+
+def test_workspace_keeps_the_declared_order(tmp_path, work):
+ for name in ("app", "docs", "infra"):
+ (work / name).mkdir()
+
+ result, dest = write_workspace(
+ work, {"DEVBASE_WORKSPACE_FOLDERS": encode_folders("app", "docs", "infra")},
+ tmp_path)
+
+ assert result.returncode == 0, result.stderr
+ assert [f["name"] for f in json.loads(dest.read_text())["folders"]] == [
+ "app", "docs", "infra"]
+
+
+def test_workspace_is_still_valid_json_when_nothing_was_cloned(tmp_path, work):
+ """全滅しても壊れた JSON を残さない (古い workspace が残るより空の方が読める)。"""
+ result, dest = write_workspace(
+ work, {"DEVBASE_WORKSPACE_FOLDERS": encode_folders("no-perm")}, tmp_path)
+
+ assert result.returncode == 0, result.stderr
+ assert json.loads(dest.read_text()) == {"folders": []}
+
+
+def test_workspace_handles_special_characters_in_the_folder_name(tmp_path, work):
+ """folder の JSON はホストが直列化済み。シェルは連結するだけで壊さない。"""
+ (work / 'we"ird').mkdir()
+
+ result, dest = write_workspace(
+ work, {"DEVBASE_WORKSPACE_FOLDERS": encode_folders('we"ird')}, tmp_path)
+
+ assert result.returncode == 0, result.stderr
+ assert json.loads(dest.read_text()) == {
+ "folders": [{"name": 'we"ird', "path": '/work/we"ird'}]}
+
+
+def test_workspace_falls_back_to_the_prebuilt_document(tmp_path, work):
+ """PLAN37 を知らない古いホストから起動されても workspace を消さない。"""
+ document = {"folders": [{"name": "app", "path": "/work/app"}]}
+ encoded = base64.b64encode(json.dumps(document).encode()).decode()
+
+ result, dest = write_workspace(work, {"DEVBASE_WORKSPACE_B64": encoded}, tmp_path)
+
+ assert result.returncode == 0, result.stderr
+ assert json.loads(dest.read_text()) == document
+
+
+def test_a_broken_folder_record_does_not_fail_startup(tmp_path, work):
+ (work / "app").mkdir()
+ broken = base64.b64encode(b"app\x1f{\"name\": \"app\", \"path\": \"/work/app\"}\nnoseparator\n").decode()
+
+ result, dest = write_workspace(
+ work, {"DEVBASE_WORKSPACE_FOLDERS": broken}, tmp_path)
+
+ assert result.returncode == 0, result.stderr
+ assert json.loads(dest.read_text()) == {
+ "folders": [{"name": "app", "path": "/work/app"}]}
+ assert "Warning" in result.stdout + result.stderr
+
+
# ---------------------------------------------------------------------------
# primary への cd
# ---------------------------------------------------------------------------
diff --git a/tests/project/test_runtime.py b/tests/project/test_runtime.py
index da8588c..8cef8d0 100644
--- a/tests/project/test_runtime.py
+++ b/tests/project/test_runtime.py
@@ -12,6 +12,7 @@
from devbase.project.runtime import (
build_workspace_document,
container_env,
+ encode_workspace_folders,
hook_env,
read_scale,
workspace_path,
@@ -59,6 +60,50 @@ def test_single_repo_projects_open_a_plain_folder():
assert "DEVBASE_WORKSPACE" not in env
assert "DEVBASE_WORKSPACE_B64" not in env
+ assert "DEVBASE_WORKSPACE_FOLDERS" not in env
+
+
+# ---------------------------------------------------------------------------
+# workspace の folder レコード (PLAN37)
+# ---------------------------------------------------------------------------
+
+def decode_folder_records(encoded: str):
+ """```` の行を (dir, folder) の列へ戻す。"""
+ text = base64.b64decode(encoded).decode()
+ assert text.endswith("\n"), "行区切りは末尾にも付ける契約"
+ return [(line.split("\x1f")[0], json.loads(line.split("\x1f")[1]))
+ for line in text.splitlines() if line]
+
+
+def test_workspace_folders_pair_each_dir_with_its_serialized_folder():
+ """entrypoint が dir で存在確認できるよう、dir と folder JSON が組で並ぶ。"""
+ env = container_env(config_of("carmo", "carmo-batch"), project_name="carmo")
+
+ assert decode_folder_records(env["DEVBASE_WORKSPACE_FOLDERS"]) == [
+ ("carmo", {"name": "carmo", "path": "/work/carmo"}),
+ ("carmo-batch", {"name": "carmo-batch", "path": "/work/carmo-batch"}),
+ ]
+
+
+def test_workspace_folders_follow_the_document_order():
+ """primary 先頭の並びは workspace 本体と揃える (エクスプローラの並び)。"""
+ config = config_of("carmo-doc", {"repo": "carmo", "primary": True})
+
+ records = decode_folder_records(encode_workspace_folders(config))
+
+ assert [dir_ for dir_, _ in records] == ["carmo", "carmo-doc"]
+ assert [folder for _, folder in records] == build_workspace_document(config)["folders"]
+
+
+def test_workspace_folder_records_stay_on_one_line_with_special_characters():
+ """dir に引用符が入っても、直列化はホスト側で済ませてあるので行が割れない。"""
+ config = config_of({"repo": "carmo", "dir": 'we"ird'}, "carmo-batch")
+
+ text = base64.b64decode(encode_workspace_folders(config)).decode()
+
+ assert len(text.splitlines()) == 2
+ assert decode_folder_records(encode_workspace_folders(config))[0] == (
+ 'we"ird', {"name": 'we"ird', "path": '/work/we"ird'})
def test_workspace_path_is_derived_from_the_project_name():