From 9627183bf73289587786ec6019da43fd2f9e2ef0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 14 Jun 2026 11:57:37 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20TUI(list)=20=E7=B5=8C=E8=B7=AF?= =?UTF-8?q?=E3=81=A7=20env=20=E3=81=AE=E5=A4=89=E6=95=B0=E5=8F=82=E7=85=A7?= =?UTF-8?q?=E3=82=92=E5=B1=95=E9=96=8B=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _load_project_env が $VAR / ${VAR} を展開しない仕様だったため、 全プロジェクト env が依存する WORK_DIR=/work/$GIT_REPO が devbase list 経由ではリテラルのまま VS Code に渡り、ワークスペースが $GIT_REPO という パスで開いてしまっていた。shell `source ./env` 相当に os.path.expandvars で 変数展開する (単一引用符値はリテラル、$(...) コマンド置換は非対応のまま)。 Co-Authored-By: Claude Opus 4.8 --- lib/devbase/commands/container.py | 33 +++++++++++++++++------ tests/cli/test_project_name_resolution.py | 33 +++++++++++++++++++---- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 99f9ace..fffb2a7 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -166,26 +166,32 @@ def _load_project_env(env_file: Path) -> None: env は環境変数定義のみを想定したファイル (bin/devbase 冒頭コメント参照) の ため、ここでは ``export`` 接頭辞付き / 無しの単純な ``KEY=VALUE`` 行のみを - 解釈する。``#`` コメント・空行は無視し、値の前後のクォートは除去する。shell - の変数展開やコマンド置換は意図的にサポートしない (安全側に倒す)。 + 解釈する。``#`` コメント・空行は無視し、値の前後のクォートは除去する。 + + 変数参照 (``$VAR`` / ``${VAR}``) は shell ``source ./env`` (wrapper 経路) と + 同様に展開する。実 env が ``WORK_DIR=/work/$GIT_REPO`` のように同一ファイル内で + 先に定義した変数を参照しており、展開しないと TUI (``list``) 経路でワークスペース + パスが ``$GIT_REPO`` 等の未展開文字列のまま VS Code で開いてしまうため + (行は file 順に ``os.environ`` へ載せるので、参照時には先行行の値が解決済み)。 + 単一引用符 ``'...'`` の値は shell 同様リテラル扱いで展開しない。 .. note:: shell ``source`` との仕様乖離について - 本パーサは完全な POSIX shell パーサではなく、shell ``source ./env`` - (wrapper 経路) とは以下のケースで挙動が乖離する。env は単純な + 本パーサは完全な POSIX shell パーサではなく、変数展開はサポートするが + 以下のケースでは shell ``source ./env`` と挙動が乖離する。env は単純な ``KEY=VALUE`` 定義に限定する運用前提のため、これらは意図的な制約として - 受容し、ファイル側で利用しない方針とする (仕様統一ではなく制約の明示):: + 受容する (仕様統一ではなく制約の明示):: - FOO=$BAR # shell: 展開 → 本実装: リテラル文字列 "$BAR" FOO=$(cmd) # shell: コマンド置換 → 本実装: リテラル "$(cmd)" + # (os.path.expandvars は $(...) を変数とみなさない) FOO=a"b"c # shell: クォート除去で "abc" → 本実装: 行頭/行末以外の # クォートは除去せず "a\"b\"c" FOO=bar # x # shell: インラインコメント無効 (値は "bar # x") → # 本実装も値は "bar # x" (行頭 # のみコメント扱い) いずれも wrapper を経ない直接起動 (例: - ``python -m devbase.cli project up ``) のフォールバック時のみ影響し、 - 通常運用の wrapper 経路では shell が env を解釈するため差異は生じない。 + ``python -m devbase.cli project up `` / TUI ``list``) 経路で用いる。 + 通常の wrapper 経路では shell が env を解釈する。 """ if not env_file.is_file(): return @@ -199,8 +205,19 @@ def _load_project_env(env_file: Path) -> None: continue key, value = assignment value = value.strip() + single_quoted = False if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): + single_quoted = value[0] == "'" value = value[1:-1] + # shell `source ./env` 相当の変数展開 ($VAR / ${VAR}) を行う。実 env は + # `WORK_DIR=/work/$GIT_REPO` のように同一ファイル内で先に定義した変数を + # 参照しており (行順に os.environ へ載せるため参照時には解決済み)、展開 + # しないと TUI (list) 経路でワークスペースパスが未展開のまま開いてしまう。 + # 単一引用符はリテラル ($BAR を展開しない) という shell 規則に合わせ、 + # `'...'` の場合のみ展開しない。os.path.expandvars は `$(...)` を変数とは + # みなさず素通しするため、コマンド置換は従来どおりリテラルのまま残る。 + if not single_quoted: + value = os.path.expandvars(value) os.environ[key] = value diff --git a/tests/cli/test_project_name_resolution.py b/tests/cli/test_project_name_resolution.py index 7427ac8..3655edc 100644 --- a/tests/cli/test_project_name_resolution.py +++ b/tests/cli/test_project_name_resolution.py @@ -192,14 +192,13 @@ def test_resolve_clears_caller_only_env_keys(fake_root, monkeypatch): def test_load_project_env_diverges_from_shell_source(tmp_path, monkeypatch): """shell ``source`` との仕様乖離を固定する回帰テスト (docstring の note 対応)。 - 本パーサは変数展開・コマンド置換・行中クォート除去・インラインコメントを - 解釈せず、値を安全側にリテラルとして扱う。この意図的な制約を pin する。 + 変数展開 (``$VAR`` / ``${VAR}``) は shell ``source`` 同様にサポートするが、 + コマンド置換・行中クォート除去・インラインコメントは解釈しない。この境界を pin する。 """ - for k in ("LIT_VAR", "LIT_CMD", "INNER_Q", "INLINE_C"): + for k in ("LIT_CMD", "INNER_Q", "INLINE_C"): monkeypatch.delenv(k, raising=False) env_path = tmp_path / "env" env_path.write_text( - "LIT_VAR=$HOME\n" # 変数展開しない (リテラル "$HOME") "LIT_CMD=$(echo x)\n" # コマンド置換しない (リテラル "$(echo x)") 'INNER_Q=a"b"c\n' # 行中クォートは除去しない "INLINE_C=bar # note\n" # 行頭以外の # はコメント扱いしない @@ -207,12 +206,36 @@ def test_load_project_env_diverges_from_shell_source(tmp_path, monkeypatch): container._load_project_env(env_path) - assert os.environ["LIT_VAR"] == "$HOME" assert os.environ["LIT_CMD"] == "$(echo x)" assert os.environ["INNER_Q"] == 'a"b"c' assert os.environ["INLINE_C"] == "bar # note" +def test_load_project_env_expands_variable_references(tmp_path, monkeypatch): + """``$VAR`` / ``${VAR}`` を shell ``source`` 同様に展開する回帰テスト。 + + 実 env の ``WORK_DIR=/work/$GIT_REPO`` (同一ファイル内で先に定義した変数を参照) + が TUI (``list``) 経路で未展開のまま VS Code に渡る不具合の回帰防止。 + 単一引用符値はリテラル扱いで展開しないことも併せて pin する。 + """ + for k in ("GIT_REPO", "WORK_DIR", "WORK_DIR_BRACE", "SINGLE_Q"): + monkeypatch.delenv(k, raising=False) + env_path = tmp_path / "env" + env_path.write_text( + "GIT_REPO=adminer\n" + "WORK_DIR=/work/$GIT_REPO\n" # 行順に解決済みの GIT_REPO を展開 + "WORK_DIR_BRACE=/work/${GIT_REPO}\n" # ${VAR} 形式も展開 + "SINGLE_Q='/work/$GIT_REPO'\n" # 単一引用符はリテラル + ) + + container._load_project_env(env_path) + + assert os.environ["GIT_REPO"] == "adminer" + assert os.environ["WORK_DIR"] == "/work/adminer" + assert os.environ["WORK_DIR_BRACE"] == "/work/adminer" + assert os.environ["SINGLE_Q"] == "/work/$GIT_REPO" + + # =========================================================================== # wrapper: cd + argv strip + 存在性ベースの曖昧性回避 # =========================================================================== From 438ca9e535e9371540f8502cbd6fc665b4cae1b8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 14 Jun 2026 11:57:46 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20devbase=20env=20init=20=E3=81=A7=20?= =?UTF-8?q?DEVBASE=5FOPEN=5FEDITOR=20=E3=82=92=E5=AF=BE=E8=A9=B1=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A=E3=81=99=E3=82=8B=20(=E6=97=A2=E5=AE=9A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit up/list 後の VS Code 自動オープン (DEVBASE_OPEN_EDITOR) を env init の 対話フローで設定できる collector を追加。既定は有効 (1) で [Y/n] 選択式、 空入力/非対話(EOF)でも 1。0/n/no/false/off で無効化可能。プロジェクト個別の env で上書きできる。 Co-Authored-By: Claude Opus 4.8 --- lib/devbase/env/collectors/editor.py | 54 ++++++++++++++++ lib/devbase/env/keys.py | 7 +- tests/env/test_collector_editor.py | 95 ++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 lib/devbase/env/collectors/editor.py create mode 100644 tests/env/test_collector_editor.py diff --git a/lib/devbase/env/collectors/editor.py b/lib/devbase/env/collectors/editor.py new file mode 100644 index 0000000..de0b0eb --- /dev/null +++ b/lib/devbase/env/collectors/editor.py @@ -0,0 +1,54 @@ +"""エディタ動作設定コレクター (VS Code 自動オープン)。 + +``devbase up`` / ``devbase list`` 後に dev コンテナへ接続した VS Code を自動で +開くか (``DEVBASE_OPEN_EDITOR``) を ``devbase env init`` 時に対話的に設定する。 +既定は有効 (``1``)。プロジェクト個別の ``env`` で ``DEVBASE_OPEN_EDITOR=0`` を +指定すれば、このグローバル既定を上書きして個別に無効化できる +(プロジェクト env はグローバル ``.env`` より後に読まれるため優先される)。 +""" + +from devbase.log import get_logger +from devbase.env import keys +from devbase.env.store import EnvFile, safe_input +from devbase.env.collector import Collector + +logger = get_logger(__name__) + +# 応答の真偽解釈 (大小無視)。空入力は default に倒れる (safe_input が処理)。 +_YES = {"1", "y", "yes", "true", "on"} +_NO = {"0", "n", "no", "false", "off"} + + +def _normalize(answer: str, default: str) -> str: + """ユーザー応答を ``"1"`` / ``"0"`` に正規化する。未知の値は default。""" + a = answer.strip().lower() + if a in _YES: + return "1" + if a in _NO: + return "0" + return default + + +def collect_open_editor(env_file: EnvFile) -> None: + """``DEVBASE_OPEN_EDITOR`` を対話的に設定する (既定: ``1`` = 有効)。 + + 既存値 (``0`` / ``1``) があればそれを既定として提示し、空入力で維持する。 + 非対話 (EOF) 環境では default が確定する。 + """ + existing = env_file.get(keys.DEVBASE_OPEN_EDITOR) + default = existing if existing in ("0", "1") else "1" + answer = safe_input( + f"{keys.DEVBASE_OPEN_EDITOR}: devbase up/list 後に VS Code を自動オープンしますか? " + f"[Y/n] (既定={default}): ", + default, + ) + value = _normalize(answer, default) + env_file.set(keys.DEVBASE_OPEN_EDITOR, value) + logger.info("%s = %s", keys.DEVBASE_OPEN_EDITOR, value) + + +COLLECTOR = Collector( + name="editor", + display_name="VS Code 自動オープン (DEVBASE_OPEN_EDITOR)", + collect_fn=collect_open_editor, +) diff --git a/lib/devbase/env/keys.py b/lib/devbase/env/keys.py index e3dddf2..62f76b3 100644 --- a/lib/devbase/env/keys.py +++ b/lib/devbase/env/keys.py @@ -52,9 +52,10 @@ def gcp_credentials_key(profile: str) -> str: HOST_SSH_HOST = "HOST_SSH_HOST" # 任意。default: host.docker.internal # --- Editor (devbase up 後の自動オープン / PLAN31_3) --- -# いずれも env collection (env init) の対象外で、プロジェクト env / グローバル -# .env に手書きする devbase 動作設定。詳細: docs/user/environment-variables.md -DEVBASE_OPEN_EDITOR = "DEVBASE_OPEN_EDITOR" # 真偽。up 後にエディタを開くか (既定 OFF) +# DEVBASE_OPEN_EDITOR は env init (collectors/editor.py) で対話設定する (既定 1)。 +# 他はプロジェクト env / グローバル .env に手書きする devbase 動作設定。 +# 詳細: docs/user/environment-variables.md +DEVBASE_OPEN_EDITOR = "DEVBASE_OPEN_EDITOR" # 真偽。up 後にエディタを開くか (env init 既定 1) DEVBASE_EDITOR = "DEVBASE_EDITOR" # 任意。起動コマンド (既定 code) DEVBASE_OPEN_INDEX = "DEVBASE_OPEN_INDEX" # 任意。開く dev インスタンス番号 (既定 1) # Remote-SSH 跨ホスト構成 (Windows VS Code → ssh → Mac のコンテナ) 用。 diff --git a/tests/env/test_collector_editor.py b/tests/env/test_collector_editor.py new file mode 100644 index 0000000..fb059ed --- /dev/null +++ b/tests/env/test_collector_editor.py @@ -0,0 +1,95 @@ +"""collectors/editor.py: VS Code 自動オープン (DEVBASE_OPEN_EDITOR) コレクタ""" + +from __future__ import annotations + +import builtins + +import pytest + +from devbase.env import keys +from devbase.env.store import EnvFile +from devbase.env.collector import CollectorRegistry +from devbase.env.collectors import editor + + +@pytest.fixture +def env_file(tmp_path): + return EnvFile(tmp_path / ".env") + + +def _patch_input(monkeypatch, responses): + """input() を順番に responses で返すモックに差し替える (尽きたら EOFError)。""" + it = iter(responses) + + def fake_input(prompt=""): + try: + return next(it) + except StopIteration: + raise EOFError + + monkeypatch.setattr(builtins, "input", fake_input) + + +def test_default_enabled_on_eof(monkeypatch, env_file): + """入力 EOF (非対話/CI) → 既定 1 が設定される""" + _patch_input(monkeypatch, []) + + editor.collect_open_editor(env_file) + + assert env_file.get(keys.DEVBASE_OPEN_EDITOR) == "1" + + +def test_empty_input_keeps_default(monkeypatch, env_file): + """空入力 (Enter のみ) → 既定 1 を維持""" + _patch_input(monkeypatch, [""]) + + editor.collect_open_editor(env_file) + + assert env_file.get(keys.DEVBASE_OPEN_EDITOR) == "1" + + +@pytest.mark.parametrize("answer", ["n", "N", "no", "0", "false", "off"]) +def test_can_disable(monkeypatch, env_file, answer): + """否定的な応答 → 0 (無効) に設定できる (選択可能)""" + _patch_input(monkeypatch, [answer]) + + editor.collect_open_editor(env_file) + + assert env_file.get(keys.DEVBASE_OPEN_EDITOR) == "0" + + +@pytest.mark.parametrize("answer", ["y", "Y", "yes", "1", "true", "on"]) +def test_can_enable(monkeypatch, env_file, answer): + """肯定的な応答 → 1 (有効) に設定できる""" + _patch_input(monkeypatch, [answer]) + + editor.collect_open_editor(env_file) + + assert env_file.get(keys.DEVBASE_OPEN_EDITOR) == "1" + + +def test_existing_value_used_as_default(monkeypatch, env_file): + """既存値 (0) があれば空入力でそれを既定として維持する""" + env_file.set(keys.DEVBASE_OPEN_EDITOR, "0") + _patch_input(monkeypatch, [""]) # Enter → 既存の 0 を維持 + + editor.collect_open_editor(env_file) + + assert env_file.get(keys.DEVBASE_OPEN_EDITOR) == "0" + + +def test_unknown_answer_falls_back_to_default(monkeypatch, env_file): + """未知の応答は既定 (1) にフォールバック""" + _patch_input(monkeypatch, ["maybe"]) + + editor.collect_open_editor(env_file) + + assert env_file.get(keys.DEVBASE_OPEN_EDITOR) == "1" + + +def test_collector_registered(): + """CollectorRegistry が editor コレクタを自動検出する""" + registry = CollectorRegistry() + registry.discover() + names = {c.name for c in registry.collectors} + assert "editor" in names From e8d1970213aa91bb961ebd6a7ce2e90bd780600a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 14 Jun 2026 13:20:19 +0900 Subject: [PATCH 3/5] =?UTF-8?q?test:=20=5Fload=5Fproject=5Fenv=20=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=81=AE=20os.environ=20=E6=BC=8F=E5=87=BA?= =?UTF-8?q?=E3=82=92=E9=98=B2=E6=AD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit container._load_project_env が os.environ に追加する変数が後続テストへ 漏出しないよう、monkeypatch で os.environ をテストスコープに隔離する (cross-review gemini minor 指摘)。 Co-Authored-By: Claude Opus 4.8 --- tests/cli/test_project_name_resolution.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/cli/test_project_name_resolution.py b/tests/cli/test_project_name_resolution.py index 3655edc..f198680 100644 --- a/tests/cli/test_project_name_resolution.py +++ b/tests/cli/test_project_name_resolution.py @@ -195,6 +195,7 @@ def test_load_project_env_diverges_from_shell_source(tmp_path, monkeypatch): 変数展開 (``$VAR`` / ``${VAR}``) は shell ``source`` 同様にサポートするが、 コマンド置換・行中クォート除去・インラインコメントは解釈しない。この境界を pin する。 """ + monkeypatch.setattr(os, "environ", os.environ.copy()) for k in ("LIT_CMD", "INNER_Q", "INLINE_C"): monkeypatch.delenv(k, raising=False) env_path = tmp_path / "env" @@ -218,6 +219,7 @@ def test_load_project_env_expands_variable_references(tmp_path, monkeypatch): が TUI (``list``) 経路で未展開のまま VS Code に渡る不具合の回帰防止。 単一引用符値はリテラル扱いで展開しないことも併せて pin する。 """ + monkeypatch.setattr(os, "environ", os.environ.copy()) for k in ("GIT_REPO", "WORK_DIR", "WORK_DIR_BRACE", "SINGLE_Q"): monkeypatch.delenv(k, raising=False) env_path = tmp_path / "env" From e088b4b2b60e7d7d48b540d13e687c84a54f2681 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 14 Jun 2026 13:28:33 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20env=20=E5=A4=89=E6=95=B0=E5=B1=95?= =?UTF-8?q?=E9=96=8B=E3=82=92=20$VAR/${VAR}=20=E9=99=90=E5=AE=9A=E3=81=AE?= =?UTF-8?q?=E5=B0=82=E7=94=A8=E9=96=A2=E6=95=B0=E3=81=AB=E3=81=97=20$=20?= =?UTF-8?q?=E3=82=A8=E3=82=B9=E3=82=B1=E3=83=BC=E3=83=97=E3=82=92=E5=B0=8A?= =?UTF-8?q?=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit os.path.expandvars は \$ を誤展開し shell source のリテラル $ や EnvFile が書く \$ 付き値を壊すため、$VAR/${VAR} のみ展開し \$ をリテラル $ にデエスケープ、 未定義は空、$(...) は素通しする _expand_env_vars に置換 (cross-review codex major)。 あわせてテストの os.environ 隔離を dict 置換から in-place 退避 (autouse fixture) に 変更し os._Environ の putenv 同期を保つ (cross-review gemini major)。 Co-Authored-By: Claude Opus 4.8 --- lib/devbase/commands/container.py | 28 +++++++++++++++--- tests/cli/test_project_name_resolution.py | 35 +++++++++++++++++++++-- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index fffb2a7..4bcb62a 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -157,6 +157,23 @@ def _env_var_keys(env_file: Path) -> set: } +# env 値中の変数参照を shell `source ./env` 相当に展開する。 +# - `$VAR` / `${VAR}` を environ から展開 (未定義は空文字 = shell source 準拠) +# - `\$` はリテラル `$` にデエスケープ (shell の `\$` と同じ。EnvFile が `$` を +# 保護するため書く `\$` 付き値を壊さない) +# - `$(...)` 等 変数名にならない `$` は素通し (コマンド置換は非対応のまま) +_ENV_VAR_REF = re.compile(r'\\\$|\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)') + + +def _expand_env_vars(value: str, environ) -> str: + def _repl(m): + if m.group(0) == '\\$': + return '$' + name = m.group(1) or m.group(2) + return environ.get(name, '') + return _ENV_VAR_REF.sub(_repl, value) + + def _load_project_env(env_file: Path) -> None: """プロジェクトの ``env`` ファイルを os.environ へ反映する (wrapper 同等)。 @@ -183,7 +200,7 @@ def _load_project_env(env_file: Path) -> None: 受容する (仕様統一ではなく制約の明示):: FOO=$(cmd) # shell: コマンド置換 → 本実装: リテラル "$(cmd)" - # (os.path.expandvars は $(...) を変数とみなさない) + # (_expand_env_vars は $(...) を変数とみなさず素通し) FOO=a"b"c # shell: クォート除去で "abc" → 本実装: 行頭/行末以外の # クォートは除去せず "a\"b\"c" FOO=bar # x # shell: インラインコメント無効 (値は "bar # x") → @@ -214,10 +231,13 @@ def _load_project_env(env_file: Path) -> None: # 参照しており (行順に os.environ へ載せるため参照時には解決済み)、展開 # しないと TUI (list) 経路でワークスペースパスが未展開のまま開いてしまう。 # 単一引用符はリテラル ($BAR を展開しない) という shell 規則に合わせ、 - # `'...'` の場合のみ展開しない。os.path.expandvars は `$(...)` を変数とは - # みなさず素通しするため、コマンド置換は従来どおりリテラルのまま残る。 + # `'...'` の場合のみ展開しない。展開は _expand_env_vars に委ね、`$VAR` / + # `${VAR}` のみ展開し (未定義は空文字 = shell source 準拠)、`\$` はリテラル + # `$` にデエスケープする (shell の `\$` と同じ。EnvFile が `$` を保護する + # ため書く `\$` 付き値を壊さない)。`$(...)` 等 変数名にならない `$` は素通し + # するため、コマンド置換は従来どおりリテラルのまま残る。 if not single_quoted: - value = os.path.expandvars(value) + value = _expand_env_vars(value, os.environ) os.environ[key] = value diff --git a/tests/cli/test_project_name_resolution.py b/tests/cli/test_project_name_resolution.py index f198680..d8d376d 100644 --- a/tests/cli/test_project_name_resolution.py +++ b/tests/cli/test_project_name_resolution.py @@ -28,6 +28,21 @@ WRAPPER = REPO_ROOT / "bin" / "devbase" +@pytest.fixture(autouse=True) +def _isolate_os_environ(): + """各テストの os.environ 変更を in-place で退避・復元する (後続テストへの漏出防止)。 + + os.environ を os._Environ のまま扱う (dict で置換しない) ため putenv 同期は保たれ、 + subprocess へ環境が伝わらなくなる問題を避ける。 + """ + saved = dict(os.environ) + try: + yield + finally: + os.environ.clear() + os.environ.update(saved) + + # =========================================================================== # Python: _resolve_project_name # =========================================================================== @@ -195,7 +210,6 @@ def test_load_project_env_diverges_from_shell_source(tmp_path, monkeypatch): 変数展開 (``$VAR`` / ``${VAR}``) は shell ``source`` 同様にサポートするが、 コマンド置換・行中クォート除去・インラインコメントは解釈しない。この境界を pin する。 """ - monkeypatch.setattr(os, "environ", os.environ.copy()) for k in ("LIT_CMD", "INNER_Q", "INLINE_C"): monkeypatch.delenv(k, raising=False) env_path = tmp_path / "env" @@ -219,7 +233,6 @@ def test_load_project_env_expands_variable_references(tmp_path, monkeypatch): が TUI (``list``) 経路で未展開のまま VS Code に渡る不具合の回帰防止。 単一引用符値はリテラル扱いで展開しないことも併せて pin する。 """ - monkeypatch.setattr(os, "environ", os.environ.copy()) for k in ("GIT_REPO", "WORK_DIR", "WORK_DIR_BRACE", "SINGLE_Q"): monkeypatch.delenv(k, raising=False) env_path = tmp_path / "env" @@ -238,6 +251,24 @@ def test_load_project_env_expands_variable_references(tmp_path, monkeypatch): assert os.environ["SINGLE_Q"] == "/work/$GIT_REPO" +def test_load_project_env_escaped_dollar_and_undefined(tmp_path, monkeypatch): + """`\\$` はリテラル `$`、未定義参照は空 (shell source 準拠)。$(...) は別テストで担保。""" + for k in ("DEFINED", "ESCAPED", "UNDEF_REF", "NOPE"): + monkeypatch.delenv(k, raising=False) + env_path = tmp_path / "env" + env_path.write_text( + "DEFINED=x\n" + "ESCAPED=a\\$DEFINED\n" # \\$ → リテラル $ (展開しない) + "UNDEF_REF=/p/$NOPE/q\n" # 未定義は空 + ) + + container._load_project_env(env_path) + + assert os.environ["DEFINED"] == "x" + assert os.environ["ESCAPED"] == "a$DEFINED" + assert os.environ["UNDEF_REF"] == "/p//q" + + # =========================================================================== # wrapper: cd + argv strip + 存在性ベースの曖昧性回避 # =========================================================================== From 55043ab091afa545a2b270341e5824d750043112 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 14 Jun 2026 13:36:06 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=5Fload=5Fproject=5Fenv=20docstring?= =?UTF-8?q?=20=E3=81=AE=20shell=20=E3=82=A4=E3=83=B3=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88=E6=8C=99=E5=8B=95?= =?UTF-8?q?=E3=81=AE=E8=A8=98=E8=BF=B0=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 非クォートの FOO=bar # x は shell ではコメント有効で値は bar になる。 誤って「無効」と記していた note を事実に沿って修正 (cross-review gemini minor)。 Co-Authored-By: Claude Opus 4.8 --- lib/devbase/commands/container.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 4bcb62a..c9bee9a 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -203,8 +203,8 @@ def _load_project_env(env_file: Path) -> None: # (_expand_env_vars は $(...) を変数とみなさず素通し) FOO=a"b"c # shell: クォート除去で "abc" → 本実装: 行頭/行末以外の # クォートは除去せず "a\"b\"c" - FOO=bar # x # shell: インラインコメント無効 (値は "bar # x") → - # 本実装も値は "bar # x" (行頭 # のみコメント扱い) + FOO=bar # x # shell: インラインコメント有効 (値は "bar") → + # 本実装: 行頭 # のみコメント扱いのため値は "bar # x" いずれも wrapper を経ない直接起動 (例: ``python -m devbase.cli project up `` / TUI ``list``) 経路で用いる。