Skip to content

Commit 4bfa255

Browse files
takemi-ohamaclaude
andcommitted
fix(tui): dispatch 委譲層で CWD/環境変数を実行後に復元 (PR #55 round1 major)
TUI から project 操作を実行すると _resolve_project_name の os.chdir / env 反映 / COMPOSE_PROJECT_NAME 上書きが同一プロセスに 残留し、トップメニュー復帰後の env get 等が直前プロジェクトの CWD・環境変数 (PWD 含む) を参照していた。 委譲チョークポイントの tui.dispatch に _preserve_cwd_env コンテキスト マネージャを追加し、dispatch_lifecycle / dispatch_group の前後で CWD と os.environ を保存・復元する (例外時も finally で保証)。 回帰テスト 3 件を追加。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9d2ec34 commit 4bfa255

2 files changed

Lines changed: 107 additions & 4 deletions

File tree

lib/devbase/tui/dispatch.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,48 @@
1616

1717
from __future__ import annotations
1818

19+
import contextlib
20+
import os
1921
import types
2022
from pathlib import Path
2123
from typing import Callable
2224

2325

26+
@contextlib.contextmanager
27+
def _preserve_cwd_env():
28+
"""ハンドラ実行前後で CWD と ``os.environ`` を保存・復元する。
29+
30+
CLI 経路は 1 コマンド = 1 プロセスのため、``_resolve_project_name`` が行う
31+
``os.chdir`` / env 反映 / ``COMPOSE_PROJECT_NAME`` 上書きはプロセス終了で消える。
32+
一方 TUI は同一プロセスでトップメニューへ復帰し操作を続行するため、復元しないと
33+
直前プロジェクトの CWD / 環境変数 (PWD 含む) を後続操作 (env get 等) が参照して
34+
しまう (PR #55 round1 codex/gemini major 指摘)。委譲チョークポイントである本層で
35+
一括復元し、各 actions_* / 共有ハンドラへ復元処理を散らさない。
36+
"""
37+
old_cwd = os.getcwd()
38+
old_env = os.environ.copy()
39+
try:
40+
yield
41+
finally:
42+
with contextlib.suppress(OSError):
43+
os.chdir(old_cwd)
44+
os.environ.clear()
45+
os.environ.update(old_env)
46+
47+
2448
def dispatch_lifecycle(subcommand: str, name: str | None = None, **attrs) -> int:
2549
"""``project <subcommand> [name]`` を共有ハンドラ ``cmd_project`` 経由で起動する。
2650
2751
``name`` が指定されると ``_dispatch_lifecycle`` が対象ディレクトリへ chdir して
2852
から実行する。``up`` の ``scale`` など各サブコマンド固有の属性は ``attrs`` で渡す
29-
(未指定でも getattr の既定で吸収される)。
53+
(未指定でも getattr の既定で吸収される)。chdir / env 変更は TUI セッションへ
54+
残留させないよう ``_preserve_cwd_env`` で実行後に復元する。
3055
"""
3156
from devbase.commands.container import cmd_project
3257

3358
ns = types.SimpleNamespace(subcommand=subcommand, name=name, **attrs)
34-
return cmd_project(ns)
59+
with _preserve_cwd_env():
60+
return cmd_project(ns)
3561

3662

3763
def dispatch_group(handler: Callable[[Path, object], int], devbase_root: Path,
@@ -40,7 +66,9 @@ def dispatch_group(handler: Callable[[Path, object], int], devbase_root: Path,
4066
4167
env / plugin / snapshot の各 ``cmd_*`` は ``(devbase_root, args)`` を取り、
4268
``args.subcommand`` で分岐する。TUI はサブコマンドと属性を ``SimpleNamespace`` に
43-
詰めてそのまま委譲する (PR3 以降の actions_* が利用)。
69+
詰めてそのまま委譲する (PR3 以降の actions_* が利用)。現行ハンドラは CWD /
70+
environ を変更しないが、lifecycle 側と契約を揃えるため同じく復元境界を張る。
4471
"""
4572
ns = types.SimpleNamespace(subcommand=subcommand, **attrs)
46-
return handler(devbase_root, ns)
73+
with _preserve_cwd_env():
74+
return handler(devbase_root, ns)

tests/cli/tui/test_dispatch.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import os
56
from pathlib import Path
67

78
from devbase.tui import dispatch
@@ -34,6 +35,80 @@ def test_dispatch_lifecycle_name_optional(monkeypatch):
3435
assert captured == {"name": None}
3536

3637

38+
def test_dispatch_lifecycle_restores_cwd_and_env(monkeypatch, tmp_path):
39+
"""ハンドラが chdir / 環境変数変更したまま戻っても呼び出し前の状態へ復元する。
40+
41+
PR #55 round1 major 回帰テスト: TUI は同一プロセスで継続するため、
42+
`_resolve_project_name` 相当の chdir / env 反映 / COMPOSE_PROJECT_NAME 上書きが
43+
トップメニュー復帰後の操作 (env get 等) へ残留してはならない。
44+
"""
45+
from devbase.commands import container as container_mod
46+
47+
other = tmp_path / "projects" / "carmo"
48+
other.mkdir(parents=True)
49+
50+
def mutating_handler(args):
51+
os.chdir(other) # chdir 残留を模擬
52+
os.environ["COMPOSE_PROJECT_NAME"] = "carmo" # 上書き残留を模擬
53+
os.environ["DEV_SERVICE_NAME"] = "leaked" # 新規キー残留を模擬
54+
os.environ.pop("DEVBASE_TEST_KEEP", None) # 既存キー削除を模擬
55+
return 0
56+
57+
monkeypatch.setattr(container_mod, "cmd_project", mutating_handler)
58+
monkeypatch.chdir(tmp_path)
59+
monkeypatch.setenv("DEVBASE_TEST_KEEP", "orig")
60+
monkeypatch.delenv("DEV_SERVICE_NAME", raising=False)
61+
monkeypatch.setenv("COMPOSE_PROJECT_NAME", "before")
62+
63+
rc = dispatch.dispatch_lifecycle("up", "carmo", scale=None)
64+
65+
assert rc == 0
66+
assert Path.cwd() == tmp_path # CWD 復元
67+
assert os.environ["COMPOSE_PROJECT_NAME"] == "before" # 上書き復元
68+
assert "DEV_SERVICE_NAME" not in os.environ # 漏えいキー除去
69+
assert os.environ["DEVBASE_TEST_KEEP"] == "orig" # 削除キー復元
70+
71+
72+
def test_dispatch_lifecycle_restores_state_on_exception(monkeypatch, tmp_path):
73+
"""ハンドラが例外を投げても CWD / 環境変数は復元される (try/finally 保証)。"""
74+
import pytest
75+
76+
from devbase.commands import container as container_mod
77+
78+
def raising_handler(args):
79+
os.chdir(tmp_path)
80+
os.environ["DEV_SERVICE_NAME"] = "leaked"
81+
raise RuntimeError("boom")
82+
83+
monkeypatch.setattr(container_mod, "cmd_project", raising_handler)
84+
old_cwd = Path.cwd()
85+
monkeypatch.delenv("DEV_SERVICE_NAME", raising=False)
86+
87+
with pytest.raises(RuntimeError):
88+
dispatch.dispatch_lifecycle("up", "carmo", scale=None)
89+
90+
assert Path.cwd() == old_cwd
91+
assert "DEV_SERVICE_NAME" not in os.environ
92+
93+
94+
def test_dispatch_group_restores_cwd_and_env(tmp_path, monkeypatch):
95+
"""dispatch_group も lifecycle と同じ復元境界を張る (契約整合)。"""
96+
97+
def mutating_handler(devbase_root, args):
98+
os.chdir(tmp_path)
99+
os.environ["DEV_SERVICE_NAME"] = "leaked"
100+
return 0
101+
102+
monkeypatch.delenv("DEV_SERVICE_NAME", raising=False)
103+
old_cwd = Path.cwd()
104+
105+
rc = dispatch.dispatch_group(mutating_handler, Path("/devbase"), "init")
106+
107+
assert rc == 0
108+
assert Path.cwd() == old_cwd
109+
assert "DEV_SERVICE_NAME" not in os.environ
110+
111+
37112
def test_dispatch_group_builds_namespace_and_calls_handler():
38113
"""dispatch_group は (devbase_root, args) 形式のハンドラへ委譲する。"""
39114
captured = {}

0 commit comments

Comments
 (0)