From 8a7bb7944806eb26c5cc067df06ac8c5c8499bff Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 01:52:09 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20env=20doctor=20=E3=81=AF=E3=83=AA?= =?UTF-8?q?=E3=83=B3=E3=82=AF=E5=85=88=E3=81=AE=E3=83=AA=E3=83=9D=E3=82=B8?= =?UTF-8?q?=E3=83=88=E3=83=AA=E3=81=A7=E9=99=A4=E5=A4=96=E8=A8=AD=E5=AE=9A?= =?UTF-8?q?=E3=82=92=E5=88=A4=E5=AE=9A=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `projects/` がプラグイン経由で別リポジトリへのシンボリックリンクに なっている環境では、`git check-ignore --no-index -- projects//.env` が `fatal: pathspec ... is beyond a symbolic link` で 128 を返す。doctor はこれを 「git が無い、または Git リポジトリではありません」と報告し、そこで残りの 点検も打ち切っていた。実際には Git も devbase も正常で、診断だけが誤っていた。 リンクを解いた実体から所属リポジトリを引き直し、そのリポジトリからの相対パスで 判定する。問うている内容 (この平文はコミットされうるか) は変えず、判定先だけを 実際にそのファイルを管理しているリポジトリへ移す。除外されていない場合は実体の 位置も報告に添え、どちらの .gitignore を直せばよいか分かるようにした。 あわせて、1 件でも判定できなかった時点で打ち切る挙動をやめ、確かめられなかった パスをまとめて注意として報告するようにした。打ち切ると、確かめられるはずの パスの漏れまで見逃す。 Co-Authored-By: Claude Opus 5 --- lib/devbase/commands/env_ops.py | 106 +++++++++++++++++++++++++++++--- tests/commands/test_env_ops.py | 72 ++++++++++++++++++++++ 2 files changed, 168 insertions(+), 10 deletions(-) diff --git a/lib/devbase/commands/env_ops.py b/lib/devbase/commands/env_ops.py index 6b8e6447..b3377246 100644 --- a/lib/devbase/commands/env_ops.py +++ b/lib/devbase/commands/env_ops.py @@ -309,6 +309,9 @@ def errors(self) -> List[Finding]: #: 除外されるかは実在のプロジェクトが無くても確かめたい。 _SAMPLE_PROJECT_NAME = 'sample' +#: 一覧を報告に載せる上限。プロジェクト数だけ並ぶと読めなくなる。 +_MAX_LISTED = 10 + def _mode_of(path: Path) -> Optional[int]: try: @@ -369,8 +372,9 @@ def _check_leftovers(root: Path, store: SecretStore, report: Report) -> None: plain = [p for p in found if p.suffix != '.age'] if plain and (encrypted or name == 'env-encrypt'): report.add('warning', f'{label}が残っています', - '\n '.join(str(p) for p in plain[:10]) - + (f'\n ... 他 {len(plain) - 10} 件' if len(plain) > 10 else ''), + '\n '.join(str(p) for p in plain[:_MAX_LISTED]) + + (f'\n ... 他 {len(plain) - _MAX_LISTED} 件' + if len(plain) > _MAX_LISTED else ''), f'内容を確認したうえで削除してください: rm -rf {base}') stale: List[Path] = [] @@ -420,6 +424,73 @@ def _git_check_ignore(root: Path, rel_path: str) -> Optional[bool]: return None +def _repo_root_of(path: Path) -> Optional[Path]: + """``path`` を管理している Git リポジトリの最上位を返す。 + + Git 管理下でない、または git を実行できない場合は ``None``。 + """ + try: + proc = subprocess.run( + ['git', 'rev-parse', '--show-toplevel'], + cwd=str(path), + capture_output=True, + text=True, + check=False, + ) + except (OSError, ValueError): + return None + if proc.returncode != 0: + return None + top = proc.stdout.strip() + if not top: + return None + return Path(top).resolve() + + +def _probe_location(root: Path, rel: str) -> Optional[Tuple[Path, str]]: + """点検対象を「実際にそれを管理するリポジトリ」と、その中の相対パスへ翻訳する。 + + ``projects/`` はプラグイン経由で別リポジトリへのシンボリックリンクに + なっていることがある。この状態で ``DEVBASE_ROOT`` を作業ディレクトリにして + ``git check-ignore -- projects//.env`` を実行すると、Git は + ``fatal: pathspec ... is beyond a symbolic link`` と言って 128 で終わる。 + リンク先の実体を管理しているのは別のリポジトリであり、その平文を除外できて + いるかは**そちらの** ``.gitignore`` が決めるためで、これは Git の正しい挙動。 + + そこで、リンクを解いた実体の位置から所属リポジトリを引き直し、そのリポジトリ + からの相対パスで判定する。判定先を移すだけで、問うている内容は変わらない + (「この平文はコミットされうるか」)。 + + 点検対象は実在しないパス (``.env.bak-20260807172231`` など) も含むため、 + 実体の解決は「実在する直近の親」まで遡って行う。 + + Returns: + ``(リポジトリの最上位, その中での相対パス)``。所属リポジトリを特定でき + なければ ``None`` + """ + target = root / rel + + anchor = target.parent + while not anchor.exists() and anchor != anchor.parent: + anchor = anchor.parent + rest = target.relative_to(anchor) + + try: + real_target = anchor.resolve() / rest + except OSError: + return None + + repo = _repo_root_of(real_target.parent if real_target.parent.exists() + else anchor.resolve()) + if repo is None: + return None + try: + return repo, str(real_target.relative_to(repo)) + except ValueError: + # リポジトリの外を指している (想定外の配置)。判定できないものとして扱う + return None + + def _ignore_probe_paths(root: Path) -> List[str]: """除外されているか確かめる代表パスを組み立てる""" paths = list(_IGNORE_PROBE_PATHS) @@ -440,19 +511,24 @@ def _check_gitignore(root: Path, report: Report) -> None: report.checked.append(f"除外設定: {path} (git check-ignore で確認)") exposed: List[str] = [] + unknown: List[str] = [] for rel in _ignore_probe_paths(root): - ignored = _git_check_ignore(root, rel) + location = _probe_location(root, rel) + ignored = None if location is None else _git_check_ignore(*location) if ignored is None: # 「確認できなかった」と「問題なし」を混同しない。ここで独自の # 文字列判定へ落とすと、Git と食い違う判定が復活してしまう。 - report.add('warning', '除外設定を確認できませんでした', - f'{root} で git check-ignore を実行できません ' - '(git が無い、または Git リポジトリではありません)', - 'Git 管理下で `git check-ignore -v .env secrets/global.env.age` ' - 'を実行し、除外されることを確かめてください') - return + # + # ただし 1 件でも確かめられなかったからといって残りを諦めない。 + # 打ち切ると、確かめられるはずのパスの漏れまで見逃す。 + unknown.append(rel) + continue if not ignored: - exposed.append(rel) + repo, rel_in_repo = location + # 別リポジトリ (シンボリックリンク先) の平文は、そちらの .gitignore を + # 直すことになる。報告にも実際の位置を添える + exposed.append(rel if repo == root.resolve() + else f'{rel} → {repo}/{rel_in_repo}') if exposed: report.add('error', '除外設定から漏れているパスがあります', @@ -460,6 +536,16 @@ def _check_gitignore(root: Path, report: Report) -> None: f'{path} へ `.env` / `.env.bak*` / `secrets/` などを追記し、' '`git check-ignore -v <パス>` で除外されることを確かめてください') + if unknown: + shown = unknown[:_MAX_LISTED] + detail = 'Git に判定させられませんでした: ' + '\n '.join(shown) + if len(unknown) > len(shown): + detail += f'\n ... 他 {len(unknown) - len(shown)} 件' + report.add('warning', '除外設定を確認できませんでした', + detail + '\n (git が無い、または対象が Git 管理下にありません)', + 'Git 管理下で `git check-ignore -v .env secrets/global.env.age` ' + 'を実行し、除外されることを確かめてください') + def cmd_env_doctor(devbase_root: Path) -> int: """端末上に残る平文と設定の穴を点検する""" diff --git a/tests/commands/test_env_ops.py b/tests/commands/test_env_ops.py index 2abdbfa0..acf23a14 100644 --- a/tests/commands/test_env_ops.py +++ b/tests/commands/test_env_ops.py @@ -381,6 +381,78 @@ def test_doctor_reports_partially_ignored_secrets_dir(root, with_key, capsys): assert 'secrets/leftover.env' in out +def link_project(root, name, target_repo): + """``projects/`` を別リポジトリ配下へのシンボリックリンクにする。 + + プラグイン経由で取り込んだプロジェクトの実際の姿 (実体は別リポジトリの中に + あり、``projects/`` にはリンクだけが置かれる) を再現する。 + """ + real = target_repo / 'projects' / name + real.mkdir(parents=True) + (root / 'projects' / name).symlink_to(real) + return real + + +@pytest.fixture +def linked_repo(root, tmp_path_factory): + """``projects/`` の実体を持つ別リポジトリ (``DEVBASE_ROOT`` の外)""" + other = tmp_path_factory.mktemp('other-repo') + git_init(other) + return other + + +def test_doctor_checks_symlinked_projects_in_their_own_repository( + root, with_key, linked_repo, capsys): + """リンク先の平文は、それを管理しているリポジトリの除外設定で判定する + + ``DEVBASE_ROOT`` 側で ``git check-ignore -- projects//.env`` を実行する + と、Git はシンボリックリンクを越えたパスを拒んで 128 を返す。これを「判定 + できない」で片付けると、点検全体が確認できなかった扱いになる。 + """ + seed_encrypted(root) + write_gitignore(root) + link_project(root, 'linked', linked_repo) + (linked_repo / '.gitignore').write_text('.env\n') + + assert env_ops.cmd_env_doctor(root) == 0 + out = capsys.readouterr().out + assert '除外設定を確認できませんでした' not in out + assert '問題は見つかりませんでした' in out + + +def test_doctor_reports_linked_projects_that_are_not_ignored( + root, with_key, linked_repo, capsys): + """リンク先のリポジトリが除外していなければ、その位置ごと報告する""" + seed_encrypted(root) + write_gitignore(root) + link_project(root, 'linked', linked_repo) + (linked_repo / '.gitignore').write_text('*.log\n') # .env を除外していない + + assert env_ops.cmd_env_doctor(root) == 1 + out = capsys.readouterr().out + assert '除外設定から漏れているパスがあります' in out + assert 'projects/linked/.env' in out + # どちらの .gitignore を直せばよいかが分かるよう、実体の位置も添える + assert str(linked_repo) in out + + +def test_doctor_keeps_checking_after_an_undeterminable_path( + root, with_key, tmp_path_factory, capsys): + """1 件確かめられなくても、残りの点検を打ち切らない""" + seed_encrypted(root) + (root / '.gitignore').write_text('.env\n.env.bak*\n') # secrets/ が無い + outside = tmp_path_factory.mktemp('not-a-repo') # Git 管理外へのリンク + stray = outside / 'projects' / 'stray' + stray.mkdir(parents=True) + (root / 'projects' / 'stray').symlink_to(stray) + + assert env_ops.cmd_env_doctor(root) == 1 + out = capsys.readouterr().out + assert '除外設定を確認できませんでした' in out # 確かめられなかった分 + assert '除外設定から漏れているパスがあります' in out # 打ち切られていない + assert 'secrets/global.env.age' in out + + def test_doctor_cannot_check_ignores_without_a_git_repository(root, with_key, capsys): """Git リポジトリでなければ「確認できなかった」と言う (成功にしない)""" seed_encrypted(root)