Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions lib/project/discovery.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -22,13 +24,19 @@ 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
return current


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:
Expand All @@ -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:
Expand Down
84 changes: 84 additions & 0 deletions test/test_project_discovery_env.py
Original file line number Diff line number Diff line change
@@ -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()