Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@

### Fixed

- Bat caches rebuild after all theme writes and recover missing or stale compiled themes, including custom theme directories.
- OpenCode surfaces follow the active mode with readable syntax and interface states.
- Managed OpenCode agent colors follow the active mode while preserving unrelated configuration.
- Dark Starship diagnostic styles resolve through the palette.
Expand Down
12 changes: 12 additions & 0 deletions docs/configuration/theme-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ python scripts/generate-palette-tokens.py --check
python scripts/generate-dark-css.py --check
```

## Bat compiled cache

`dreamcoder sync` checks Bat's compiled cache after all theme source and variant
writes. Missing `themes.bin` or `syntaxes.bin`, or theme sources newer than either
binary, trigger `bat cache --build`; an up-to-date cache is left unchanged.
`BAT_THEME_DIR` selects the sources (default: `$XDG_CONFIG_HOME/bat/themes`, or
`~/.config/bat/themes`). `BAT_CACHE_PATH` selects the compiled cache (default:
`$XDG_CACHE_HOME/bat`, or `~/.cache/bat`). Builds ignore user Bat options by setting
`BAT_CONFIG_PATH` to the null device. Bat is optional: when it is absent, sync
continues without compiling. A build failure stops sync with a diagnostic and
paths to check before retrying; generated theme sources remain available.

## Pi and Gentle Shell

`dreamcoder light`, `dreamcoder dark`, and `dreamcoder sync` generate the same
Expand Down
64 changes: 64 additions & 0 deletions src/dreamcoder_theme/bat_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Maintain Bat's compiled cache after theme sources have been written."""

from __future__ import annotations

import os
import shutil
import subprocess
import tempfile
from contextlib import ExitStack
from pathlib import Path

from .settings import ThemePaths


class BatCacheError(RuntimeError):
"""A present Bat installation could not compile its theme sources."""


def ensure_bat_cache(paths: ThemePaths) -> str:
"""Repair missing/stale binaries without rebuilding an up-to-date cache."""
bat = shutil.which("bat")
if bat is None:
return "skipped (bat not installed)"
cache = Path(
os.environ.get(
"BAT_CACHE_PATH",
Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "bat",
)
)
command = [bat, "cache", "--build"]
try:
binaries = [cache / name for name in ("themes.bin", "syntaxes.bin")]
sources = [path for path in paths.bat_theme_dir.rglob("*") if path.is_file()]
if all(path.is_file() for path in binaries):
oldest_cache = min(path.stat().st_mtime_ns for path in binaries)
if all(path.stat().st_mtime_ns <= oldest_cache for path in sources):
return "unchanged"
with ExitStack() as stack:
source_root = paths.bat_theme_dir.parent
if paths.bat_theme_dir.name != "themes":
# Bat requires a themes/ child of --source, even for custom destinations.
source_root = Path(stack.enter_context(tempfile.TemporaryDirectory()))
(source_root / "themes").symlink_to(paths.bat_theme_dir.resolve())
command += ["--source", str(source_root), "--target", str(cache)]
result = subprocess.run(
command,
env=dict(os.environ, BAT_CONFIG_PATH=os.devnull),
text=True,
capture_output=True,
check=False,
)
if result.returncode:
detail = result.stderr.strip() or result.stdout.strip() or "no diagnostic output"
raise BatCacheError(
f"bat cache build failed (exit {result.returncode}): {detail}. "
f"Check themes in {paths.bat_theme_dir} and cache permissions at {cache}, "
"then rerun dreamcoder sync."
)
except OSError as exc:
raise BatCacheError(
f"bat cache build failed: {exc}. Check themes in {paths.bat_theme_dir} "
f"and cache permissions at {cache}, then rerun dreamcoder sync."
) from exc
return "rebuilt"
8 changes: 8 additions & 0 deletions src/dreamcoder_theme/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from pathlib import Path
from typing import Any, NamedTuple

from .bat_cache import BatCacheError, ensure_bat_cache
from .herdr_contract import SUPPORTED_PROFILES, HerdrProfile
from .opencode_colors import update_opencode_agent_colors
from .palette import (
Expand Down Expand Up @@ -1269,6 +1270,13 @@ def main(argv: Sequence[str] | None = None) -> None:
)
changed["bat_theme_variants"] = any(bat_variant_changes)

# Repository mirrors can share the live Bat sources through symlinks.
# Compile only after every source/variant write, even when nothing changed.
try:
ensure_bat_cache(paths)
except BatCacheError as exc:
raise SystemExit(str(exc)) from None

if not valid_starship(paths.starship):
raise SystemExit(f"Generated Starship config is invalid: {paths.starship}")
print_summary(prepared.mode, paths, changed, repo_changes)
Expand Down
148 changes: 148 additions & 0 deletions tests/test_bat_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""Public sync/cache lifecycle checks; every subprocess uses isolated roots."""

from __future__ import annotations

import os
import shutil
import subprocess
import sys
from unittest import mock

import pytest
from test_pi_theme_generation import run_sync

from dreamcoder_theme.bat_cache import BatCacheError, ensure_bat_cache
from dreamcoder_theme.palette_tokens import VARIANTS
from dreamcoder_theme.renderers import codex_tmtheme_content
from dreamcoder_theme.settings import theme_paths


@pytest.fixture
def bat_env(tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache"))
monkeypatch.setenv("BAT_CACHE_PATH", str(tmp_path / "cache/bat"))
monkeypatch.setenv("BAT_THEME_DIR", str(tmp_path / "config/bat/themes"))
return tmp_path


@pytest.mark.skipif(shutil.which("bat") is None, reason="optional bat not installed")
@pytest.mark.parametrize("existing_sources", [False, True])
def test_public_sync_builds_after_all_writes_and_repairs_missing_cache(tmp_path, existing_sources):
if existing_sources:
assert run_sync(tmp_path, "light").returncode == 0
for _ in range(2):
result = run_sync(tmp_path, "light")
assert result.returncode == 0, result.stdout + result.stderr
assert result.stderr == ""
cache = tmp_path / "cache/bat/themes.bin"
assert cache.is_file()
env = dict(os.environ, BAT_CONFIG_PATH=os.devnull, BAT_CACHE_PATH=str(cache.parent))
themes = subprocess.run(
[shutil.which("bat"), "--list-themes"],
env=env,
text=True,
capture_output=True,
timeout=10,
check=False,
)
assert themes.returncode == 0
assert themes.stderr == ""
assert {"Dreamcoder", "Dreamcoder-Dark", "Dreamcoder-Light"} <= set(
themes.stdout.splitlines()
)
before = {path.name: path.read_bytes() for path in cache.parent.iterdir()}
modified = cache.stat().st_mtime_ns
unchanged = run_sync(tmp_path, "light")
assert unchanged.returncode == 0
assert unchanged.stderr == ""
assert cache.stat().st_mtime_ns == modified
assert {path.name: path.read_bytes() for path in cache.parent.iterdir()} == before
cache.unlink()


@pytest.mark.parametrize("xdg_default", [False, True])
def test_cache_fresh_stale_and_unchanged(bat_env, monkeypatch, xdg_default):
if xdg_default:
monkeypatch.delenv("BAT_CACHE_PATH")
monkeypatch.delenv("BAT_THEME_DIR")
paths = theme_paths()
paths.bat_theme_dir.mkdir(parents=True)
source = paths.bat_theme_dir / "Dreamcoder.tmTheme"
source.write_text("theme")
cache = bat_env / "cache/bat"

def build(*args, **kwargs):
assert args[0][-4:] == ["--source", str(paths.bat_theme_dir.parent), "--target", str(cache)]
assert kwargs["env"]["BAT_CONFIG_PATH"] == os.devnull
cache.mkdir(parents=True, exist_ok=True)
for name in ("themes.bin", "syntaxes.bin"):
(cache / name).write_text("compiled")
return subprocess.CompletedProcess(args[0], 0, "", "")

with (
mock.patch("dreamcoder_theme.bat_cache.shutil.which", return_value="bat"),
mock.patch("dreamcoder_theme.bat_cache.subprocess.run", side_effect=build) as runner,
):
assert ensure_bat_cache(paths) == "rebuilt"
assert ensure_bat_cache(paths) == "unchanged"
assert runner.call_count == 1
os.utime(source, ns=(1, (cache / "themes.bin").stat().st_mtime_ns + 1000000))
assert ensure_bat_cache(paths) == "rebuilt"
assert runner.call_count == 2
(cache / "syntaxes.bin").unlink()
assert ensure_bat_cache(paths) == "rebuilt"
assert runner.call_count == 3


def test_optional_absence_and_build_failure(bat_env):
with mock.patch("dreamcoder_theme.bat_cache.shutil.which", return_value=None):
assert ensure_bat_cache(theme_paths()) == "skipped (bat not installed)"
with (
mock.patch("dreamcoder_theme.bat_cache.shutil.which", return_value="bat"),
mock.patch(
"dreamcoder_theme.bat_cache.subprocess.run",
return_value=subprocess.CompletedProcess([], 7, "", "broken syntax"),
),
pytest.raises(BatCacheError, match=r"bat cache build failed.*broken syntax"),
):
ensure_bat_cache(theme_paths())


@pytest.mark.skipif(shutil.which("bat") is None, reason="optional bat not installed")
def test_custom_theme_directory(bat_env, monkeypatch):
monkeypatch.setenv("BAT_THEME_DIR", str(bat_env / "custom-colors"))
paths = theme_paths()
paths.bat_theme_dir.mkdir()
(paths.bat_theme_dir / "Dreamcoder.tmTheme").write_text(codex_tmtheme_content(VARIANTS["dark"]))
assert ensure_bat_cache(paths) == "rebuilt"
assert ensure_bat_cache(paths) == "unchanged"


def test_public_build_failure_is_actionable(tmp_path, monkeypatch):
binary_dir = tmp_path / "bin"
binary_dir.mkdir()
bat = binary_dir / "bat"
bat.write_text(
f"#!{sys.executable}\nimport sys\nsys.stderr.write('broken syntax')\nsys.exit(7)\n"
)
bat.chmod(0o755)
monkeypatch.setenv("PATH", str(binary_dir) + os.pathsep + os.environ["PATH"])
result = run_sync(tmp_path, "light")
assert result.returncode == 1
assert result.stdout == "✓ Generated src/dreamcoder_theme/palette_tokens.py\n"
assert result.stderr == (
"bat cache build failed (exit 7): broken syntax. "
f"Check themes in {tmp_path / 'config/bat/themes'} and cache permissions at "
f"{tmp_path / 'cache/bat'}, then rerun dreamcoder sync.\n"
)
assert not (tmp_path / "cache/bat/themes.bin").exists()
before = {p.name: p.read_bytes() for p in (tmp_path / "config/bat/themes").iterdir()}
repeated = run_sync(tmp_path, "light")
assert (repeated.returncode, repeated.stdout, repeated.stderr) == (
result.returncode,
result.stdout,
result.stderr,
)
assert {p.name: p.read_bytes() for p in (tmp_path / "config/bat/themes").iterdir()} == before
3 changes: 3 additions & 0 deletions tests/test_pi_theme_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ def run_sync(tmp: Path, mode: str = "dark") -> subprocess.CompletedProcess[str]:
{
"HOME": str(tmp / "home"),
"XDG_CACHE_HOME": str(tmp / "cache"),
"BAT_CACHE_PATH": str(tmp / "cache/bat"),
"BAT_CONFIG_PATH": os.devnull,
"BAT_THEME_DIR": str(tmp / "config/bat/themes"),
"GENTLE_SHELL_HOME": str(tmp / "gentle-agent"),
"PI_CODING_AGENT_DIR": str(pi_agent),
"PI_THEME": str(pi_agent / "themes/dreamcoder.json"),
Expand Down
Loading