Skip to content

Commit 739bd09

Browse files
takemi-ohamaclaude
andcommitted
fix(env): プロジェクト切替時に呼び出し元固有 env キーをクリア (codex指摘)
別プロジェクト内から `devbase project up other` 等を実行した際、対象 env を 上書き source するだけだと呼び出し元プロジェクトにしか無い env キー (例: DEV_SERVICE_NAME) が残留し、対象プロジェクトへ誤って引き継がれていた。 - bin/devbase: 起動時に呼び出し元 (初期 CWD) の env キーを env_var_keys() で記録し、 maybe_cd_project の対象 env source 前に unset。共通キーは対象 env が再設定するため 対象側の値が勝つ。 - lib/devbase/commands/container.py: Python フォールバック (_resolve_project_name / _load_project_env) でも同様に、chdir 前の呼び出し元 env キーのうち対象 env に 無いものを unset。 - tests: wrapper / Python 両経路の回帰テストを追加。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 90bbb8f commit 739bd09

3 files changed

Lines changed: 175 additions & 0 deletions

File tree

bin/devbase

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,27 @@ done
1212
SCRIPT_DIR="$(cd -P "$(dirname "$SCRIPT_PATH")" && pwd)"
1313
DEVBASE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
1414

15+
# env ファイルから「定義されている変数キー名」だけを抽出する (値は読まない)。
16+
# project 切替時に呼び出し元プロジェクト固有の env キーを unset するために使う。
17+
# Python フォールバック側 (_load_project_env) の KEY=VALUE パーサと同じ前提で
18+
# 解釈する: 行頭の空白除去 → 先頭 `#` はコメント → 任意の `export ` 接頭辞除去 →
19+
# `=` の左辺をキーとして採用。シェル展開やコマンド置換は解釈しない (値は無視)。
20+
env_var_keys() {
21+
local file="$1"
22+
[ -f "$file" ] || return 0
23+
local raw line key
24+
while IFS= read -r raw || [ -n "$raw" ]; do
25+
line="${raw#"${raw%%[![:space:]]*}"}" # 行頭空白除去
26+
case "$line" in ''|'#'*) continue ;; esac
27+
line="${line#export }"
28+
line="${line#"${line%%[![:space:]]*}"}" # export 後の空白除去
29+
case "$line" in *=*) ;; *) continue ;; esac
30+
key="${line%%=*}"
31+
key="${key%"${key##*[![:space:]]}"}" # キー末尾空白除去
32+
[ -n "$key" ] && printf '%s\n' "$key"
33+
done < "$file"
34+
}
35+
1536
# Environment setup
1637
export DOCKER_GID=$( [ "$(uname)" = "Darwin" ] && echo "0" || grep docker /etc/group | cut -d: -f3 )
1738
export COMPOSE_PROJECT_NAME=$(basename "$PWD")
@@ -21,6 +42,16 @@ export COMPOSE_PROJECT_NAME=$(basename "$PWD")
2142
# なる。compose は同階層の .env を自動で読むため wrapper 側で project .env を
2243
# source する必要は無い。
2344
[ -f "${DEVBASE_ROOT}/.env" ] && set -a && source "${DEVBASE_ROOT}/.env" && set +a
45+
46+
# 呼び出し元 (初期 CWD) の env で定義された変数キーを記録しておく。
47+
# project 切替 (maybe_cd_project) 時に「呼び出し元プロジェクトにしか無い変数」を
48+
# unset してから対象 env を source するために使う。これをしないと、対象 env に
49+
# 同名キーが無い場合に呼び出し元プロジェクト固有の値 (例: DEV_SERVICE_NAME) が
50+
# 残留し、`devbase project up other` を別プロジェクト内から実行した際に誤って
51+
# 引き継がれてしまう (codex 指摘 / PR#33 bin/devbase:235)。
52+
# env パース仕様は env_var_keys() のコメント参照。
53+
_CALLER_ENV_KEYS=""
54+
[ -f "env" ] && _CALLER_ENV_KEYS="$(env_var_keys ./env)"
2455
[ -f "env" ] && set -a && source ./env && set +a
2556

2657
# Export for Python modules
@@ -232,6 +263,16 @@ maybe_cd_project() {
232263
# 管理する信頼境界内のファイルで (b) 元々 L24 で初期 CWD でも source される
233264
# ため、ここで新たなリスクが増えるわけではない。万一 exit を含む env を読んで
234265
# も「該当プロジェクトの操作が中断する」だけで他プロジェクトへ波及しない。
266+
#
267+
# 重要 (codex 指摘 / PR#33 bin/devbase:235): 単に対象 env を source するだけ
268+
# では、呼び出し元プロジェクトの env にしか無いキー (例: DEV_SERVICE_NAME) が
269+
# 残留し、対象プロジェクトへ誤って引き継がれる。対象 env を読む前に、起動時に
270+
# 記録した呼び出し元 env 固有キーを unset してクリーンな状態を作る。
271+
# (対象 env が同名キーを定義していれば直後の source で再設定される。)
272+
local _k
273+
for _k in $_CALLER_ENV_KEYS; do
274+
unset "$_k"
275+
done
235276
[ -f "env" ] && set -a && source ./env && set +a
236277
return 0
237278
}

lib/devbase/commands/container.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,35 @@ def _report_unknown_project(name: str, projects_dir: Path) -> None:
118118
logger.error("利用可能なプロジェクト: %s", listing)
119119

120120

121+
def _env_var_keys(env_file: Path) -> set:
122+
"""env ファイルが定義する変数キー名の集合を返す (値は読まない)。
123+
124+
project 切替時に「呼び出し元プロジェクト固有の env キー」を unset するために
125+
使う。パース前提は :func:`_load_project_env` と同一 (wrapper の env_var_keys
126+
とも揃える): 行頭空白除去 → 先頭 ``#`` はコメント → ``export`` 接頭辞除去 →
127+
``=`` の左辺をキーとして採用。
128+
"""
129+
keys: set = set()
130+
if not env_file.is_file():
131+
return keys
132+
try:
133+
lines = env_file.read_text().splitlines()
134+
except OSError:
135+
return keys
136+
for raw in lines:
137+
line = raw.strip()
138+
if not line or line.startswith('#'):
139+
continue
140+
if line.startswith('export '):
141+
line = line[len('export '):].lstrip()
142+
if '=' not in line:
143+
continue
144+
key = line.split('=', 1)[0].strip()
145+
if key:
146+
keys.add(key)
147+
return keys
148+
149+
121150
def _load_project_env(env_file: Path) -> None:
122151
"""プロジェクトの ``env`` ファイルを os.environ へ反映する (wrapper 同等)。
123152
@@ -207,8 +236,21 @@ def _resolve_project_name(project_name: str) -> bool:
207236
already_there = target.resolve() == Path.cwd().resolve()
208237
except OSError:
209238
already_there = False
239+
240+
# chdir 前に呼び出し元 (現 CWD) の env が定義するキーを記録しておく。
241+
# 別プロジェクトから `project up other` を直接起動した場合、呼び出し元 env に
242+
# しか無いキー (例: DEV_SERVICE_NAME) が os.environ に残留し対象へ誤って
243+
# 引き継がれるため、対象 env を読む前に unset してクリーンにする
244+
# (codex 指摘 / wrapper の _CALLER_ENV_KEYS と同等のフォールバック)。
245+
# already_there (= 既に対象ディレクトリ。通常 wrapper 経由) の場合は呼び出し元
246+
# =対象であり、wrapper 側で既にクリーン化済みのため何もしない。
247+
caller_env_keys: set = set()
210248
if not already_there:
249+
caller_env_keys = _env_var_keys(Path('env'))
211250
os.chdir(target)
251+
target_env_keys = _env_var_keys(Path('env'))
252+
for key in caller_env_keys - target_env_keys:
253+
os.environ.pop(key, None)
212254

213255
# wrapper の `source ./env` と同等に project env を os.environ へ反映する。
214256
# wrapper 経由なら既に同じ値が載っているため冪等。

tests/cli/test_project_name_resolution.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,35 @@ def test_resolve_missing_env_file_is_noop(fake_root):
160160
assert os.environ["COMPOSE_PROJECT_NAME"] == "carmo"
161161

162162

163+
def test_resolve_clears_caller_only_env_keys(fake_root, monkeypatch):
164+
"""別プロジェクトから直接起動した際、呼び出し元固有の env キーが残留しない。
165+
166+
codex 指摘 (bin/devbase:235 / _load_project_env) の回帰テスト。呼び出し元
167+
プロジェクト caller の env にしか無い ``DEV_SERVICE_NAME`` が対象プロジェクト
168+
other へ誤って引き継がれないこと、共通キーは対象側の値が勝つことを固定する。
169+
"""
170+
for k in ("DEV_SERVICE_NAME", "SHARED"):
171+
monkeypatch.delenv(k, raising=False)
172+
caller = fake_root / "projects" / "caller"
173+
caller.mkdir()
174+
(caller / "env").write_text("DEV_SERVICE_NAME=caller_svc\nSHARED=caller_shared\n")
175+
other = fake_root / "projects" / "other"
176+
other.mkdir()
177+
(other / "env").write_text("SHARED=other_shared\n")
178+
179+
# 呼び出し元プロジェクト内から起動した状況を再現 (env を os.environ へ反映)。
180+
monkeypatch.chdir(caller)
181+
container._load_project_env(Path("env"))
182+
assert os.environ["DEV_SERVICE_NAME"] == "caller_svc"
183+
184+
assert container._resolve_project_name("other") is True
185+
# 呼び出し元固有キーは unset され残留しない
186+
assert "DEV_SERVICE_NAME" not in os.environ
187+
# 共通キーは対象プロジェクトの値が勝つ
188+
assert os.environ["SHARED"] == "other_shared"
189+
assert os.environ["COMPOSE_PROJECT_NAME"] == "other"
190+
191+
163192
def test_load_project_env_diverges_from_shell_source(tmp_path, monkeypatch):
164193
"""shell ``source`` との仕様乖離を固定する回帰テスト (docstring の note 対応)。
165194
@@ -246,6 +275,69 @@ def _build_args(result):
246275
return None
247276

248277

278+
def _run_wrapper_from(args, devbase_root, cwd):
279+
"""`_run_wrapper` と同じだが任意の CWD から起動し env 残留を検証できる版。
280+
281+
run_python スタブが ``DEV_SERVICE_NAME`` の値も出力するため、呼び出し元 env の
282+
残留有無を判定できる。
283+
"""
284+
harness = (
285+
'run_python() { echo "PWD:$PWD"; echo "PYTHON:$*"; '
286+
'echo "DEV_SERVICE_NAME:${DEV_SERVICE_NAME:-<unset>}"; '
287+
'echo "SHARED:${SHARED:-<unset>}"; exit 0; }\n'
288+
'cmd_build() { echo "PWD:$PWD"; echo "BUILD:$*"; exit 0; }\n'
289+
'ensure_uv() { :; }\n'
290+
'eval "$(sed -e \'/^run_python()/,/^}/d\' '
291+
' -e \'/^ensure_uv()/,/^}/d\' '
292+
' -e \'/^cmd_build()/,/^}/d\' '
293+
' -e \'/^DEVBASE_ROOT=/d\' "$WRAPPER_PATH")"\n'
294+
)
295+
env = {
296+
**os.environ,
297+
"DEVBASE_ROOT": str(devbase_root),
298+
"WRAPPER_PATH": str(WRAPPER),
299+
}
300+
env.pop("DEV_SERVICE_NAME", None)
301+
env.pop("SHARED", None)
302+
return subprocess.run(
303+
["bash", "-c", harness, "devbase", *args],
304+
capture_output=True,
305+
text=True,
306+
env=env,
307+
cwd=str(cwd),
308+
)
309+
310+
311+
def _stdout_field(result, prefix):
312+
for line in result.stdout.splitlines():
313+
if line.startswith(prefix):
314+
return line[len(prefix):]
315+
return None
316+
317+
318+
def test_wrapper_clears_caller_only_env_on_project_switch(tmp_path):
319+
"""別プロジェクト内から `up <name>` した際、呼び出し元固有 env が残らない。
320+
321+
codex 指摘 (bin/devbase:235) の回帰テスト。呼び出し元 caller の env にしか無い
322+
``DEV_SERVICE_NAME`` が対象 carmo へ引き継がれず、共通キー ``SHARED`` は対象側の
323+
値が勝つことを wrapper 経路で固定する。
324+
"""
325+
root = tmp_path
326+
carmo = root / "projects" / "carmo"
327+
carmo.mkdir(parents=True)
328+
(carmo / "env").write_text("SHARED=carmo_shared\n")
329+
caller = root / "projects" / "caller"
330+
caller.mkdir(parents=True)
331+
(caller / "env").write_text("DEV_SERVICE_NAME=caller_svc\nSHARED=caller_shared\n")
332+
333+
r = _run_wrapper_from(["up", "carmo"], root, caller)
334+
assert _pwd(r).endswith("/projects/carmo"), r.stdout
335+
# 呼び出し元固有キーは残留しない
336+
assert _stdout_field(r, "DEV_SERVICE_NAME:") == "<unset>", r.stdout
337+
# 共通キーは対象プロジェクトの値が勝つ
338+
assert _stdout_field(r, "SHARED:") == "carmo_shared", r.stdout
339+
340+
249341
def test_wrapper_project_up_name_cds_and_strips(wrapper_root):
250342
r = _run_wrapper(["project", "up", "carmo"], wrapper_root)
251343
assert "unknown command" not in r.stderr.lower(), r.stderr

0 commit comments

Comments
 (0)