diff --git a/bin/devbase b/bin/devbase index bb964f7..a3723fd 100755 --- a/bin/devbase +++ b/bin/devbase @@ -180,6 +180,48 @@ resolve_command() { fi } +# =================================================================== +# Project name resolution (PLAN06 Task 2) +# =================================================================== +# `devbase project ` および同義のトップレベルシノニム +# `devbase ` の が $DEVBASE_ROOT/projects/ に実在する +# 場合、そのディレクトリへ cd し COMPOSE_PROJECT_NAME / env を再設定する。 +# これにより任意の CWD からプロジェクトを指定してコンテナ操作できる。 +# +# 重要: `build` は shell 実装 (cmd_build) が CWD で動くため、この wrapper cd +# だけが build の name 解決手段になる (PLAN06 方針 A の核心)。Python 側 chdir +# フォールバックでは build を救えない。 +# +# 判定は projects/ 配下の実在性で行う。これにより `login ` / +# `build ` / `scale ` の既存 positional と曖昧にならない: 実在する +# プロジェクト名のときだけ name として解釈し cd + strip する。実在しなければ +# 引数はそのまま下流 (Python パーサ) へ渡し、Python 側で index/image/scale +# あるいは「存在しない name」エラーとして扱わせる。 + +# name 候補を受け取り projects/ 配下に実在すれば cd + env 再設定して 0 を返す。 +maybe_cd_project() { + local name="${1:-}" + case "$name" in -*|"") return 1 ;; esac # フラグ・空は name ではない + local target="${DEVBASE_ROOT}/projects/${name}" + [ -d "$target" ] || return 1 + cd "$target" || return 1 + export COMPOSE_PROJECT_NAME="$name" + # cd 後にプロジェクトの env を再 source (初期 CWD で読んだ値を上書き)。 + # project の .env (dotfile) は CRLF / 特殊文字対策で意図的に source しない + # 方針を踏襲する (冒頭コメント参照)。 + # + # 注意: env は環境変数定義のみを想定したファイルであり subshell ではなく + # 現プロセスで source する。これは wrapper 冒頭 L23-24 の env 読み込みと同一 + # 意図 — set -a で export した変数を後続の run_python / cmd_build へ引き継ぐ + # ため、変数が親プロセスに残らない subshell 化はできない。代償として env 内に + # `exit` 等があると wrapper ごと終了するが、env は (a) プロジェクト所有者が + # 管理する信頼境界内のファイルで (b) 元々 L24 で初期 CWD でも source される + # ため、ここで新たなリスクが増えるわけではない。万一 exit を含む env を読んで + # も「該当プロジェクトの操作が中断する」だけで他プロジェクトへ波及しない。 + [ -f "env" ] && set -a && source ./env && set +a + return 0 +} + # Resolve the command (skip flags like --version, -V, -h, --help) _resolved_cmd="${1:-}" case "$_resolved_cmd" in @@ -187,14 +229,60 @@ case "$_resolved_cmd" in *) _resolved_cmd="$(resolve_command "$_resolved_cmd")" ;; esac +# name 解決: 実在するプロジェクト名を検出したら cd し、その token を argv から +# 取り除いた配列 _DEVBASE_ARGS を組み立てる。検出しなければ素通し。 +# name 候補の位置: +# project|container -> $3 (サブコマンドは保持) +# トップレベルシノニム -> $2 +# +# 重要 (PLAN06 codex 指摘対応): `project`/`container` グループでは parser が +# `name` positional を持つサブコマンド (`up`/`down`/`ps`/`logs`/`scale`) に限定 +# して $3 を name 解決する。`project login` / `project build` は単一 positional が +# index / image (旧 container 互換) であり parser が name を受け付けない +# (cli.py の _add_login_subparser / _add_build_subparser 参照)。これらで $3 を +# name strip すると、`project build web` の image=web や `project login web` の +# index 引数が実在プロジェクト名と一致した瞬間に消えて別操作へ化けるため除外する。 +# +# トップレベルシノニム (`build`/`login` を含む) は従来どおり「実在 project なら +# cd」方針を維持する: トップレベル `build`/`login` は Python parser を経由せず +# shell cmd_build / wrapper cd だけが name 指定の手段であり、`build carmo` / +# `login carmo` を「そのプロジェクトを操作」と解釈する設計 (存在性ベース判定)。 +_DEVBASE_ARGS=("${@:2}") +# 同期注意 (メンテナンス性): 下記 2 リストは cli.py の parser 定義に対応する。 +# _PROJECT_NAME_SUBCOMMANDS = `project`/`container` で `name` positional を +# 受け付けるサブコマンド集合。cli.py の _add_project_parser で +# `add_argument('name', ...)` を持つもの (up/down/ps/logs/scale) と一致させる。 +# login/build は index/image 互換のため意図的に除外 (上のコメント参照)。 +# _NAME_RESOLVABLE_SHORTCUTS = トップレベルシノニムのうち「実在 project なら cd」 +# を許すもの。cli.py の SHORTCUTS 経由で project サブコマンドへ写像される +# 集合 + shell 実装の build を含む。 +# cli.py 側でサブコマンドを追加/削除した際は両リストの更新漏れに注意すること +# (cli.py の _add_project_parser / SHORTCUTS にも対の注記あり)。 +_PROJECT_NAME_SUBCOMMANDS=" up down ps logs scale " +_NAME_RESOLVABLE_SHORTCUTS=" up down ps scale login build " +case "$_resolved_cmd" in + project|container) + if [[ "$_PROJECT_NAME_SUBCOMMANDS" == *" ${2:-} "* ]] \ + && maybe_cd_project "${3:-}"; then + _DEVBASE_ARGS=("${2:-}" "${@:4}") + fi + ;; + *) + if [[ "$_NAME_RESOLVABLE_SHORTCUTS" == *" $_resolved_cmd "* ]] \ + && maybe_cd_project "${2:-}"; then + _DEVBASE_ARGS=("${@:3}") + fi + ;; +esac + case "$_resolved_cmd" in # Python-implemented commands --version|-V) run_python "$@" ;; init|status|shell-rc|project|container|ct|env|plugin|pl|snapshot|ss|up|down|login|ps|scale) - run_python "${_resolved_cmd}" "${@:2}" ;; + run_python "${_resolved_cmd}" "${_DEVBASE_ARGS[@]}" ;; # Shell-implemented commands - build) shift; cmd_build "$@" ;; + build) cmd_build "${_DEVBASE_ARGS[@]}" ;; # Help and unknown -h|--help|help|"") run_python "--help" ;; *) echo "Error: unknown command '$1'" >&2; exit 1 ;; diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 0143bd8..24f499a 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -23,6 +23,11 @@ # Python の project build (単純な compose build) とは実装が異なるため。Python 側で # `build` を project build ショートカットとして広告すると wrapper の実経路と乖離する。 # project build / container build サブコマンド自体は引き続き利用可能。 +# +# 同期注意 (メンテナンス性): SHORTCUTS のキー集合と _add_project_parser の +# `name` positional 付きサブコマンドは bin/devbase の _NAME_RESOLVABLE_SHORTCUTS / +# _PROJECT_NAME_SUBCOMMANDS と対応している。サブコマンドを追加/削除する際は +# wrapper 側 (bin/devbase の該当リスト) の更新漏れに注意すること。 SHORTCUTS = { 'up': 'up', 'down': 'down', @@ -129,6 +134,10 @@ def _add_project_parser(subparsers): であり、`[name]` を足すと `project login 2` / `project build web` が誤解釈される ため name を受け付けない。両者は project / container で定義が完全に一致するので `_add_login_subparser` / `_add_build_subparser` に共通化している。 + + 同期注意: ここで `name` positional を持つサブコマンド集合 (up/down/ps/logs/scale) + は bin/devbase の `_PROJECT_NAME_SUBCOMMANDS` と一致させる必要がある。追加/削除時は + wrapper 側リストの更新漏れに注意すること。 """ pj_parser = subparsers.add_parser('project', help='Manage projects (CWD-independent)') pj_sub = pj_parser.add_subparsers(dest='subcommand') diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index e1a735c..cdf9382 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -81,33 +81,148 @@ def _run_pre_up_hook() -> bool: # ディスパッチャ # --------------------------------------------------------------------------- +def _projects_dir() -> Optional[Path]: + """$DEVBASE_ROOT/projects を返す。DEVBASE_ROOT 未設定なら None。""" + root = os.environ.get('DEVBASE_ROOT') + if not root: + return None + return Path(root) / 'projects' + + +# 候補一覧に表示するプロジェクト数の上限。多数プロジェクト環境で iterdir 全件を +# 出力すると 1 行が極端に長くなるため、先頭 N 件 + 「... 他 M 件」で truncate する。 +_MAX_PROJECT_CANDIDATES = 20 + + +def _report_unknown_project(name: str, projects_dir: Path) -> None: + """存在しない project name に対するエラーと候補一覧を出力する。 + + 候補が多数の場合は先頭 ``_MAX_PROJECT_CANDIDATES`` 件のみ表示し、残りは + 「... 他 M 件」と省略する。 + """ + logger.error("プロジェクト '%s' が見つかりません (%s 配下に存在しません)。", + name, projects_dir) + try: + candidates = sorted( + p.name for p in projects_dir.iterdir() + if p.is_dir() or p.is_symlink() + ) + except OSError: + candidates = [] + if candidates: + total = len(candidates) + shown = candidates[:_MAX_PROJECT_CANDIDATES] + listing = ', '.join(shown) + if total > _MAX_PROJECT_CANDIDATES: + listing += f', ... 他 {total - _MAX_PROJECT_CANDIDATES} 件' + logger.error("利用可能なプロジェクト: %s", listing) + + +def _load_project_env(env_file: Path) -> None: + """プロジェクトの ``env`` ファイルを os.environ へ反映する (wrapper 同等)。 + + wrapper (bin/devbase) は cd 後に ``source ./env`` で env を読み込むため、 + Python フォールバック経路でも同じ KEY=VALUE を ``os.environ`` に載せて + 変数欠落 (例: project 固有の ``CONTAINER_SCALE``) を防ぐ。 + + env は環境変数定義のみを想定したファイル (bin/devbase 冒頭コメント参照) の + ため、ここでは ``export`` 接頭辞付き / 無しの単純な ``KEY=VALUE`` 行のみを + 解釈する。``#`` コメント・空行は無視し、値の前後のクォートは除去する。shell + の変数展開やコマンド置換は意図的にサポートしない (安全側に倒す)。 + """ + if not env_file.is_file(): + return + try: + lines = env_file.read_text().splitlines() + except OSError as e: + logger.warning("env ファイルを読み込めませんでした (%s): %s", env_file, e) + return + for raw in lines: + line = raw.strip() + if not line or line.startswith('#'): + continue + if line.startswith('export '): + line = line[len('export '):].lstrip() + if '=' not in line: + continue + key, value = line.split('=', 1) + key = key.strip() + if not key: + continue + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): + value = value[1:-1] + os.environ[key] = value + + +def _resolve_project_name(project_name: str) -> bool: + """project name を $DEVBASE_ROOT/projects/ へ解決し chdir する。 + + 通常は wrapper (bin/devbase) が起動前に cd 済みのため、ここは + + - `python -m devbase.cli project up ` の直接起動 + - wrapper を経ない経路 (`_ensure_env_files` 等) + + に対する防御的フォールバックとして働く。wrapper が既に対象ディレクトリへ + cd 済みなら chdir は no-op になる (同一パス判定)。 + + chdir 後は wrapper の ``source ./env`` と同等に project の ``env`` を + ``os.environ`` へ反映し、wrapper を経ない直接起動でも環境変数が欠落しない + ようにする (gemini round2 minor 指摘対応)。 + + Returns: + True: 解決成功 (または既に対象ディレクトリにいる) + False: DEVBASE_ROOT 未設定 / 対象が存在しない (呼び出し側で return 1) + """ + projects_dir = _projects_dir() + if projects_dir is None: + logger.error("DEVBASE_ROOT が未設定のため project name '%s' を解決できません。", + project_name) + return False + + target = projects_dir / project_name + if not target.is_dir(): + _report_unknown_project(project_name, projects_dir) + return False + + try: + already_there = target.resolve() == Path.cwd().resolve() + except OSError: + already_there = False + if not already_there: + os.chdir(target) + + # wrapper の `source ./env` と同等に project env を os.environ へ反映する。 + # wrapper 経由なら既に同じ値が載っているため冪等。 + _load_project_env(Path('env')) + + # COMPOSE_PROJECT_NAME を name で上書き (wrapper が設定済みでも冪等)。 + # env 由来の COMPOSE_PROJECT_NAME より name 指定を優先するため env 反映後に行う。 + os.environ['COMPOSE_PROJECT_NAME'] = project_name + return True + + def _dispatch_lifecycle(args) -> int: """`project` / `container` 共有のサブコマンドディスパッチャ。 `project [name]` の `name` を解決して project_name へ畳み込む。 `container` 経路には `name` 属性が無いため従来通り None になる。 - NOTE (PLAN06): name によるディレクトリ解決の本体は Task 2 (PR2) で wrapper の - cd + Python フォールバックとして実装する。PR1 では project_name 引数を取れる - up / scale にのみ name を伝播するが、その name も compose のプロジェクトラベル - (COMPOSE_PROJECT_NAME 相当) として使われるだけで、操作対象はあくまで CWD の - compose.yml である点に注意 (ディレクトリ解決は未実装)。 + name 指定時は handler 呼び出し前に一括で `$DEVBASE_ROOT/projects/` へ + chdir する (PLAN06 方針 A の Python 側フォールバック)。chdir を各 handler に + 散らさずここで実施するのは、`cmd_down()` / `cmd_login()` / `cmd_logs()` 等が + project_name 引数を取らず、per-handler 実装では down/login/logs で名前解決が + 効かなくなるため。build は wrapper の shell 実装で CWD 実行されるため、この + Python フォールバックの対象外 (name 属性も持たない)。 """ subcmd = getattr(args, 'subcommand', None) project_name = getattr(args, 'name', None) or getattr(args, 'project_name', None) - # PR1 では name によるディレクトリ解決は未実装で、どのサブコマンドも CWD の - # compose.yml に対して動作する。name を指定されたまま黙って CWD に作用すると - # 「指定したプロジェクトに対して操作できた」と誤解させるため、明示的に警告する - # (name → ディレクトリ解決は PR2 で実装)。up / scale は name をプロジェクト - # ラベルには反映するが、対象ディレクトリは依然 CWD であるため同様に警告する。 + # name 指定時はディレクトリを解決して chdir する。解決失敗 (DEVBASE_ROOT 未設定 + # / 存在しない name) は候補提示の上でエラー終了する。 if project_name: - logger.warning( - "project name '%s' によるディレクトリ解決は未実装です。" - "カレントディレクトリの compose に対して実行します " - "(name 指定は将来のリリースで対応予定)。", - project_name, - ) + if not _resolve_project_name(project_name): + return 1 handlers = { 'up': lambda: cmd_up(project_name=project_name, diff --git a/tests/cli/test_build_shortcut_consistency.py b/tests/cli/test_build_shortcut_consistency.py index 2796d5d..d49182e 100644 --- a/tests/cli/test_build_shortcut_consistency.py +++ b/tests/cli/test_build_shortcut_consistency.py @@ -56,8 +56,9 @@ def test_wrapper_routes_build_to_shell_not_python(): # bin/devbase の dispatch で build は shell の cmd_build に委譲され、 # Python 用 run_python の case には含まれないことを確認する。 wrapper = (Path(__file__).resolve().parents[2] / "bin" / "devbase").read_text() - # build は専用の shell ケースへ - assert "build) shift; cmd_build" in wrapper or "build) shift; cmd_build" in wrapper + # build は専用の shell ケースへ (PLAN06 Task 2 で name strip 後の _DEVBASE_ARGS + # を渡す形に変更。引数は wrapper 側の name 解決で既にコマンド/名を除去済み)。 + assert "build) cmd_build" in wrapper or "build) cmd_build" in wrapper # run_python に委譲する case 行に build が紛れ込んでいないこと for line in wrapper.splitlines(): if "run_python" in line and "${_resolved_cmd}" in line: diff --git a/tests/cli/test_project_dispatch.py b/tests/cli/test_project_dispatch.py index a73eaed..bec846b 100644 --- a/tests/cli/test_project_dispatch.py +++ b/tests/cli/test_project_dispatch.py @@ -120,6 +120,8 @@ def test_lifecycle_passes_name_to_cmd_up(monkeypatch): """`project up ` の name は project_name として up に伝播する。""" from devbase.commands import container captured = {} + # name 解決 (chdir) は別テストで検証するためここでは no-op 化し、伝播のみ見る。 + monkeypatch.setattr(container, '_resolve_project_name', lambda name: True) monkeypatch.setattr(container, 'cmd_up', lambda project_name=None, scale=None: captured.update(project_name=project_name) or 0) @@ -141,42 +143,49 @@ def test_lifecycle_container_path_has_no_name(monkeypatch): # --------------------------------------------------------------------------- -# _dispatch_lifecycle: name 未実装 warning -# (PR1 では up/scale も含め全サブコマンドが CWD の compose に作用するため、 -# name 指定時はサブコマンドに関わらず警告する) +# _dispatch_lifecycle: name 解決 (PR2 で wrapper cd の Python フォールバックを実装) +# name 指定時は handler 呼び出し前に _resolve_project_name で chdir する。 +# 解決失敗時は handler を呼ばずに 1 を返す。詳細な解決ロジックは +# test_project_name_resolution.py を参照。 # --------------------------------------------------------------------------- -def test_lifecycle_warns_for_up_with_name(monkeypatch, caplog): - """`project up ` は name 指定時に未実装 warning を出す。""" +def test_lifecycle_resolves_name_before_handler(monkeypatch): + """name 指定時は handler 前に _resolve_project_name を呼ぶ。""" from devbase.commands import container - monkeypatch.setattr(container, 'cmd_up', lambda project_name=None, scale=None: 0) + order = [] + monkeypatch.setattr(container, '_resolve_project_name', + lambda name: order.append(('resolve', name)) or True) + monkeypatch.setattr(container, 'cmd_up', + lambda project_name=None, scale=None: + order.append(('up', project_name)) or 0) args = _args(subcommand='up', name='carmo', scale=None) - with caplog.at_level(logging.WARNING, logger='devbase.commands.container'): - assert container._dispatch_lifecycle(args) == 0 - assert any('未実装' in r.message for r in caplog.records), \ - 'up でも name 指定時は警告しなければならない' + assert container._dispatch_lifecycle(args) == 0 + assert order == [('resolve', 'carmo'), ('up', 'carmo')] -def test_lifecycle_warns_for_scale_with_name(monkeypatch, caplog): - """`project scale N` も name 指定時に未実装 warning を出す。""" +def test_lifecycle_aborts_when_name_unresolved(monkeypatch): + """name 解決に失敗したら handler を呼ばず 1 を返す。""" from devbase.commands import container - monkeypatch.setattr(container, 'cmd_scale', - lambda new_scale=None, project_name=None: 0) - args = _args(subcommand='scale', name='carmo', new_scale=3) - with caplog.at_level(logging.WARNING, logger='devbase.commands.container'): - assert container._dispatch_lifecycle(args) == 0 - assert any('未実装' in r.message for r in caplog.records), \ - 'scale でも name 指定時は警告しなければならない' + called = [] + monkeypatch.setattr(container, '_resolve_project_name', lambda name: False) + monkeypatch.setattr(container, 'cmd_up', + lambda project_name=None, scale=None: + called.append('up') or 0) + args = _args(subcommand='up', name='bogus', scale=None) + assert container._dispatch_lifecycle(args) == 1 + assert called == [], '解決失敗時は handler を呼んではならない' -def test_lifecycle_no_warning_without_name(monkeypatch, caplog): - """name 未指定なら警告を出さない。""" +def test_lifecycle_no_resolution_without_name(monkeypatch): + """name 未指定なら _resolve_project_name を呼ばない。""" from devbase.commands import container + resolved = [] + monkeypatch.setattr(container, '_resolve_project_name', + lambda name: resolved.append(name) or True) monkeypatch.setattr(container, 'cmd_up', lambda project_name=None, scale=None: 0) args = _args(subcommand='up', scale=None) # name 属性なし - with caplog.at_level(logging.WARNING, logger='devbase.commands.container'): - assert container._dispatch_lifecycle(args) == 0 - assert not any('未実装' in r.message for r in caplog.records) + assert container._dispatch_lifecycle(args) == 0 + assert resolved == [] # --------------------------------------------------------------------------- @@ -293,6 +302,7 @@ def test_shortcut_up_propagates_name_through_dispatch(monkeypatch): """`devbase up ` の name がショートカット経由で cmd_up まで伝播する。""" from devbase.commands import container captured = {} + monkeypatch.setattr(container, '_resolve_project_name', lambda name: True) monkeypatch.setattr(container, 'cmd_up', lambda project_name=None, scale=None: captured.update(project_name=project_name) or 0) @@ -306,6 +316,7 @@ def test_shortcut_scale_propagates_name_through_dispatch(monkeypatch): """`devbase scale N` の name がショートカット経由で cmd_scale まで伝播する。""" from devbase.commands import container captured = {} + monkeypatch.setattr(container, '_resolve_project_name', lambda name: True) monkeypatch.setattr(container, 'cmd_scale', lambda new_scale=None, project_name=None: captured.update(project_name=project_name, new_scale=new_scale) or 0) diff --git a/tests/cli/test_project_name_resolution.py b/tests/cli/test_project_name_resolution.py new file mode 100644 index 0000000..61b5eb8 --- /dev/null +++ b/tests/cli/test_project_name_resolution.py @@ -0,0 +1,319 @@ +"""PLAN06 Task 2: プロジェクト名解決 (wrapper cd + Python フォールバック) のテスト。 + +検証対象: + - Python `container._resolve_project_name`: $DEVBASE_ROOT/projects/ への + chdir + COMPOSE_PROJECT_NAME 上書き、存在しない name のエラー + 候補提示。 + - wrapper (bin/devbase): project/container サブコマンド及びトップレベルシノニムで + 実在するプロジェクト名のみ cd + argv strip し、login / build / + scale の既存 positional と曖昧にならないこと (存在性ベースの判定)。 + +wrapper テストは実際の `uv run` を避けるため run_python / cmd_build をスタブに +差し替え、DEVBASE_ROOT を一時ディレクトリへ向けた薄いハーネスで dispatch のみ +実行する (wrapper 末尾の DEVBASE_ROOT 自動解決行も sed で除去する)。 +""" + +from __future__ import annotations + +import logging +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from devbase.commands import container + +REPO_ROOT = Path(__file__).resolve().parents[2] +WRAPPER = REPO_ROOT / "bin" / "devbase" + + +# =========================================================================== +# Python: _resolve_project_name +# =========================================================================== + +@pytest.fixture +def fake_root(tmp_path, monkeypatch): + """projects/carmo を持つ一時 DEVBASE_ROOT を用意し、CWD/環境を復元する。""" + (tmp_path / "projects" / "carmo").mkdir(parents=True) + (tmp_path / "projects" / "shop").mkdir(parents=True) + monkeypatch.setenv("DEVBASE_ROOT", str(tmp_path)) + monkeypatch.delenv("COMPOSE_PROJECT_NAME", raising=False) + origin = Path.cwd() + monkeypatch.chdir(tmp_path) + yield tmp_path + os.chdir(origin) + + +def test_resolve_chdirs_into_project(fake_root): + assert container._resolve_project_name("carmo") is True + assert Path.cwd().resolve() == (fake_root / "projects" / "carmo").resolve() + assert os.environ["COMPOSE_PROJECT_NAME"] == "carmo" + + +def test_resolve_unknown_name_errors_with_candidates(fake_root, caplog): + with caplog.at_level(logging.ERROR, logger="devbase.commands.container"): + assert container._resolve_project_name("nope") is False + messages = " ".join(r.message for r in caplog.records) + assert "nope" in messages + # 候補一覧に既存プロジェクトが提示される + assert "carmo" in messages and "shop" in messages + + +def test_report_unknown_truncates_many_candidates(tmp_path, monkeypatch, caplog): + """候補が上限を超える場合は先頭 N 件 + 「... 他 M 件」に truncate される。""" + projects_dir = tmp_path / "projects" + projects_dir.mkdir() + total = container._MAX_PROJECT_CANDIDATES + 5 + # ゼロ埋めで sorted 順を安定させる (p000, p001, ...)。 + for i in range(total): + (projects_dir / f"p{i:03d}").mkdir() + + monkeypatch.setenv("DEVBASE_ROOT", str(tmp_path)) + with caplog.at_level(logging.ERROR, logger="devbase.commands.container"): + container._report_unknown_project("nope", projects_dir) + + messages = " ".join(r.message for r in caplog.records) + # 先頭 N 件は表示される + assert "p000" in messages + assert f"p{container._MAX_PROJECT_CANDIDATES - 1:03d}" in messages + # 上限超過分は表示されず、省略表記に集約される + assert f"p{container._MAX_PROJECT_CANDIDATES:03d}" not in messages + assert f"... 他 {total - container._MAX_PROJECT_CANDIDATES} 件" in messages + + +def test_report_unknown_no_truncation_when_within_limit(tmp_path, monkeypatch, caplog): + """候補が上限以内なら省略表記は付かず全件表示される。""" + projects_dir = tmp_path / "projects" + projects_dir.mkdir() + for n in ("carmo", "shop"): + (projects_dir / n).mkdir() + + monkeypatch.setenv("DEVBASE_ROOT", str(tmp_path)) + with caplog.at_level(logging.ERROR, logger="devbase.commands.container"): + container._report_unknown_project("nope", projects_dir) + + messages = " ".join(r.message for r in caplog.records) + assert "carmo" in messages and "shop" in messages + assert "他" not in messages + + +def test_resolve_without_devbase_root(tmp_path, monkeypatch, caplog): + monkeypatch.delenv("DEVBASE_ROOT", raising=False) + with caplog.at_level(logging.ERROR, logger="devbase.commands.container"): + assert container._resolve_project_name("carmo") is False + assert any("DEVBASE_ROOT" in r.message for r in caplog.records) + + +def test_resolve_noop_when_already_in_target(fake_root, monkeypatch): + """wrapper が既に cd 済みなら chdir を呼ばない (冪等)。""" + target = fake_root / "projects" / "carmo" + monkeypatch.chdir(target) + + called = [] + monkeypatch.setattr(container.os, "chdir", lambda p: called.append(p)) + assert container._resolve_project_name("carmo") is True + assert called == [], "既に対象ディレクトリにいる場合 chdir は呼ばれない" + assert os.environ["COMPOSE_PROJECT_NAME"] == "carmo" + + +def test_resolve_loads_project_env(fake_root, monkeypatch): + """wrapper を経ない直接起動でも project env が os.environ へ反映される。 + + gemini round2 minor 指摘 (wrapper の `source ./env` 相当) の回帰テスト。 + """ + monkeypatch.delenv("CONTAINER_SCALE", raising=False) + monkeypatch.delenv("CUSTOM_VAR", raising=False) + env_path = fake_root / "projects" / "carmo" / "env" + env_path.write_text( + "# comment line\n" + "\n" + "CONTAINER_SCALE=5\n" + "export CUSTOM_VAR=hello\n" + 'QUOTED="dq value"\n' + "SQUOTED='sq value'\n" + ) + + assert container._resolve_project_name("carmo") is True + assert os.environ["CONTAINER_SCALE"] == "5" + assert os.environ["CUSTOM_VAR"] == "hello" + assert os.environ["QUOTED"] == "dq value" + assert os.environ["SQUOTED"] == "sq value" + # name 指定は env 由来値より優先される + assert os.environ["COMPOSE_PROJECT_NAME"] == "carmo" + + +def test_resolve_env_name_overrides_env_file_compose_project_name(fake_root, monkeypatch): + """env に COMPOSE_PROJECT_NAME があっても name 指定が優先される。""" + monkeypatch.setenv("COMPOSE_PROJECT_NAME", "stale") + env_path = fake_root / "projects" / "carmo" / "env" + env_path.write_text("COMPOSE_PROJECT_NAME=from_env\n") + + assert container._resolve_project_name("carmo") is True + assert os.environ["COMPOSE_PROJECT_NAME"] == "carmo" + + +def test_resolve_missing_env_file_is_noop(fake_root): + """env ファイルが無くても解決は成功する (フォールバックの堅牢性)。""" + assert not (fake_root / "projects" / "carmo" / "env").exists() + assert container._resolve_project_name("carmo") is True + assert os.environ["COMPOSE_PROJECT_NAME"] == "carmo" + + +# =========================================================================== +# wrapper: cd + argv strip + 存在性ベースの曖昧性回避 +# =========================================================================== + +def _run_wrapper(args, devbase_root): + """run_python / cmd_build をスタブ化し wrapper の dispatch だけを実行する。 + + - run_python -> "PWD:" と "PYTHON:" を出力 + - cmd_build -> "PWD:" と "BUILD:" を出力 + 実際の wrapper が DEVBASE_ROOT を自身のパスから再計算してしまうため、その + 代入行 (`DEVBASE_ROOT=...`) も sed で除去し、環境変数で渡した値を使わせる。 + """ + harness = ( + 'run_python() { echo "PWD:$PWD"; echo "PYTHON:$*"; exit 0; }\n' + 'cmd_build() { echo "PWD:$PWD"; echo "BUILD:$*"; exit 0; }\n' + 'ensure_uv() { :; }\n' + 'eval "$(sed -e \'/^run_python()/,/^}/d\' ' + ' -e \'/^ensure_uv()/,/^}/d\' ' + ' -e \'/^cmd_build()/,/^}/d\' ' + ' -e \'/^DEVBASE_ROOT=/d\' "$WRAPPER_PATH")"\n' + ) + env = { + **os.environ, + "DEVBASE_ROOT": str(devbase_root), + "WRAPPER_PATH": str(WRAPPER), + } + return subprocess.run( + ["bash", "-c", harness, "devbase", *args], + capture_output=True, + text=True, + env=env, + cwd=str(REPO_ROOT), + ) + + +@pytest.fixture +def wrapper_root(tmp_path): + (tmp_path / "projects" / "carmo").mkdir(parents=True) + return tmp_path + + +def _pwd(result): + for line in result.stdout.splitlines(): + if line.startswith("PWD:"): + return line[len("PWD:"):] + return None + + +def _python_args(result): + for line in result.stdout.splitlines(): + if line.startswith("PYTHON:"): + return line[len("PYTHON:"):] + return None + + +def _build_args(result): + for line in result.stdout.splitlines(): + if line.startswith("BUILD:"): + return line[len("BUILD:"):] + return None + + +def test_wrapper_project_up_name_cds_and_strips(wrapper_root): + r = _run_wrapper(["project", "up", "carmo"], wrapper_root) + assert "unknown command" not in r.stderr.lower(), r.stderr + assert _pwd(r).endswith("/projects/carmo"), r.stdout + # name は strip され Python へは渡らない + assert _python_args(r) == "project up", r.stdout + + +def test_wrapper_shortcut_up_name_cds_and_strips(wrapper_root): + r = _run_wrapper(["up", "carmo"], wrapper_root) + assert _pwd(r).endswith("/projects/carmo"), r.stdout + assert _python_args(r) == "up", r.stdout + + +def test_wrapper_unknown_name_not_stripped_no_cd(wrapper_root): + """存在しない name は cd せず素通し (Python 側でエラー処理させる)。""" + r = _run_wrapper(["up", "bogus"], wrapper_root) + assert not _pwd(r).endswith("/projects/bogus"), r.stdout + assert _python_args(r) == "up bogus", r.stdout + + +def test_wrapper_build_name_cds_via_shell(wrapper_root): + """build は shell cmd_build 経路。wrapper cd で対象プロジェクトへ移動する。""" + r = _run_wrapper(["build", "carmo"], wrapper_root) + assert _pwd(r).endswith("/projects/carmo"), r.stdout + assert _build_args(r) == "", r.stdout # name は strip + + +def test_wrapper_build_flag_not_treated_as_name(wrapper_root): + """`build --no-cache` のフラグは name とみなさず CWD でビルド。""" + r = _run_wrapper(["build", "--no-cache"], wrapper_root) + assert not _pwd(r).endswith("/projects/"), r.stdout + assert _build_args(r) == "--no-cache", r.stdout + + +def test_wrapper_scale_name_disambiguation(wrapper_root): + """`scale carmo 3` は name+N、`scale 3` は N のみ (存在性で判定)。""" + r1 = _run_wrapper(["scale", "carmo", "3"], wrapper_root) + assert _pwd(r1).endswith("/projects/carmo"), r1.stdout + assert _python_args(r1) == "scale 3", r1.stdout + + r2 = _run_wrapper(["scale", "3"], wrapper_root) + assert not _pwd(r2).endswith("/projects/3"), r2.stdout + assert _python_args(r2) == "scale 3", r2.stdout + + +def test_wrapper_login_index_not_treated_as_name(wrapper_root): + """`login 2` の 2 は index。projects/2 が無いので cd せず素通し。""" + r = _run_wrapper(["login", "2"], wrapper_root) + assert _python_args(r) == "login 2", r.stdout + + # 一方 `login carmo` は実在プロジェクトなので cd + strip (index=1 既定) + r2 = _run_wrapper(["login", "carmo"], wrapper_root) + assert _pwd(r2).endswith("/projects/carmo"), r2.stdout + assert _python_args(r2) == "login", r2.stdout + + +def test_wrapper_project_scale_name_strips_keeps_subcommand(wrapper_root): + r = _run_wrapper(["project", "scale", "carmo", "3"], wrapper_root) + assert _pwd(r).endswith("/projects/carmo"), r.stdout + assert _python_args(r) == "project scale 3", r.stdout + + +def test_wrapper_no_name_uses_cwd(wrapper_root): + """name を渡さなければ cd せず従来通り (引数素通し)。""" + r = _run_wrapper(["project", "up"], wrapper_root) + assert _python_args(r) == "project up", r.stdout + + +def test_wrapper_project_build_keeps_image_positional(wrapper_root): + """`project build carmo` の carmo は image positional。 + + `project build` parser は name を持たず image を取る (cli.py 参照)。実在 + プロジェクト名 carmo が image と衝突しても name strip せず素通しし、Python + 側で image=carmo として解釈させる (codex 指摘の衝突回避)。 + """ + r = _run_wrapper(["project", "build", "carmo"], wrapper_root) + # cd せず (image 解決は Python 側)、carmo を strip しない + assert not _pwd(r).endswith("/projects/carmo"), r.stdout + assert _python_args(r) == "project build carmo", r.stdout + + +def test_wrapper_project_login_keeps_index_positional(wrapper_root): + """`project login carmo` の carmo は index positional として素通しする。 + + `project login` parser は name を持たず index を取る。実在プロジェクト名と + 一致しても name strip せず、Python パーサに委ねる (codex 指摘の衝突回避)。 + """ + r = _run_wrapper(["project", "login", "carmo"], wrapper_root) + assert not _pwd(r).endswith("/projects/carmo"), r.stdout + assert _python_args(r) == "project login carmo", r.stdout + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"]))