diff --git a/Makefile b/Makefile index 54febb3e0..4e815c7e0 100644 --- a/Makefile +++ b/Makefile @@ -118,6 +118,7 @@ conductor-selftest: python3 Scripts/test_ci_app_test_runner.py python3 Scripts/test_conductor_output.py python3 Scripts/test_agent_mode_file_tools_benchmark.py + python3 Scripts/test_cache_inventory.py python3 Scripts/test_conductor_lifecycle.py python3 Scripts/test_local_production_installer.py python3 Scripts/test_security_inventory.py diff --git a/Scripts/cache_inventory.py b/Scripts/cache_inventory.py new file mode 100644 index 000000000..5b85981c3 --- /dev/null +++ b/Scripts/cache_inventory.py @@ -0,0 +1,740 @@ +#!/usr/bin/env python3 +"""SwiftPM build cache inventory identity and read-only diagnostics for RepoPrompt CE.""" + +from __future__ import annotations + +import argparse +import contextlib +import dataclasses +import enum +import fcntl +import hashlib +import json +import os +from pathlib import Path +import platform +import re +import shutil +import subprocess +import sys +import time +from typing import Any, Dict, List, Optional, Tuple + +BUILD_CACHE_DIAGNOSTIC_MAX_ROWS = 12 + + +class CacheSource(enum.Enum): + """How the effective scratch path was chosen.""" + + EXACT_ENV = "exact-env" + DEVELOPER_ROOT = "developer-root" + DEFAULT = "default" + + +def repo_hash(repo_root: Path) -> str: + """Stable hash identifying the resolved repository root.""" + return hashlib.sha256(str(repo_root.resolve()).encode("utf-8")).hexdigest() + + +def format_bytes(byte_count: Optional[int]) -> str: + if byte_count is None: + return "n/a" + value = float(max(0, int(byte_count))) + units = ["B", "KiB", "MiB", "GiB", "TiB"] + unit = units[0] + for unit in units: + if value < 1024 or unit == units[-1]: + break + value /= 1024 + if unit == "B": + return f"{int(value)} B" + return f"{value:.1f} {unit}" + + +def directory_size_bytes(path: Path) -> Optional[int]: + try: + if not path.exists(): + return None + if path.is_symlink(): + path = path.resolve(strict=True) + except OSError: + return None + + try: + result = subprocess.run( + ["du", "-sk", str(path)], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=120, + check=False, + ) + if result.returncode == 0: + first = result.stdout.strip().split()[0] + return int(first) * 1024 + except (OSError, subprocess.SubprocessError, ValueError, IndexError): + pass + + total = 0 + stack = [path] + while stack: + current = stack.pop() + try: + with os.scandir(current) as entries: + for entry in entries: + try: + stat_result = entry.stat(follow_symlinks=False) + except OSError: + continue + if entry.is_dir(follow_symlinks=False): + stack.append(Path(entry.path)) + else: + total += stat_result.st_size + except NotADirectoryError: + try: + total += current.stat(follow_symlinks=False).st_size + except OSError: + pass + except OSError: + continue + return total + + +def latest_mtime(path: Path) -> Optional[float]: + try: + return path.stat(follow_symlinks=False).st_mtime + except OSError: + return None + + +def managed_worktree_container(repo_root: Path) -> Optional[Path]: + """Return the .repoprompt-worktrees container that owns repo_root, if any.""" + try: + resolved_root = repo_root.resolve() + except OSError: + resolved_root = repo_root + try: + parent = resolved_root.parent + if parent.parent.name == ".repoprompt-worktrees": + return parent + except (IndexError, OSError): + return None + return None + + +def managed_worktree_repo_roots(repo_root: Path) -> List[Path]: + """All real repo roots under the same managed worktree container, including current. + + Symlinked children are resolved and only included if they point inside the + container, so stale or outside links do not get treated as managed worktrees. + """ + try: + resolved_root = repo_root.resolve() + except OSError: + resolved_root = repo_root + container = managed_worktree_container(resolved_root) + if container is None or not container.exists(): + return [resolved_root] + try: + resolved_container = container.resolve() + except OSError: + resolved_container = container + + roots: List[Path] = [] + for child in sorted(resolved_container.iterdir()): + if not child.is_dir(): + continue + try: + resolved_child = child.resolve(strict=True) + except OSError: + continue + if not resolved_child.is_dir(): + continue + if not is_path_within(resolved_child, resolved_container): + continue + if (resolved_child / ".build").exists() or (resolved_child / "Package.swift").exists(): + roots.append(resolved_child) + if resolved_root not in roots: + roots.append(resolved_root) + return roots + + +def _toolchain_id(env: Dict[str, str]) -> str: + inputs = "|".join( + env.get(k, "") for k in ("DEVELOPER_DIR", "TOOLCHAINS", "SWIFT_EXEC") + ) + if not inputs.strip("|"): + return "default" + return hashlib.sha256(inputs.encode("utf-8")).hexdigest()[:8] + + +def _macos_version() -> Optional[str]: + # Prefer the in-process platform value. Besides avoiding a needless process + # launch on every cache-identity lookup, this keeps the identity resolver + # independent from conductor's subprocess transport in lifecycle tests. + try: + version = platform.mac_ver()[0] + if version: + return ".".join(version.split(".")[:2]) + except Exception: + pass + try: + result = subprocess.run( + ["sw_vers", "-productVersion"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + version = result.stdout.strip() + if version: + return ".".join(version.split(".")[:2]) + except (OSError, subprocess.SubprocessError): + pass + return None + + +def _destination_id(env: Optional[Dict[str, str]] = None) -> str: + if env is None: + env = dict(os.environ) + target_triple = env.get("SWIFT_TARGET_TRIPLE") + if target_triple: + return target_triple + machine = platform.machine().lower() or "unknown" + system = platform.system() + if system == "Darwin": + version = _macos_version() + if version: + return f"{machine}-apple-macosx{version}" + return f"{machine}-apple-macosx" + if system == "Linux": + return f"{machine}-unknown-linux-gnu" + return f"{machine}-unknown-{system.lower()}" + + +def _safe_name(name: str) -> str: + return re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("._") or "repo" + + +@dataclasses.dataclass(frozen=True) +class SwiftPMCacheIdentity: + """The canonical identity of a SwiftPM build cache for a repo/worktree + config.""" + + repo_root: Path + repo_hash: str + worktree_name: str + toolchain_id: str + destination_id: str + configuration: str + source: CacheSource + effective_path: Path + exact_path: Optional[str] = None + developer_root: Optional[str] = None + + def key(self) -> str: + parts = [ + self.repo_hash[:8], + self.worktree_name, + self.destination_short(), + self.configuration, + ] + if self.toolchain_id != "default": + parts.append(self.toolchain_id) + return "-".join(parts) + + def destination_short(self) -> str: + return self.destination_id.replace("-", "_") + + def is_default(self) -> bool: + return self.source == CacheSource.DEFAULT + + def describe(self) -> str: + parts = [f"source={self.source.value}", f"key={self.key()}"] + if self.exact_path: + parts.append(f"exact_path={self.exact_path}") + if self.developer_root: + parts.append(f"developer_root={self.developer_root}") + return " ".join(parts) + + +def resolve_swiftpm_cache_identity( + repo_root: Path, configuration: str, env: Optional[Dict[str, str]] = None +) -> SwiftPMCacheIdentity: + """Determine the authoritative SwiftPM scratch path and identity for a repo root. + + Resolution order: + 1. REPOPROMPT_SWIFTPM_SCRATCH_PATH (exact path, highest priority) + 2. REPOPROMPT_DEVELOPER_SWIFTPM_SCRATCH_ROOT (deterministic keyed subdirectory) + 3. /.build (default) + """ + if env is None: + env = dict(os.environ) + configuration = str(configuration).lower() + resolved_root = repo_root.resolve() + worktree_name = _safe_name(resolved_root.name) + rhash = repo_hash(resolved_root) + toolchain = _toolchain_id(env) + destination = _destination_id(env) + + exact_path = env.get("REPOPROMPT_SWIFTPM_SCRATCH_PATH") + if exact_path: + try: + effective_path = Path(exact_path).expanduser().resolve(strict=False) + except (OSError, RuntimeError): + effective_path = Path(exact_path).expanduser().absolute() + return SwiftPMCacheIdentity( + repo_root=resolved_root, + repo_hash=rhash, + worktree_name=worktree_name, + toolchain_id=toolchain, + destination_id=destination, + configuration=configuration, + source=CacheSource.EXACT_ENV, + effective_path=effective_path, + exact_path=str(Path(exact_path).expanduser()), + ) + + developer_root = env.get("REPOPROMPT_DEVELOPER_SWIFTPM_SCRATCH_ROOT") + if developer_root: + try: + base = Path(developer_root).expanduser().resolve() + except (OSError, RuntimeError): + base = Path(developer_root).expanduser().absolute() + identity = SwiftPMCacheIdentity( + repo_root=resolved_root, + repo_hash=rhash, + worktree_name=worktree_name, + toolchain_id=toolchain, + destination_id=destination, + configuration=configuration, + source=CacheSource.DEVELOPER_ROOT, + effective_path=base, + developer_root=str(base), + ) + identity = dataclasses.replace(identity, effective_path=base / identity.key()) + return identity + + return SwiftPMCacheIdentity( + repo_root=resolved_root, + repo_hash=rhash, + worktree_name=worktree_name, + toolchain_id=toolchain, + destination_id=destination, + configuration=configuration, + source=CacheSource.DEFAULT, + effective_path=resolved_root / ".build", + ) + + +def is_path_within(path: Path, root: Path) -> bool: + """Return True if path is the same as or under root, resolving symlinks carefully.""" + try: + resolved_path = path.resolve() + resolved_root = root.resolve() + return resolved_path == resolved_root or resolved_root in resolved_path.parents + except OSError: + return False + + +def is_active_swiftpm_scratch(path: Path) -> bool: + """True if another process appears to be actively using this scratch directory.""" + lock_path = path / ".lock" + if not lock_path.exists(): + return False + try: + with lock_path.open("r+") as lock_file: + fd = lock_file.fileno() + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + fcntl.flock(fd, fcntl.LOCK_UN) + return False + except (OSError, IOError): + return True + + +def debug_app_bundle_path() -> Path: + root = os.environ.get( + "REPOPROMPT_DEBUG_APP_ROOT", + str(Path.home() / "Library" / "Application Support" / "RepoPrompt CE" / "DebugApps"), + ) + return Path(os.environ.get("REPOPROMPT_DEBUG_APP_BUNDLE", str(Path(root) / "RepoPrompt.app"))) + + +def _debug_app_provenance_path(bundle: Path) -> Path: + return bundle / "Contents" / "Resources" / "RepoPromptDebugProvenance.json" + + +def _read_debug_app_provenance(bundle: Path) -> Optional[Dict[str, Any]]: + try: + return json.loads(_debug_app_provenance_path(bundle).read_text(encoding="utf-8")) + except (FileNotFoundError, OSError, json.JSONDecodeError): + return None + + +def is_live_bound_to_path(path: Path, repo_root: Path) -> bool: + """True if the running debug app provenance points to this repo root/worktree.""" + provenance = _read_debug_app_provenance(debug_app_bundle_path()) + if not provenance: + return False + provenance_root = provenance.get("repoRoot") or provenance.get("worktreePath") + if not provenance_root: + return False + try: + return Path(provenance_root).resolve() == repo_root.resolve() + except OSError: + return False + + +def is_dirty_worktree(worktree_root: Path) -> bool: + try: + result = subprocess.run( + ["git", "-C", str(worktree_root), "status", "--porcelain"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=10, + ) + return result.returncode == 0 and bool(result.stdout.strip()) + except (OSError, subprocess.SubprocessError): + return False + + +def _list_developer_root_candidates( + repo_root: Path, developer_root: Optional[str], configuration: str +) -> List[Path]: + if not developer_root: + return [] + try: + base = Path(developer_root).expanduser().resolve() + except (OSError, RuntimeError): + return [] + if not base.exists() or not base.is_dir(): + return [] + rhash = repo_hash(repo_root) + candidates: List[Path] = [] + for child in base.iterdir(): + if not child.is_dir(): + continue + if child.name.startswith(rhash[:8]): + candidates.append(child) + return candidates + + +def operation_diagnostics_build_cache(repo_root: Path, args: Dict[str, Any]) -> int: + limit = int(args.get("limit") or BUILD_CACHE_DIAGNOSTIC_MAX_ROWS) + limit = max(1, min(limit, 100)) + env = args.get("env") or dict(os.environ) + configuration = str(args.get("configuration") or env.get("REPOPROMPT_PACKAGE_CONFIGURATION") or "debug") + identity = resolve_swiftpm_cache_identity(repo_root, configuration, env) + + print("Build cache diagnostics", flush=True) + print(f"Cache identity: {identity.describe()}", flush=True) + current_build = repo_root / ".build" + if current_build.exists(): + symlink_note = "" + if current_build.is_symlink(): + with contextlib.suppress(OSError): + symlink_note = f" -> {current_build.resolve(strict=True)}" + print(f"Current .build: {format_bytes(directory_size_bytes(current_build))}{symlink_note}", flush=True) + else: + print("Current .build: missing", flush=True) + + effective = identity.effective_path + if effective != current_build and effective.exists(): + print( + f"Effective scratch path: {effective} ({format_bytes(directory_size_bytes(effective))})", + flush=True, + ) + + container = managed_worktree_container(repo_root) + if container is None or not container.exists(): + print("Managed worktree container: not detected", flush=True) + return 0 + + rows: List[Tuple[int, Optional[float], str, Path]] = [] + for child in sorted(container.iterdir(), key=lambda item: item.name): + if not child.is_dir(): + continue + try: + resolved_child = child.resolve(strict=True) + except OSError: + continue + if not resolved_child.is_dir() or not is_path_within(resolved_child, container): + continue + worktree_identity = resolve_swiftpm_cache_identity(resolved_child, configuration, env) + build_dir = worktree_identity.effective_path + size = directory_size_bytes(build_dir) + if size is None: + continue + rows.append((size, latest_mtime(build_dir), resolved_child.name, build_dir)) + + if identity.developer_root: + for candidate in _list_developer_root_candidates( + repo_root, identity.developer_root, configuration + ): + if candidate in {r[3] for r in rows}: + continue + size = directory_size_bytes(candidate) + if size is None: + continue + rows.append((size, latest_mtime(candidate), candidate.name, candidate)) + + total = sum(size for size, _mtime, _name, _path in rows) + print(f"Managed worktree container: {container}", flush=True) + print(f"Worktree .build total: {format_bytes(total)} across {len(rows)} build director{'y' if len(rows) == 1 else 'ies'}", flush=True) + if not rows: + return 0 + + print("Top .build directories:", flush=True) + for size, mtime, name, path in sorted(rows, key=lambda row: row[0], reverse=True)[:limit]: + mtime_text = "unknown" if mtime is None else time.strftime("%Y-%m-%d %H:%M", time.localtime(mtime)) + print(f" {format_bytes(size):>9} {name} {path} modified={mtime_text}", flush=True) + return 0 + + +@dataclasses.dataclass +class BuildCacheEntry: + path: Path + repo_root: Path + identity: SwiftPMCacheIdentity + size_bytes: Optional[int] + mtime: Optional[float] + current: bool + skip_reasons: List[str] + + @property + def eligible(self) -> bool: + return not self.skip_reasons + + +@dataclasses.dataclass +class BuildCacheCleanupPlan: + entries: List[BuildCacheEntry] + apply: bool + dry_run: bool + confirmed: bool + + @property + def eligible_entries(self) -> List[BuildCacheEntry]: + return [e for e in self.entries if e.eligible] + + @property + def total_size_bytes(self) -> int: + return sum(e.size_bytes or 0 for e in self.eligible_entries) + + def to_text(self) -> str: + lines: List[str] = [] + lines.append("Build cache cleanup plan") + lines.append(f"dry_run={self.dry_run} apply={self.apply} confirmed={self.confirmed}") + if not self.eligible_entries: + lines.append("No eligible cache directories to remove.") + else: + lines.append(f"Eligible directories ({len(self.eligible_entries)} entries, {format_bytes(self.total_size_bytes)}):") + for e in self.eligible_entries: + mtime_text = "unknown" if e.mtime is None else time.strftime("%Y-%m-%d %H:%M", time.localtime(e.mtime)) + lines.append(f" {format_bytes(e.size_bytes):>9} {e.path} modified={mtime_text}") + skipped = [e for e in self.entries if not e.eligible] + if skipped: + lines.append(f"Skipped directories ({len(skipped)}):") + for e in skipped: + reasons = ", ".join(e.skip_reasons) + lines.append(f" {format_bytes(e.size_bytes):>9} {e.path} reasons={reasons}") + return "\n".join(lines) + + +def plan_build_cache_cleanup( + repo_root: Path, + *, + dry_run: bool = True, + apply: bool = False, + confirm: bool = False, + limit: Optional[int] = None, + env: Optional[Dict[str, str]] = None, +) -> BuildCacheCleanupPlan: + """Build a conservative, safety-checked plan for cache cleanup. + + By default the plan is dry-run. Actual deletion requires both apply=True and + confirm=True, and the caller must ensure the current .build is never removed. + """ + if env is None: + env = dict(os.environ) + resolved_root = repo_root.resolve() + repo_roots = managed_worktree_repo_roots(resolved_root) + entries: List[BuildCacheEntry] = [] + seen_paths: set[Path] = set() + + for config in ("debug", "release"): + identity = resolve_swiftpm_cache_identity(resolved_root, config, env) + configuration = identity.configuration + + def add_entry(path: Path, worktree_root: Path, current: bool) -> None: + path = path.expanduser().absolute() + is_symlink = path.is_symlink() + if not is_symlink: + try: + path = path.resolve() + except OSError: + pass + if path in seen_paths: + return + seen_paths.add(path) + if not path.exists() and not is_symlink: + return + + skip_reasons: List[str] = [] + if current: + skip_reasons.append("current") + roots: List[Path] = list(repo_roots) + if identity.developer_root: + roots.append(Path(identity.developer_root)) + if not any(is_path_within(path, r) for r in roots): + skip_reasons.append("out-of-scope") + if is_symlink: + skip_reasons.append("symlink") + if not is_symlink and is_active_swiftpm_scratch(path): + skip_reasons.append("active-lock") + if is_live_bound_to_path(path, worktree_root): + skip_reasons.append("live-bound") + if is_dirty_worktree(worktree_root): + skip_reasons.append("dirty-worktree") + + size = 0 if is_symlink else directory_size_bytes(path) + mtime = path.lstat().st_mtime if is_symlink else latest_mtime(path) + entry_identity = resolve_swiftpm_cache_identity(worktree_root, configuration, env) + entries.append( + BuildCacheEntry( + path=path, + repo_root=worktree_root, + identity=entry_identity, + size_bytes=size, + mtime=mtime, + current=current, + skip_reasons=skip_reasons, + ) + ) + + for worktree_root in repo_roots: + worktree_identity = resolve_swiftpm_cache_identity(worktree_root, configuration, env) + add_entry(worktree_identity.effective_path, worktree_root, worktree_root == resolved_root) + legacy_build = worktree_root / ".build" + if legacy_build != worktree_identity.effective_path and legacy_build.exists(): + add_entry(legacy_build, worktree_root, worktree_root == resolved_root) + + if identity.developer_root: + for candidate in _list_developer_root_candidates(resolved_root, identity.developer_root, configuration): + if candidate in seen_paths: + continue + add_entry(candidate, resolved_root, current=False) + + entries.sort(key=lambda e: (not e.skip_reasons, e.current, e.path.name), reverse=True) + if limit is not None: + entries = entries[:limit] + return BuildCacheCleanupPlan(entries=entries, apply=apply, dry_run=dry_run, confirmed=confirm) + + +def execute_build_cache_cleanup(plan: BuildCacheCleanupPlan) -> int: + """Execute a prepared cleanup plan. Returns 0 on success, 1 on failure.""" + if not plan.apply: + print(plan.to_text()) + return 0 + if not plan.confirmed: + print("ERROR: cache cleanup --apply requires --confirm", file=sys.stderr) + return 1 + if not plan.eligible_entries: + print(plan.to_text()) + return 0 + + failed = 0 + for entry in plan.eligible_entries: + try: + if not entry.path.exists() or entry.path.is_symlink(): + print(f"Skipping {entry.path}: no longer present or is a symlink", flush=True) + continue + if is_active_swiftpm_scratch(entry.path): + print(f"Skipping {entry.path}: active lock acquired since plan", flush=True) + continue + if is_live_bound_to_path(entry.path, entry.repo_root): + print(f"Skipping {entry.path}: became live-bound since plan", flush=True) + continue + scope_roots: List[Path] = [entry.repo_root] + if entry.identity.developer_root: + scope_roots.append(Path(entry.identity.developer_root).expanduser().resolve()) + if entry.identity.exact_path: + scope_roots.append(Path(entry.identity.exact_path).expanduser().resolve()) + if not any(is_path_within(entry.path, r) for r in scope_roots): + print(f"Skipping {entry.path}: out of scope since plan", flush=True) + continue + if is_dirty_worktree(entry.repo_root): + print(f"Skipping {entry.path}: worktree became dirty since plan", flush=True) + continue + print(f"Removing {entry.path}", flush=True) + shutil.rmtree(entry.path) + except OSError as exc: + print(f"ERROR: failed to remove {entry.path}: {exc}", file=sys.stderr) + failed += 1 + if failed: + return 1 + print(f"Removed {len(plan.eligible_entries)} cache directories ({format_bytes(plan.total_size_bytes)}).") + return 0 + + +def operation_cache_cleanup(repo_root: Path, args: Dict[str, Any]) -> int: + env = args.get("env") or dict(os.environ) + dry_run = not bool(args.get("apply")) + apply = bool(args.get("apply")) + confirm = bool(args.get("confirm")) + limit = args.get("limit") + if limit is not None: + limit = max(1, int(limit)) + plan = plan_build_cache_cleanup( + repo_root, + dry_run=dry_run, + apply=apply, + confirm=confirm, + limit=limit, + env=env, + ) + if not apply: + print(plan.to_text()) + return 0 + return execute_build_cache_cleanup(plan) + + +def _command_line_path() -> int: + parser = argparse.ArgumentParser(prog="cache_inventory.py") + parser.add_argument("--repo-root", required=True, type=Path) + parser.add_argument("--configuration", default="debug") + parser.add_argument("--format", choices=["path", "json", "key", "identity"], default="path") + parser.add_argument("--cleanup-plan", action="store_true") + parser.add_argument("--apply", action="store_true") + parser.add_argument("--confirm", action="store_true") + parser.add_argument("--limit", type=int, default=None) + ns = parser.parse_args() + + if ns.cleanup_plan: + plan = plan_build_cache_cleanup( + ns.repo_root, + dry_run=not ns.apply, + apply=ns.apply, + confirm=ns.confirm, + limit=ns.limit, + ) + print(plan.to_text()) + return execute_build_cache_cleanup(plan) if ns.apply else 0 + + identity = resolve_swiftpm_cache_identity(ns.repo_root, ns.configuration) + if ns.format == "path": + print(identity.effective_path) + elif ns.format == "key": + print(identity.key()) + elif ns.format == "identity": + print(identity.describe()) + else: + print(json.dumps(dataclasses.asdict(identity), default=str, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(_command_line_path()) + except KeyboardInterrupt: + raise SystemExit(130) diff --git a/Scripts/conductor.py b/Scripts/conductor.py index e599407b7..bb2584ab3 100755 --- a/Scripts/conductor.py +++ b/Scripts/conductor.py @@ -36,11 +36,17 @@ from debug_app_process import ProcessIdentityError, matching_processes, terminate_matching_processes +import cache_inventory +from cache_inventory import ( + BUILD_CACHE_DIAGNOSTIC_MAX_ROWS, + operation_cache_cleanup, + operation_diagnostics_build_cache, +) + PROTOCOL_VERSION = 10 TERMINAL_STATES = {"completed", "failed", "canceled"} LANE_NAMES = {"build", "debugArtifact", "liveApp", "release", "style"} LOG_TAIL_LINES = 30 -BUILD_CACHE_DIAGNOSTIC_MAX_ROWS = 12 SUMMARY_VERSION = 1 SUMMARY_SUCCESS_MAX_LINES = 25 SUMMARY_FAILURE_MAX_LINES = 100 @@ -99,6 +105,7 @@ "app", "smoke", "diagnostics", + "cache", "release", } @@ -148,6 +155,7 @@ (without --launch/--packaged-app, requires the CE debug app to already be running and CLI installed) ./conductor diagnostics agent-mode-on [--log-file ] ./conductor diagnostics build-cache [--limit ] + ./conductor cache cleanup [--dry-run|--apply --confirm] [--limit ] ./conductor release preflight|artifact|package|local-install Foundation validation operation: @@ -1079,21 +1087,6 @@ def format_duration(seconds: Optional[float]) -> str: return f"{int(hours)}h {int(minutes)}m {remainder:.0f}s" -def format_bytes(byte_count: Optional[int]) -> str: - if byte_count is None: - return "n/a" - value = float(max(0, int(byte_count))) - units = ["B", "KiB", "MiB", "GiB", "TiB"] - unit = units[0] - for unit in units: - if value < 1024 or unit == units[-1]: - break - value /= 1024 - if unit == "B": - return f"{int(value)} B" - return f"{value:.1f} {unit}" - - @dataclasses.dataclass class Job: ticket: str @@ -1241,6 +1234,9 @@ class OperationRegistry: "LANG", "LC_ALL", "LC_CTYPE", + "REPOPROMPT_DEVELOPER_SWIFTPM_SCRATCH_ROOT", + "REPOPROMPT_SWIFTPM_SCRATCH_PATH", + "SWIFT_TARGET_TRIPLE", ] STYLE_ENV_KEYS = [ "GITHUB_ACTIONS", @@ -1283,6 +1279,15 @@ def __init__(self, repo_root: Path) -> None: self.repo_root = repo_root self.script_path = Path(__file__).resolve() + def _build_cache_identity(self, configuration: str, env: Dict[str, str]) -> cache_inventory.SwiftPMCacheIdentity: + return cache_inventory.resolve_swiftpm_cache_identity(self.repo_root, configuration, env) + + def _default_build_cache_path(self) -> Path: + # Cache identities are canonicalized by cache_inventory. Canonicalize + # the default as well so macOS's /var -> /private/var alias does not + # turn the ordinary in-tree cache into an explicit scratch override. + return self.repo_root.resolve() / ".build" + @classmethod def client_env_snapshot(cls) -> Dict[str, str]: snapshot: Dict[str, str] = {} @@ -1351,17 +1356,29 @@ def prepare(self, request: Dict[str, Any]) -> Tuple[List[str], List[str], Path, if operation == "swift-build": product = args.get("product") lanes = ["build"] + identity = self._build_cache_identity("debug", env) + scratch_path = str(identity.effective_path) + default_path = str(self._default_build_cache_path()) if product == "all": - return self._internal_argv("swift_build_all", {}), lanes, cwd, env, effective_timeout - return ["swift", "build", "--product", str(product)], lanes, cwd, env, effective_timeout + return self._internal_argv("swift_build_all", {"scratchPath": scratch_path}), lanes, cwd, env, effective_timeout + if scratch_path == default_path: + return ["swift", "build", "--product", str(product)], lanes, cwd, env, effective_timeout + return ["swift", "build", "--scratch-path", scratch_path, "--product", str(product)], lanes, cwd, env, effective_timeout if operation == "build": + identity = self._build_cache_identity("debug", env) + env["REPOPROMPT_SWIFTPM_SCRATCH_PATH"] = str(identity.effective_path) return [script("package_app.sh"), "debug"], ["build", "debugArtifact"], cwd, env, effective_timeout if operation == "package": config = str(args.get("config")) lanes = ["build", "debugArtifact"] + (["release"] if config == "release" else []) + identity = self._build_cache_identity(config, env) + env["REPOPROMPT_SWIFTPM_SCRATCH_PATH"] = str(identity.effective_path) return [script("package_app.sh"), config], lanes, cwd, env, effective_timeout if operation == "test": argv = ["swift", "test"] + identity = self._build_cache_identity("debug", env) + if str(identity.effective_path) != str(self._default_build_cache_path()): + argv.extend(["--scratch-path", str(identity.effective_path)]) if args.get("testProduct"): argv.extend(["--test-product", str(args["testProduct"])]) if args.get("list"): @@ -1371,6 +1388,9 @@ def prepare(self, request: Dict[str, Any]) -> Tuple[List[str], List[str], Path, return argv, ["build"], cwd, env, effective_timeout if operation == "provider-test": argv = ["swift", "test"] + identity = self._build_cache_identity("debug", env) + if str(identity.effective_path) != str(self._default_build_cache_path()): + argv.extend(["--scratch-path", str(identity.effective_path)]) if args.get("testProduct"): argv.extend(["--test-product", str(args["testProduct"])]) if args.get("list"): @@ -1379,10 +1399,14 @@ def prepare(self, request: Dict[str, Any]) -> Tuple[List[str], List[str], Path, argv.extend(["--filter", str(args["filter"])]) return argv, ["build"], self.repo_root / "Packages" / "RepoPromptAgentProviders", env, effective_timeout if operation == "install-debug-cli": + identity = self._build_cache_identity("debug", env) + env["REPOPROMPT_SWIFTPM_SCRATCH_PATH"] = str(identity.effective_path) return [script("install_debug_cli.sh"), "install", "--build"], ["build", "debugArtifact"], cwd, env, effective_timeout if operation == "debug-cli-status": return [script("install_debug_cli.sh"), "status"], lanes, cwd, env, effective_timeout if operation == "run": + identity = self._build_cache_identity("debug", env) + env["REPOPROMPT_SWIFTPM_SCRATCH_PATH"] = str(identity.effective_path) return self._internal_argv("debug_app_build_then_launch", dict(args)), ["liveApp"], cwd, env, effective_timeout if operation == "app": subcommand = args.get("subcommand") @@ -1394,6 +1418,8 @@ def prepare(self, request: Dict[str, Any]) -> Tuple[List[str], List[str], Path, if subcommand == "launch-existing": return self._internal_argv("app_launch_existing", dict(args)), ["liveApp"], cwd, env, effective_timeout if subcommand == "relaunch": + identity = self._build_cache_identity("debug", env) + env["REPOPROMPT_SWIFTPM_SCRATCH_PATH"] = str(identity.effective_path) return self._internal_argv("debug_app_build_then_launch", dict(args)), ["liveApp"], cwd, env, effective_timeout if operation == "smoke": lanes = ["debugArtifact", "liveApp"] @@ -1410,13 +1436,23 @@ def prepare(self, request: Dict[str, Any]) -> Tuple[List[str], List[str], Path, return self._internal_argv("diagnostics_agent_mode_on", dict(args)), ["debugArtifact", "liveApp"], cwd, env, effective_timeout if subcommand == "build-cache": return self._internal_argv("diagnostics_build_cache", dict(args)), lanes, cwd, env, effective_timeout + if operation == "cache": + subcommand = args.get("subcommand") + if subcommand == "cleanup": + return self._internal_argv("cache_cleanup", dict(args)), lanes, cwd, env, effective_timeout if operation == "release": subcommand = args.get("subcommand") if subcommand == "package": + identity = self._build_cache_identity("release", env) + env["REPOPROMPT_SWIFTPM_SCRATCH_PATH"] = str(identity.effective_path) return [script("package_app.sh"), "release"], ["build", "debugArtifact", "release"], cwd, env, effective_timeout if subcommand == "local-install": + identity = self._build_cache_identity("release", env) + env["REPOPROMPT_SWIFTPM_SCRATCH_PATH"] = str(identity.effective_path) return [script("install_local_production.sh")], ["build", "debugArtifact", "release"], cwd, env, effective_timeout if subcommand == "artifact": + identity = self._build_cache_identity("release", env) + env["REPOPROMPT_SWIFTPM_SCRATCH_PATH"] = str(identity.effective_path) return [script("release.sh"), "artifact"], ["build", "debugArtifact", "release"], cwd, env, effective_timeout if subcommand == "preflight": release_script = self.repo_root / "Scripts" / "release.sh" @@ -1497,7 +1533,7 @@ def _default_timeout(self, operation: Any, args: Dict[str, Any]) -> float: return RELEASE_TIMEOUT_SECONDS if operation == "smoke" and args.get("agentRun"): return MEDIUM_TIMEOUT_SECONDS - if operation == "diagnostics": + if operation in {"diagnostics", "cache"}: return SHORT_TIMEOUT_SECONDS return MEDIUM_TIMEOUT_SECONDS @@ -4227,9 +4263,19 @@ def operation_app_stop(repo_root: Path, args: Dict[str, Any]) -> int: return _operation_app_stop_unlocked(repo_root, args) -def operation_swift_build_all(repo_root: Path) -> int: +def operation_swift_build_all(repo_root: Path, args: Dict[str, Any]) -> int: + scratch_path = args.get("scratchPath") + default_path = str(repo_root.resolve() / ".build") + if scratch_path and scratch_path != default_path: + base_argv = ["swift", "build", "--scratch-path", scratch_path] + else: + base_argv = ["swift", "build"] for product in ["RepoPrompt", "repoprompt-mcp"]: - code, _stdout, _stderr = run_operation_command(f"swift build --product {product}", ["swift", "build", "--product", product], repo_root) + code, _stdout, _stderr = run_operation_command( + f"swift build --product {product}", + base_argv + ["--product", product], + repo_root, + ) if code != 0: return code return 0 @@ -4354,119 +4400,6 @@ def operation_smoke(repo_root: Path, args: Dict[str, Any]) -> int: return 0 -def directory_size_bytes(path: Path) -> Optional[int]: - try: - if not path.exists(): - return None - if path.is_symlink(): - path = path.resolve(strict=True) - except OSError: - return None - - # Prefer the platform disk-usage tool for explicit cache diagnostics. It is - # read-only and much faster than Python-level recursive stat walks for large - # SwiftPM scratch directories. Fall back to a Python walk for small tests or - # unusual environments where `du` is unavailable. - try: - result = subprocess.run( - ["du", "-sk", str(path)], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - text=True, - timeout=120, - check=False, - ) - if result.returncode == 0: - first = result.stdout.strip().split()[0] - return int(first) * 1024 - except (OSError, subprocess.SubprocessError, ValueError, IndexError): - pass - - total = 0 - stack = [path] - while stack: - current = stack.pop() - try: - with os.scandir(current) as entries: - for entry in entries: - try: - stat_result = entry.stat(follow_symlinks=False) - except OSError: - continue - if entry.is_dir(follow_symlinks=False): - stack.append(Path(entry.path)) - else: - total += stat_result.st_size - except NotADirectoryError: - try: - total += current.stat(follow_symlinks=False).st_size - except OSError: - pass - except OSError: - continue - return total - - -def latest_mtime(path: Path) -> Optional[float]: - try: - return path.stat(follow_symlinks=False).st_mtime - except OSError: - return None - - -def managed_worktree_container(repo_root: Path) -> Optional[Path]: - parent = repo_root.parent - try: - if parent.parent.name == ".repoprompt-worktrees": - return parent - except IndexError: - return None - return None - - -def operation_diagnostics_build_cache(repo_root: Path, args: Dict[str, Any]) -> int: - limit = int(args.get("limit") or BUILD_CACHE_DIAGNOSTIC_MAX_ROWS) - limit = max(1, min(limit, 100)) - current_build = repo_root / ".build" - - print("Build cache diagnostics", flush=True) - if current_build.exists(): - symlink_note = "" - if current_build.is_symlink(): - with contextlib.suppress(OSError): - symlink_note = f" -> {current_build.resolve(strict=True)}" - print(f"Current .build: {format_bytes(directory_size_bytes(current_build))}{symlink_note}", flush=True) - else: - print("Current .build: missing", flush=True) - - container = managed_worktree_container(repo_root) - if container is None or not container.exists(): - print("Managed worktree container: not detected", flush=True) - return 0 - - rows: List[Tuple[int, Optional[float], str]] = [] - for child in sorted(container.iterdir(), key=lambda item: item.name): - if not child.is_dir(): - continue - build_dir = child / ".build" - size = directory_size_bytes(build_dir) - if size is None: - continue - rows.append((size, latest_mtime(build_dir), child.name)) - - total = sum(size for size, _mtime, _name in rows) - print(f"Managed worktree container: {container}", flush=True) - print(f"Worktree .build total: {format_bytes(total)} across {len(rows)} build director{'y' if len(rows) == 1 else 'ies'}", flush=True) - if not rows: - return 0 - - print("Top .build directories:", flush=True) - for size, mtime, name in sorted(rows, key=lambda row: row[0], reverse=True)[:limit]: - mtime_text = "unknown" if mtime is None else time.strftime("%Y-%m-%d %H:%M", time.localtime(mtime)) - print(f" {format_bytes(size):>9} {name} modified={mtime_text}", flush=True) - return 0 - - def operation_diagnostics_agent_mode_on(repo_root: Path, args: Dict[str, Any]) -> int: cli = require_debug_cli() if not cli: @@ -4497,7 +4430,7 @@ def run_operation_runner(payload_json: str) -> int: args = payload.get("args") or {} repo_root = Path(payload.get("repoRoot") or resolve_repo_root()).resolve() if kind == "swift_build_all": - return operation_swift_build_all(repo_root) + return operation_swift_build_all(repo_root, args) if kind == "app_stop": return operation_app_stop(repo_root, args) if kind == "app_status": @@ -4512,6 +4445,8 @@ def run_operation_runner(payload_json: str) -> int: return operation_diagnostics_agent_mode_on(repo_root, args) if kind == "diagnostics_build_cache": return operation_diagnostics_build_cache(repo_root, args) + if kind == "cache_cleanup": + return operation_cache_cleanup(repo_root, args) if kind == "release_preflight_missing": return operation_release_preflight_missing(repo_root) print(f"unknown internal operation runner kind: {kind}", file=sys.stderr) @@ -4690,6 +4625,21 @@ def handle_real_operation(paths: Paths, operation: str, argv: List[str]) -> int: if ns.limit <= 0: raise ConductorError("diagnostics build-cache --limit must be greater than zero") args["limit"] = ns.limit + elif operation == "cache": + parser = argparse.ArgumentParser(prog="conductor cache") + subparsers = parser.add_subparsers(dest="subcommand", required=True) + cleanup = subparsers.add_parser("cleanup") + cleanup.add_argument("--apply", action="store_true") + cleanup.add_argument("--confirm", action="store_true") + cleanup.add_argument("--limit", type=int, default=None) + ns = parser.parse_args(rest) + args["subcommand"] = ns.subcommand + if ns.subcommand == "cleanup": + if ns.limit is not None and ns.limit <= 0: + raise ConductorError("cache cleanup --limit must be greater than zero") + args["apply"] = ns.apply + args["confirm"] = ns.confirm + args["limit"] = ns.limit elif operation == "release": parser = argparse.ArgumentParser(prog="conductor release") parser.add_argument("subcommand", choices=["preflight", "artifact", "package", "local-install"]) diff --git a/Scripts/install_local_production.sh b/Scripts/install_local_production.sh index 3551661fc..3e6d8d51e 100755 --- a/Scripts/install_local_production.sh +++ b/Scripts/install_local_production.sh @@ -279,9 +279,9 @@ LOCAL_SELF_SIGNED_RELEASE=1 \ SIGN_IDENTITY="$SIGN_IDENTITY" \ "$ROOT_DIR/Scripts/package_app.sh" release -BUILD_DIR="$(swift build -c release --show-bin-path)" -SOURCE_APP="$BUILD_DIR/$APP_NAME.app" -[[ -d "$SOURCE_APP" ]] || fail "Missing packaged local production app: $SOURCE_APP" +# Source app is the package_app compatibility symlink/path under .build/release. +SOURCE_APP="$(python3 -c "import os,sys; print(os.path.realpath(sys.argv[1]))" "$ROOT_DIR/.build/release/$APP_NAME.app")" +[[ -n "$SOURCE_APP" && -d "$SOURCE_APP" ]] || fail "Missing packaged local production app: $ROOT_DIR/.build/release/$APP_NAME.app" [[ "$(plutil -extract RepoPromptSigningMode raw "$SOURCE_APP/Contents/Info.plist")" == "local-self-signed" ]] || fail "Packaged app is missing the local self-signed signing-mode marker." [[ "$(plutil -extract RepoPromptLocalSigningCertificateSHA256 raw "$SOURCE_APP/Contents/Info.plist")" == "$SELECTED_CERTIFICATE_SHA256" ]] || diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index ada8d754b..41d95bf52 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -5,6 +5,8 @@ CONF="${1:-debug}" ROOT_DIR="${REPOPROMPT_RELEASE_SOURCE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" CONTROL_PLANE_SCRIPTS_DIR="${REPOPROMPT_CONTROL_PLANE_SCRIPTS_DIR:-$ROOT_DIR/Scripts}" RUN_WITHOUT_GITHUB_TOKENS="$CONTROL_PLANE_SCRIPTS_DIR/run_without_github_tokens.sh" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CACHE_INVENTORY_SCRIPT="$SCRIPT_DIR/cache_inventory.py" cd "$ROOT_DIR" [[ "${VERBOSE:-0}" == "1" || "${VERBOSE:-0}" == "true" ]] && set -x @@ -67,6 +69,16 @@ except OSError: print("1" if left_resolved == right_resolved else "0") PY } +resolve_swiftpm_scratch_path(){ + if [[ -n "${REPOPROMPT_SWIFTPM_SCRATCH_PATH:-}" ]]; then + SWIFTPM_SCRATCH_PATH="$REPOPROMPT_SWIFTPM_SCRATCH_PATH" + else + SWIFTPM_SCRATCH_PATH="$(python3 "$CACHE_INVENTORY_SCRIPT" --repo-root "$ROOT_DIR" --configuration "$CONF" --format path)" + REPOPROMPT_SWIFTPM_SCRATCH_PATH="$SWIFTPM_SCRATCH_PATH" + fi + export REPOPROMPT_SWIFTPM_SCRATCH_PATH + printf 'SwiftPM scratch path: %s\n' "$SWIFTPM_SCRATCH_PATH" +} finish(){ local status="$1" now total [[ -z "${APP_ENTITLEMENTS:-}" ]] || rm -f "$APP_ENTITLEMENTS" @@ -107,6 +119,7 @@ fi phase "Checking build environment" run "$CONTROL_PLANE_SCRIPTS_DIR/doctor.sh" --quiet +resolve_swiftpm_scratch_path SIGN_IDENTITY_WAS_EXPLICIT=0 if [[ -n "${SIGN_IDENTITY:-}" ]]; then SIGN_IDENTITY_WAS_EXPLICIT=1 @@ -234,14 +247,14 @@ else run "$CONTROL_PLANE_SCRIPTS_DIR/patch_keyboard_shortcuts_resource_lookup.sh" "$ROOT_DIR" phase "Building $APP_NAME ($CONF, host-native)" - run "$RUN_WITHOUT_GITHUB_TOKENS" swift build "${SWIFT_BUILD_ARGS[@]}" --product "$APP_NAME" + run "$RUN_WITHOUT_GITHUB_TOKENS" swift build "${SWIFT_BUILD_ARGS[@]}" --scratch-path "$SWIFTPM_SCRATCH_PATH" --product "$APP_NAME" phase "Building repoprompt-mcp ($CONF, host-native)" - run "$RUN_WITHOUT_GITHUB_TOKENS" swift build "${SWIFT_BUILD_ARGS[@]}" --product repoprompt-mcp + run "$RUN_WITHOUT_GITHUB_TOKENS" swift build "${SWIFT_BUILD_ARGS[@]}" --scratch-path "$SWIFTPM_SCRATCH_PATH" --product repoprompt-mcp phase "Resolving build artifact paths" - echo_cmd "$RUN_WITHOUT_GITHUB_TOKENS" swift build -c "$CONF" --show-bin-path - BUILD_DIR="$("$RUN_WITHOUT_GITHUB_TOKENS" swift build -c "$CONF" --show-bin-path)" + echo_cmd "$RUN_WITHOUT_GITHUB_TOKENS" swift build -c "$CONF" --scratch-path "$SWIFTPM_SCRATCH_PATH" --show-bin-path + BUILD_DIR="$("$RUN_WITHOUT_GITHUB_TOKENS" swift build -c "$CONF" --scratch-path "$SWIFTPM_SCRATCH_PATH" --show-bin-path)" fi if (( PUBLIC_UNIVERSAL_RELEASE )); then APP_BUNDLE="$ROOT_DIR/.build/release/$APP_NAME.app" diff --git a/Scripts/test_cache_inventory.py b/Scripts/test_cache_inventory.py new file mode 100644 index 000000000..aee47807c --- /dev/null +++ b/Scripts/test_cache_inventory.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +"""Tests for cache_inventory scratch identity and lifecycle planning.""" + +from __future__ import annotations + +import contextlib +import io +import os +import shutil +import subprocess +import tempfile +import unittest +from unittest import mock +from pathlib import Path + +import cache_inventory +import conductor + + +SCRIPT_DIR = Path(__file__).resolve().parent + + +class CacheInventoryTests(unittest.TestCase): + def test_macos_version_prefers_platform_value_without_spawning_process(self) -> None: + with mock.patch.object(cache_inventory.platform, "mac_ver", return_value=("15.4.1", ("", "", ""), "")), mock.patch.object( + cache_inventory.subprocess, + "run", + ) as run: + self.assertEqual(cache_inventory._macos_version(), "15.4") + + run.assert_not_called() + + def test_default_identity_points_to_dot_build(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "Package.swift").write_text('// swift-tools-version:5.9\n', encoding="utf-8") + identity = cache_inventory.resolve_swiftpm_cache_identity(root, "debug", {}) + self.assertEqual(identity.source, cache_inventory.CacheSource.DEFAULT) + self.assertEqual(identity.effective_path, root.resolve() / ".build") + self.assertIsNone(identity.developer_root) + self.assertIn("debug", identity.key()) + + def test_exact_env_identity_uses_provided_path(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + exact = root / "exact" / "scratch" + exact.mkdir(parents=True) + env = {"REPOPROMPT_SWIFTPM_SCRATCH_PATH": str(exact)} + identity = cache_inventory.resolve_swiftpm_cache_identity(root, "debug", env) + self.assertEqual(identity.source, cache_inventory.CacheSource.EXACT_ENV) + self.assertEqual(identity.effective_path, exact.resolve()) + + def test_developer_root_identity_is_keyed_under_root(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "Package.swift").write_text('// swift-tools-version:5.9\n', encoding="utf-8") + dev_root = root / "dev-cache" + dev_root.mkdir() + env = {"REPOPROMPT_DEVELOPER_SWIFTPM_SCRATCH_ROOT": str(dev_root)} + identity = cache_inventory.resolve_swiftpm_cache_identity(root, "debug", env) + self.assertEqual(identity.source, cache_inventory.CacheSource.DEVELOPER_ROOT) + self.assertEqual(identity.developer_root, str(dev_root.resolve())) + self.assertTrue(cache_inventory.is_path_within(identity.effective_path, dev_root)) + self.assertIn(cache_inventory.repo_hash(root)[:8], identity.key()) + + def test_managed_worktree_container_detects_worktree_layout(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + container = root / ".repoprompt-worktrees" / "group" + repo_root = container / "wt-a" + repo_root.mkdir(parents=True) + self.assertEqual(cache_inventory.managed_worktree_container(repo_root), container.resolve()) + + def test_managed_worktree_repo_roots_includes_siblings_and_current(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + container = root / ".repoprompt-worktrees" / "group" + current = container / "wt-a" + sibling = container / "wt-b" + other = container / "not-a-repo" + current.mkdir(parents=True) + sibling.mkdir(parents=True) + other.mkdir(parents=True) + (current / ".build").mkdir() + (sibling / ".build").mkdir() + roots = cache_inventory.managed_worktree_repo_roots(current) + self.assertEqual(roots, [current.resolve(), sibling.resolve()]) + + def test_cleanup_plan_skips_current_and_marks_siblings_eligible(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + container = root / ".repoprompt-worktrees" / "group" + current = container / "wt-a" + sibling = container / "wt-b" + current.mkdir(parents=True) + sibling.mkdir(parents=True) + (current / ".build" / "debug").mkdir(parents=True) + (sibling / ".build" / "debug").mkdir(parents=True) + (current / "Package.swift").write_text("", encoding="utf-8") + (sibling / "Package.swift").write_text("", encoding="utf-8") + + plan = cache_inventory.plan_build_cache_cleanup(current) + + self.assertEqual(len(plan.entries), 2) + current_entry = next(e for e in plan.entries if e.repo_root == current.resolve()) + sibling_entry = next(e for e in plan.entries if e.repo_root == sibling.resolve()) + self.assertTrue(current_entry.current) + self.assertIn("current", current_entry.skip_reasons) + self.assertFalse(sibling_entry.current) + self.assertEqual(sibling_entry.skip_reasons, []) + self.assertEqual(len(plan.eligible_entries), 1) + self.assertEqual(plan.eligible_entries[0].repo_root, sibling.resolve()) + + def test_cleanup_plan_respects_limit(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + container = root / ".repoprompt-worktrees" / "group" + current = container / "wt-a" + sibling1 = container / "wt-b" + sibling2 = container / "wt-c" + for d in (current, sibling1, sibling2): + d.mkdir(parents=True) + (d / ".build" / "debug").mkdir(parents=True) + (d / "Package.swift").write_text("", encoding="utf-8") + + plan = cache_inventory.plan_build_cache_cleanup(current, limit=1) + + eligible = [e for e in plan.entries if not e.current] + self.assertEqual(len(eligible), 1) + + def test_cleanup_apply_requires_confirm(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + container = root / ".repoprompt-worktrees" / "group" + current = container / "wt-a" + sibling = container / "wt-b" + current.mkdir(parents=True) + sibling.mkdir(parents=True) + (current / ".build" / "debug").mkdir(parents=True) + (sibling / ".build" / "debug").mkdir(parents=True) + (current / "Package.swift").write_text("", encoding="utf-8") + (sibling / "Package.swift").write_text("", encoding="utf-8") + + plan = cache_inventory.plan_build_cache_cleanup( + current, dry_run=False, apply=True, confirm=False + ) + self.assertEqual(len(plan.eligible_entries), 1) + rc = cache_inventory.execute_build_cache_cleanup(plan) + self.assertEqual(rc, 1) + + def test_cleanup_apply_confirm_removes_eligible_siblings(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + container = root / ".repoprompt-worktrees" / "group" + current = container / "wt-a" + sibling = container / "wt-b" + current.mkdir(parents=True) + sibling.mkdir(parents=True) + (current / ".build" / "debug").mkdir(parents=True) + (sibling / ".build" / "debug").mkdir(parents=True) + (current / "Package.swift").write_text("", encoding="utf-8") + (sibling / "Package.swift").write_text("", encoding="utf-8") + + plan = cache_inventory.plan_build_cache_cleanup( + current, dry_run=False, apply=True, confirm=True + ) + rc = cache_inventory.execute_build_cache_cleanup(plan) + self.assertEqual(rc, 0) + self.assertTrue((current / ".build").exists()) + self.assertFalse((sibling / ".build").exists()) + + def test_cleanup_plan_skips_symlinked_cache_without_resolving_target(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + container = Path(tmp) / ".repoprompt-worktrees" / "group" + current = container / "wt-a" + sibling = container / "wt-b" + target = container / "shared-cache" + for directory in (current, sibling, target): + directory.mkdir(parents=True) + (current / "Package.swift").write_text("", encoding="utf-8") + (sibling / "Package.swift").write_text("", encoding="utf-8") + (current / ".build").mkdir() + (sibling / ".build").symlink_to(target, target_is_directory=True) + + plan = cache_inventory.plan_build_cache_cleanup(sibling) + entry = next(e for e in plan.entries if e.path == sibling.resolve() / ".build") + + self.assertIn("symlink", entry.skip_reasons) + self.assertNotEqual(entry.path, target.resolve()) + + def test_diagnostics_build_cache_reports_identity_and_worktrees(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + container = Path(tmp) / ".repoprompt-worktrees" / "group" + current = container / "wt-a" + sibling = container / "wt-b" + current.mkdir(parents=True) + sibling.mkdir(parents=True) + (current / ".build" / "debug").mkdir(parents=True) + (sibling / ".build" / "debug").mkdir(parents=True) + (sibling / ".build" / "debug" / "large.bin").write_bytes(b"x" * 2000) + (current / "Package.swift").write_text("", encoding="utf-8") + (sibling / "Package.swift").write_text("", encoding="utf-8") + + output = io.StringIO() + with contextlib.redirect_stdout(output): + rc = cache_inventory.operation_diagnostics_build_cache(current, {"limit": 2}) + text = output.getvalue() + + self.assertEqual(rc, 0) + self.assertIn("Cache identity:", text) + self.assertIn("Build cache diagnostics", text) + self.assertIn("Worktree .build total:", text) + self.assertIn("Top .build directories:", text) + self.assertIn("wt-b", text) + + def test_conductor_registry_swift_build_adds_scratch_path_from_env(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + scratch = root / "external" / "scratch" + scratch.mkdir(parents=True) + env = {"REPOPROMPT_DEVELOPER_SWIFTPM_SCRATCH_ROOT": str(scratch)} + registry = conductor.OperationRegistry(root) + argv, _lanes, _cwd, _env, _timeout = registry.prepare( + { + "operation": "swift-build", + "args": {"product": "RepoPrompt"}, + "env": env, + } + ) + self.assertIn("--scratch-path", argv) + self.assertTrue(any(str(scratch) in a for a in argv), argv) + + def test_conductor_registry_swift_build_omits_scratch_path_for_default(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + registry = conductor.OperationRegistry(root) + argv, _lanes, _cwd, _env, _timeout = registry.prepare( + {"operation": "swift-build", "args": {"product": "RepoPrompt"}} + ) + self.assertNotIn("--scratch-path", argv) + + def test_conductor_registry_build_sets_scratch_path_env(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + scratch = root / "external" / "scratch" + scratch.mkdir(parents=True) + env = {"REPOPROMPT_DEVELOPER_SWIFTPM_SCRATCH_ROOT": str(scratch)} + registry = conductor.OperationRegistry(root) + argv, _lanes, _cwd, out_env, _timeout = registry.prepare( + {"operation": "build", "args": {}, "env": env} + ) + self.assertIn("package_app.sh", argv[0]) + expected_path = scratch / cache_inventory.resolve_swiftpm_cache_identity(root, "debug", env).key() + self.assertEqual(out_env["REPOPROMPT_SWIFTPM_SCRATCH_PATH"], str(expected_path.resolve())) + + def test_conductor_registry_cache_cleanup_delegates_internal_runner(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + registry = conductor.OperationRegistry(root) + argv, _lanes, _cwd, _env, _timeout = registry.prepare( + {"operation": "cache", "args": {"subcommand": "cleanup", "apply": True, "confirm": True, "limit": 3}} + ) + self.assertIn("__operation_runner", argv) + payload = argv[-1] + self.assertIn('"kind":"cache_cleanup"', payload) + self.assertIn('"apply":true', payload) + self.assertIn('"confirm":true', payload) + + def test_cli_identity_and_path_outputs(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "Package.swift").write_text("", encoding="utf-8") + result = subprocess.run( + ["python3", str(SCRIPT_DIR / "cache_inventory.py"), "--repo-root", str(root), "--configuration", "debug", "--format", "identity"], + text=True, + capture_output=True, + ) + self.assertEqual(result.returncode, 0) + self.assertIn("source=default", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/test_conductor_lifecycle.py b/Scripts/test_conductor_lifecycle.py index 5bde3f164..c3df24e53 100644 --- a/Scripts/test_conductor_lifecycle.py +++ b/Scripts/test_conductor_lifecycle.py @@ -795,7 +795,16 @@ def wait_for_terminal_job(self, state: conductor.DaemonState, ticket: str, timeo while time.monotonic() < deadline: with state.condition: job = state.jobs[ticket] - if job.state in conductor.TERMINAL_STATES: + # A job becomes terminal before the runner's final persistence + # and lane-release work is complete. The output-summary refresh + # is deliberately started after lanes are released, so waiting + # only for lane release still lets TemporaryDirectory cleanup + # race that final read of the job log. + if ( + job.state in conductor.TERMINAL_STATES + and ticket not in state.active_lanes.values() + and job.output_summary is not None + ): return job time.sleep(0.01) with state.condition: @@ -2077,6 +2086,7 @@ def test_guarded_failed_relaunch_does_not_inspect_or_stop_before_packaging_succe run_script = scripts / "run.sh" shutil.copy2(SCRIPT_DIR / "run.sh", run_script) shutil.copy2(SCRIPT_DIR / "conductor.py", scripts / "conductor.py") + shutil.copy2(SCRIPT_DIR / "cache_inventory.py", scripts / "cache_inventory.py") run_script.chmod(0o755) package_script = scripts / "package_app.sh" package_script.write_text("#!/usr/bin/env bash\necho package failed\nexit 23\n", encoding="utf-8") @@ -2128,6 +2138,7 @@ def test_direct_run_packages_before_waiting_for_live_lock_then_activates(self) - run_script = scripts / "run.sh" shutil.copy2(SCRIPT_DIR / "run.sh", run_script) shutil.copy2(SCRIPT_DIR / "conductor.py", scripts / "conductor.py") + shutil.copy2(SCRIPT_DIR / "cache_inventory.py", scripts / "cache_inventory.py") run_script.chmod(0o755) event_log = root / "events.log" launched_marker = root / "launched" @@ -2247,6 +2258,7 @@ def test_successful_relaunch_uses_debug_executable_for_stop_and_readiness(self) run_script = scripts / "run.sh" shutil.copy2(SCRIPT_DIR / "run.sh", run_script) shutil.copy2(SCRIPT_DIR / "conductor.py", scripts / "conductor.py") + shutil.copy2(SCRIPT_DIR / "cache_inventory.py", scripts / "cache_inventory.py") run_script.chmod(0o755) event_log = root / "events.log" launched_marker = root / "launched" diff --git a/Scripts/test_local_production_installer.py b/Scripts/test_local_production_installer.py index 0a0e6cc18..e100e4df4 100644 --- a/Scripts/test_local_production_installer.py +++ b/Scripts/test_local_production_installer.py @@ -585,9 +585,12 @@ def run_installer( textwrap.dedent( """\ #!/usr/bin/env bash + ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" set -euo pipefail app="$FAKE_BUILD_DIR/RepoPrompt.app" mkdir -p "$app/Contents" + mkdir -p "$ROOT_DIR/.build" + ln -sfn "$FAKE_BUILD_DIR" "$ROOT_DIR/.build/release" printf 'new\\n' > "$app/payload.txt" printf '%s|%s|%s\\n' "$LOCAL_SIGNING_CERTIFICATE_SHA1" "$LOCAL_SIGNING_CERTIFICATE_SHA256" "$LOCAL_SIGNING_SERVICE_GENERATION" >> "$PACKAGE_CAPTURE" cat > "$app/Contents/Info.plist" <