diff --git a/apps/backend/core/build_cache.py b/apps/backend/core/build_cache.py new file mode 100644 index 000000000..9717f7e15 --- /dev/null +++ b/apps/backend/core/build_cache.py @@ -0,0 +1,65 @@ +""" +Build Cache Environment +======================= + +Compiler-cache environment variables for compiled-language projects. + +Fresh worktrees always cold-build, which makes every coder -> QA -> fixer +iteration expensive for C/C++ and Rust projects. ccache and sccache keep +their caches outside the worktree, so pointing the build at them lets a +brand-new worktree reuse object files from previous builds. + +Only opt-in mechanisms are used: +- CMake honors CMAKE__COMPILER_LAUNCHER environment variables +- Cargo honors RUSTC_WRAPPER + +Plain Make projects are deliberately left alone: overriding CC/CXX can +break builds that expect a bare compiler path. +""" + +import os +import shutil +from pathlib import Path + + +def _is_cmake_project(project_dir: Path) -> bool: + """Check if the project builds with CMake.""" + return (project_dir / "CMakeLists.txt").exists() + + +def _is_rust_project(project_dir: Path) -> bool: + """Check if the project builds with Cargo.""" + return (project_dir / "Cargo.toml").exists() + + +def get_build_cache_env( + project_dir: Path, existing_env: dict[str, str] | None = None +) -> dict[str, str]: + """ + Get compiler-cache env vars for the agent session. + + Args: + project_dir: Root directory of the project being built + existing_env: Env vars already collected for the SDK subprocess; + variables present there (or in os.environ) are never overridden + + Returns: + Dict of env vars to add (empty when no cache tool applies) + """ + project_dir = Path(project_dir) + existing = {**os.environ, **(existing_env or {})} + env: dict[str, str] = {} + + if _is_cmake_project(project_dir) and shutil.which("ccache"): + for var in ("CMAKE_C_COMPILER_LAUNCHER", "CMAKE_CXX_COMPILER_LAUNCHER"): + if var not in existing: + env[var] = "ccache" + + if _is_rust_project(project_dir) and shutil.which("sccache"): + if "RUSTC_WRAPPER" not in existing: + env["RUSTC_WRAPPER"] = "sccache" + + return env + + +__all__ = ["get_build_cache_env"] diff --git a/apps/backend/core/client.py b/apps/backend/core/client.py index 2dfefa03d..290d64ab2 100644 --- a/apps/backend/core/client.py +++ b/apps/backend/core/client.py @@ -183,6 +183,7 @@ def invalidate_project_cache(project_dir: Path | None = None) -> None: require_auth_token, validate_token_not_encrypted, ) +from core.build_cache import get_build_cache_env from core.providers.config import get_provider_config from enterprise.data_residency import get_data_residency_config from linear_updater import is_linear_enabled @@ -942,6 +943,13 @@ def create_client( # Collect env vars to pass to SDK (ANTHROPIC_BASE_URL, etc.) sdk_env = get_sdk_env_vars() + # Reuse compiler caches across fresh worktrees for compiled stacks + # (ccache for CMake C/C++ builds, sccache for Cargo builds) + build_cache_env = get_build_cache_env(project_dir, sdk_env) + if build_cache_env: + sdk_env.update(build_cache_env) + logger.info(f"Build cache enabled: {', '.join(sorted(build_cache_env))}") + # Configure data residency (regional API endpoints) data_residency_config = get_data_residency_config() regional_endpoint = data_residency_config.get_endpoint() diff --git a/apps/backend/project/command_registry/languages.py b/apps/backend/project/command_registry/languages.py index e964ec698..f65ca0624 100644 --- a/apps/backend/project/command_registry/languages.py +++ b/apps/backend/project/command_registry/languages.py @@ -41,6 +41,7 @@ "rustup", "rustfmt", "rust-analyzer", + "sccache", # Compiler cache (RUSTC_WRAPPER) # Cargo subcommand binaries "cargo-clippy", "cargo-fmt", diff --git a/tests/test_build_cache.py b/tests/test_build_cache.py new file mode 100644 index 000000000..7fe780099 --- /dev/null +++ b/tests/test_build_cache.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +""" +Tests for the build_cache module. + +Tests cover: +- ccache env injection for CMake projects +- sccache env injection for Cargo projects +- Respecting user-set variables +- No-op behavior when tools are missing or project is not compiled +""" + +# Add auto-claude to path for imports +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "apps" / "backend")) + +from core.build_cache import get_build_cache_env + + +@pytest.fixture +def temp_dir(tmp_path): + """Alias fixture for a temporary project directory.""" + return tmp_path + + +def _which_factory(available: set[str]): + """Return a shutil.which stand-in knowing only the given tools.""" + + def _which(name: str): + return f"/usr/bin/{name}" if name in available else None + + return _which + + +class TestBuildCacheEnv: + """Tests for get_build_cache_env.""" + + def test_cmake_project_with_ccache(self, temp_dir): + """CMake project gets compiler launcher vars when ccache exists.""" + (temp_dir / "CMakeLists.txt").write_text("project(demo)") + + with patch("core.build_cache.shutil.which", _which_factory({"ccache"})): + env = get_build_cache_env(temp_dir) + + assert env["CMAKE_C_COMPILER_LAUNCHER"] == "ccache" + assert env["CMAKE_CXX_COMPILER_LAUNCHER"] == "ccache" + + def test_cmake_project_without_ccache(self, temp_dir): + """No launcher vars when ccache is not installed.""" + (temp_dir / "CMakeLists.txt").write_text("project(demo)") + + with patch("core.build_cache.shutil.which", _which_factory(set())): + env = get_build_cache_env(temp_dir) + + assert env == {} + + def test_rust_project_with_sccache(self, temp_dir): + """Cargo project gets RUSTC_WRAPPER when sccache exists.""" + (temp_dir / "Cargo.toml").write_text('[package]\nname = "demo"') + + with patch("core.build_cache.shutil.which", _which_factory({"sccache"})): + env = get_build_cache_env(temp_dir) + + assert env["RUSTC_WRAPPER"] == "sccache" + + def test_user_set_vars_not_overridden(self, temp_dir): + """Variables already present in the session env are respected.""" + (temp_dir / "CMakeLists.txt").write_text("project(demo)") + (temp_dir / "Cargo.toml").write_text('[package]\nname = "demo"') + + existing = { + "CMAKE_C_COMPILER_LAUNCHER": "distcc", + "RUSTC_WRAPPER": "my-wrapper", + } + with patch( + "core.build_cache.shutil.which", + _which_factory({"ccache", "sccache"}), + ): + env = get_build_cache_env(temp_dir, existing) + + assert "CMAKE_C_COMPILER_LAUNCHER" not in env + assert "RUSTC_WRAPPER" not in env + # CXX launcher was not pre-set, so it is still added + assert env["CMAKE_CXX_COMPILER_LAUNCHER"] == "ccache" + + def test_os_environ_vars_not_overridden(self, temp_dir): + """Variables set in os.environ are respected.""" + (temp_dir / "CMakeLists.txt").write_text("project(demo)") + + with ( + patch("core.build_cache.shutil.which", _which_factory({"ccache"})), + patch.dict( + "core.build_cache.os.environ", + {"CMAKE_C_COMPILER_LAUNCHER": "distcc"}, + ), + ): + env = get_build_cache_env(temp_dir) + + assert "CMAKE_C_COMPILER_LAUNCHER" not in env + assert env["CMAKE_CXX_COMPILER_LAUNCHER"] == "ccache" + + def test_non_compiled_project_is_noop(self, temp_dir): + """Python/JS projects get no build-cache vars.""" + (temp_dir / "package.json").write_text("{}") + (temp_dir / "requirements.txt").write_text("flask\n") + + with patch( + "core.build_cache.shutil.which", + _which_factory({"ccache", "sccache"}), + ): + env = get_build_cache_env(temp_dir) + + assert env == {} + + def test_make_only_project_is_noop(self, temp_dir): + """Plain Make projects are left alone (no CC/CXX override).""" + (temp_dir / "Makefile").write_text("all:\n\tgcc main.c\n") + (temp_dir / "main.c").write_text("int main(void) { return 0; }") + + with patch("core.build_cache.shutil.which", _which_factory({"ccache"})): + env = get_build_cache_env(temp_dir) + + assert env == {}