diff --git a/lib/project/discovery.py b/lib/project/discovery.py index c05a8106..1ce106fc 100644 --- a/lib/project/discovery.py +++ b/lib/project/discovery.py @@ -1,12 +1,14 @@ from __future__ import annotations import json +import os from pathlib import Path import tempfile from typing import Any CCB_DIRNAME = '.ccb' WORKSPACE_BINDING_FILENAME = '.ccb-workspace.json' +CCB_PROJECT_DIR_ENV = 'CCB_PROJECT_DIR' class ProjectDiscoveryError(ValueError): @@ -22,6 +24,9 @@ def global_ccb_dir() -> Path: def find_current_project_anchor(start_dir: Path) -> Path | None: + env_anchor = _env_project_anchor() + if env_anchor is not None: + return env_anchor current = _resolved_dir(start_dir) if _project_anchor_dir(current) is None: return None @@ -29,6 +34,9 @@ def find_current_project_anchor(start_dir: Path) -> Path | None: def find_nearest_project_anchor(start_dir: Path) -> Path | None: + env_anchor = _env_project_anchor() + if env_anchor is not None: + return env_anchor current = _resolved_dir(start_dir) for root in _search_roots(current): if _project_anchor_dir(root) is None: @@ -40,6 +48,19 @@ def find_nearest_project_anchor(start_dir: Path) -> Path | None: return None +def _env_project_anchor() -> Path | None: + raw = os.environ.get(CCB_PROJECT_DIR_ENV) + if not raw: + return None + try: + candidate = _resolved_dir(Path(raw)) + except Exception: + return None + if _project_anchor_dir(candidate) is None: + return None + return candidate + + def find_parent_project_anchor_dir(start_dir: Path) -> Path | None: current = _resolved_dir(start_dir) for root in current.parents: diff --git a/test/test_project_discovery_env.py b/test/test_project_discovery_env.py new file mode 100644 index 00000000..646e9185 --- /dev/null +++ b/test/test_project_discovery_env.py @@ -0,0 +1,84 @@ +"""Tests for CCB_PROJECT_DIR env var support in project discovery.""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from project.discovery import ( + CCB_PROJECT_DIR_ENV, + find_current_project_anchor, + find_nearest_project_anchor, +) + + +def _make_ccb_dir(parent: Path) -> Path: + (parent / '.ccb').mkdir(parents=True, exist_ok=True) + return parent + + +class TestCcbProjectDirEnv: + def test_env_unset_walks_cwd(self, monkeypatch, tmp_path): + monkeypatch.delenv(CCB_PROJECT_DIR_ENV, raising=False) + _make_ccb_dir(tmp_path) + nested = tmp_path / 'nested' / 'deeper' + nested.mkdir(parents=True) + assert find_nearest_project_anchor(nested) == tmp_path.resolve() + + def test_env_set_with_valid_ccb_dir_wins_over_cwd(self, monkeypatch, tmp_path): + env_project = _make_ccb_dir(tmp_path / 'env_project') + cwd_project = _make_ccb_dir(tmp_path / 'cwd_project') + monkeypatch.setenv(CCB_PROJECT_DIR_ENV, str(env_project)) + assert find_nearest_project_anchor(cwd_project) == env_project.resolve() + + def test_env_set_with_path_that_has_no_ccb_dir_falls_through(self, monkeypatch, tmp_path): + env_path = tmp_path / 'empty_dir' + env_path.mkdir() + cwd_project = _make_ccb_dir(tmp_path / 'cwd_project') + monkeypatch.setenv(CCB_PROJECT_DIR_ENV, str(env_path)) + assert find_nearest_project_anchor(cwd_project) == cwd_project.resolve() + + def test_env_set_with_nonexistent_path_falls_through(self, monkeypatch, tmp_path): + cwd_project = _make_ccb_dir(tmp_path / 'cwd_project') + monkeypatch.setenv(CCB_PROJECT_DIR_ENV, str(tmp_path / 'does_not_exist')) + assert find_nearest_project_anchor(cwd_project) == cwd_project.resolve() + + def test_env_set_to_empty_string_treated_as_unset(self, monkeypatch, tmp_path): + cwd_project = _make_ccb_dir(tmp_path / 'cwd_project') + monkeypatch.setenv(CCB_PROJECT_DIR_ENV, '') + assert find_nearest_project_anchor(cwd_project) == cwd_project.resolve() + + def test_env_bypasses_dangerous_root_check(self, monkeypatch, tmp_path): + """User explicit intent via env overrides the $HOME-like dangerous check.""" + env_project = _make_ccb_dir(tmp_path) + monkeypatch.setenv(CCB_PROJECT_DIR_ENV, str(env_project)) + unrelated = tmp_path.parent + assert find_nearest_project_anchor(unrelated) == env_project.resolve() + + +class TestFindCurrentProjectAnchorEnv: + """Env var support for the bootstrap_if_missing=True code path. + + ccb ps / ccb ask / etc. use `find_current_project_anchor` when + bootstrap-if-missing is enabled. Without env support here, callers + from a non-project cwd would trigger auto-bootstrap even with + CCB_PROJECT_DIR set. + """ + + def test_env_unset_returns_none_for_non_project_cwd(self, monkeypatch, tmp_path): + monkeypatch.delenv(CCB_PROJECT_DIR_ENV, raising=False) + assert find_current_project_anchor(tmp_path) is None + + def test_env_set_returns_env_path_even_from_non_project_cwd(self, monkeypatch, tmp_path): + env_project = _make_ccb_dir(tmp_path / 'env_project') + non_project_cwd = tmp_path / 'elsewhere' + non_project_cwd.mkdir() + monkeypatch.setenv(CCB_PROJECT_DIR_ENV, str(env_project)) + assert find_current_project_anchor(non_project_cwd) == env_project.resolve() + + def test_env_set_falls_through_when_env_path_has_no_ccb_dir(self, monkeypatch, tmp_path): + env_path = tmp_path / 'no_ccb_here' + env_path.mkdir() + cwd_project = _make_ccb_dir(tmp_path / 'cwd_project') + monkeypatch.setenv(CCB_PROJECT_DIR_ENV, str(env_path)) + assert find_current_project_anchor(cwd_project) == cwd_project.resolve()