diff --git a/cecli/helpers/monorepo/config.py b/cecli/helpers/monorepo/config.py index af8f4b39ad6..281fe33c1b8 100644 --- a/cecli/helpers/monorepo/config.py +++ b/cecli/helpers/monorepo/config.py @@ -70,6 +70,15 @@ def resolve_workspace_config(config_arg: Optional[str] = None) -> Optional[Any]: return workspace_conf +def load_workspace_config_file(path: Path) -> Dict[str, Any]: + """Load and validate a repo-local ``.cecli.workspaces.yml`` file.""" + from cecli.helpers.monorepo.local_workspace import load_workspace_file + + config = load_workspace_file(path) + validate_config(config) + return config + + def load_workspace_config( config_arg: Optional[str] = None, name: Optional[str] = None ) -> Dict[str, Any]: @@ -108,7 +117,10 @@ def load_workspace_config( def validate_config(config: Dict[str, Any]) -> None: """ - Minimal validation of required fields. + Validate workspace config shape. + + Each project must have a ``name`` and exactly one of ``path`` (local git + root) or ``repo`` (clone URL). At most one project may set ``primary: true``. """ if not config: return @@ -120,12 +132,23 @@ def validate_config(config: Dict[str, Any]) -> None: config["projects"] = [] project_names = set() + primary_count = 0 for project in config["projects"]: - if "name" not in project or "repo" not in project: - raise ValueError("Each project must have a 'name' and 'repo' URL") + if "name" not in project: + raise ValueError("Each project must have a 'name'") + has_path = bool(project.get("path")) + has_repo = bool(project.get("repo")) + if has_path == has_repo: + raise ValueError( + f"Project '{project['name']}' must have exactly one of 'path' or 'repo'" + ) + if project.get("primary"): + primary_count += 1 if project["name"] in project_names: raise ValueError(f"Duplicate project name: {project['name']}") project_names.add(project["name"]) + if primary_count > 1: + raise ValueError("Only one project may be marked primary: true") def find_active_workspace_name(config_arg: Optional[str] = None) -> Optional[str]: diff --git a/cecli/helpers/monorepo/local_workspace.py b/cecli/helpers/monorepo/local_workspace.py new file mode 100644 index 00000000000..51df9b486c9 --- /dev/null +++ b/cecli/helpers/monorepo/local_workspace.py @@ -0,0 +1,233 @@ +""" +Repo-local multi-project workspaces (``path:`` git roots). + +Cecli already supports **clone** workspaces under ``~/.cecli/workspaces/`` with +``repo:`` URLs and paths like ``{project}/main/{file}``. This module adds +**local** layout: projects point at existing directories on disk, and tracked +paths are prefixed as ``{project}/{file}`` (no ``/main/`` segment). + +Config file names (at the workspace root — usually the primary project directory): + +- ``.cecli.workspaces.yml`` +- ``.cecli.workspaces.yaml`` + +Each project must have exactly one of ``path`` (absolute local git root) or +``repo`` (clone URL; handled by existing clone workspace code). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from typing import Any + +import yaml + +WORKSPACE_FILENAMES = (".cecli.workspaces.yml", ".cecli.workspaces.yaml") +METADATA_NAME = ".cecli/.workspace-meta.json" + + +def find_workspace_config_file(start: Path) -> Path | None: + """Return the nearest ``.cecli.workspaces.yml`` walking up from *start*.""" + current = Path(start).resolve() + if current.is_file(): + current = current.parent + while True: + for name in WORKSPACE_FILENAMES: + candidate = current / name + if candidate.is_file(): + return candidate + parent = current.parent + if parent == current: + break + current = parent + return None + + +def load_workspace_file(path: Path) -> dict[str, Any]: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + if not isinstance(raw, dict): + raise ValueError("Workspace file must be a mapping") + if "name" not in raw: + raw["name"] = path.parent.name or "workspace" + if "projects" not in raw: + raw["projects"] = [] + return raw + + +def primary_project(config: dict[str, Any]) -> dict[str, Any] | None: + projects = config.get("projects") or [] + for proj in projects: + if proj.get("primary"): + return proj + if len(projects) == 1: + return projects[0] + return projects[0] if projects else None + + +def project_git_root(workspace_root: Path, project: dict[str, Any], *, layout: str) -> Path | None: + name = project.get("name") + if not name: + return None + path_val = project.get("path") + if path_val: + root = Path(str(path_val)).expanduser().resolve() + if not root.is_dir(): + return None + try: + subprocess.check_output( + ["git", "-C", str(root), "rev-parse", "--show-toplevel"], + stderr=subprocess.DEVNULL, + ) + return root + except Exception: + return None + if layout != "clone": + return None + clone_root = workspace_root / name / "main" + return clone_root if clone_root.is_dir() else None + + +def project_path_prefix(project: dict[str, Any], *, layout: str) -> str: + name = str(project.get("name") or "") + if layout == "clone": + return f"{name}/main" + return name + + +def resolve_workspace_file_path( + workspace_root: Path, + workspace_rel: str, + config: dict[str, Any], + *, + layout: str, +) -> tuple[Path, Path, str] | None: + """ + Map a workspace-relative path to ``(project_git_root, absolute_file, path_in_project_repo)``. + """ + rel = workspace_rel.replace("\\", "/").lstrip("/") + if not rel: + return None + parts = Path(rel).parts + if not parts: + return None + projects = config.get("projects") or [] + by_name = {str(p.get("name")): p for p in projects if p.get("name")} + + # Clone layout: name/main/rest + if layout == "clone" and len(parts) >= 2 and parts[1] == "main": + proj = by_name.get(parts[0]) + if not proj: + return None + git_root = project_git_root(workspace_root, proj, layout=layout) + if not git_root: + return None + in_repo = "/".join(parts[2:]) if len(parts) > 2 else "" + abs_path = git_root / in_repo if in_repo else git_root + return git_root, abs_path, in_repo + + # Local layout: name/rest or bare path under primary-only tree + if parts[0] in by_name: + proj = by_name[parts[0]] + git_root = project_git_root(workspace_root, proj, layout=layout) + if not git_root: + return None + in_repo = "/".join(parts[1:]) if len(parts) > 1 else "" + abs_path = git_root / in_repo if in_repo else git_root + return git_root, abs_path, in_repo + + primary = primary_project(config) + if primary: + git_root = project_git_root(workspace_root, primary, layout=layout) + if git_root: + in_repo = rel + return git_root, git_root / in_repo, in_repo + return None + + +def union_tracked_files( + workspace_root: Path, + config: dict[str, Any], + *, + layout: str, + ignored_file=None, +) -> list[str]: + """All tracked files as workspace-relative paths.""" + out: list[str] = [] + for proj in config.get("projects") or []: + name = proj.get("name") + if not name: + continue + git_root = project_git_root(workspace_root, proj, layout=layout) + if not git_root: + continue + prefix = project_path_prefix(proj, layout=layout) + try: + lines = subprocess.check_output( + ["git", "-C", str(git_root), "ls-files"], + stderr=subprocess.DEVNULL, + encoding="utf-8", + ).splitlines() + except Exception: + continue + for line in lines: + if not line.strip(): + continue + rel = f"{prefix}/{line}" if prefix else line + rel = rel.replace("\\", "/") + if ignored_file and ignored_file(rel): + continue + out.append(rel) + return out + + +def project_head_shas( + workspace_root: Path, + config: dict[str, Any], + *, + layout: str, +) -> list[str]: + shas: list[str] = [] + for proj in config.get("projects") or []: + name = proj.get("name") + if not name: + continue + git_root = project_git_root(workspace_root, proj, layout=layout) + if not git_root: + shas.append(f"{name}:unknown") + continue + try: + sha = subprocess.check_output( + ["git", "-C", str(git_root), "rev-parse", "HEAD"], + stderr=subprocess.DEVNULL, + encoding="utf-8", + ).strip() + shas.append(f"{name}:{sha}") + except Exception: + shas.append(f"{name}:unknown") + return shas + + +def write_workspace_metadata(workspace_root: Path, config: dict[str, Any], *, layout: str) -> None: + meta_dir = workspace_root / ".cecli" + meta_dir.mkdir(parents=True, exist_ok=True) + payload = {**config, "_layout": layout} + (meta_dir / ".workspace-meta.json").write_text( + json.dumps(payload, indent=2), + encoding="utf-8", + ) + + +def read_workspace_metadata(workspace_root: Path) -> tuple[dict[str, Any], str] | None: + legacy = workspace_root / ".cecli-workspace.json" + modern = workspace_root / METADATA_NAME + path = modern if modern.is_file() else legacy if legacy.is_file() else None + if not path: + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + layout = data.pop("_layout", "clone") + return data, layout + except Exception: + return None diff --git a/cecli/repo.py b/cecli/repo.py index 07ce806d0bd..f10ff1039e4 100644 --- a/cecli/repo.py +++ b/cecli/repo.py @@ -98,6 +98,7 @@ def __init__( self.is_workspace = False self.workspace_path = None self.workspace_config = {} + self.workspace_layout = "clone" self.workspace_ignore_specs = {} self.workspace_ignore_ts = {} # Workspace detection and config loading occurs later in __init__ @@ -131,23 +132,33 @@ def __init__( if num_repos == 0: raise FileNotFoundError if num_repos > 1: - self.io.tool_error("Files are in different git repos.") - raise FileNotFoundError - - self._init_repo_path = repo_paths.pop() + from cecli.helpers.monorepo.local_workspace import find_workspace_config_file + from cecli.helpers.monorepo.config import load_workspace_config_file + + ws_file = find_workspace_config_file(Path(repo_paths[0])) + if not ws_file: + self.io.tool_error( + "Files are in different git repos. Add a .cecli.workspaces.yml at a" + " common ancestor with path: entries for each project." + ) + raise FileNotFoundError + self.workspace_config = load_workspace_config_file(ws_file) + primary = next( + (p for p in self.workspace_config.get("projects", []) if p.get("primary")), + None, + ) + if primary and primary.get("path"): + self._init_repo_path = str(Path(str(primary["path"])).expanduser().resolve()) + else: + self._init_repo_path = str(Path(repo_paths[0]).resolve()) + else: + self._init_repo_path = repo_paths.pop() # Detect if we're in a workspace self.workspace_path = self._detect_workspace_path(self._init_repo_path) if self.workspace_path: self.is_workspace = True - - try: - from cecli.helpers.monorepo.config import load_workspace_config - - self.workspace_config = load_workspace_config(name=self.workspace_path.name) - except Exception: - self.workspace_config = {} - + self._load_workspace_config() self.refresh_cecli_ignore() self.init_repo() @@ -170,9 +181,41 @@ def init_repo(self): self.repo = git.Repo(self._init_repo_path, odbt=git.GitCmdObjectDB) self.root = utils.safe_abs_path(self.repo.working_tree_dir) + def _load_workspace_config(self) -> None: + from cecli.helpers.monorepo.config import load_workspace_config, load_workspace_config_file + from cecli.helpers.monorepo.local_workspace import ( + find_workspace_config_file, + read_workspace_metadata, + write_workspace_metadata, + ) + + ws_file = find_workspace_config_file(Path(self.workspace_path)) + if ws_file: + self.workspace_layout = "local" + self.workspace_config = load_workspace_config_file(ws_file) + write_workspace_metadata( + Path(self.workspace_path), self.workspace_config, layout="local" + ) + return + meta = read_workspace_metadata(Path(self.workspace_path)) + if meta: + self.workspace_config, self.workspace_layout = meta + return + self.workspace_layout = "clone" + try: + self.workspace_config = load_workspace_config(name=Path(self.workspace_path).name) + except Exception: + self.workspace_config = {} + def _detect_workspace_path(self, start_path: str): """Check if current directory is within a workspace""" + from cecli.helpers.monorepo.local_workspace import find_workspace_config_file + current = Path(start_path).resolve() + ws_file = find_workspace_config_file(current) + if ws_file: + return ws_file.parent.resolve() + workspace_root = Path("~/.cecli/workspaces").expanduser() # Walk up directory tree looking for workspace root @@ -267,6 +310,11 @@ async def commit(self, fnames=None, context=None, message=None, coder_edits=Fals - User commit with explicit no-committer: coder_edits=False, --no-attribute-committer -> Author=You, Committer=You """ + if self.is_workspace and getattr(self, "workspace_layout", "clone") == "local": + return await self._commit_local_workspace( + fnames, context, message, coder_edits, coder + ) + if not fnames and not self.repo.is_dirty(): return @@ -592,6 +640,57 @@ def get_tracked_files(self): return res + async def _commit_local_workspace( + self, fnames=None, context=None, message=None, coder_edits=False, coder=None + ): + from collections import defaultdict + + from cecli.helpers.monorepo.local_workspace import resolve_workspace_file_path + + layout = getattr(self, "workspace_layout", "local") + config = self.workspace_config or {} + readonly = { + str(p.get("name")) + for p in config.get("projects", []) + if p.get("readonly") and p.get("name") + } + + by_root: dict[str, list[str]] = defaultdict(list) + if fnames: + for fname in fnames: + resolved = resolve_workspace_file_path( + Path(self.workspace_path), str(fname), config, layout=layout + ) + if not resolved: + continue + git_root, _abs_path, in_repo = resolved + parts = Path(str(fname)).parts + if parts and parts[0] in readonly: + continue + if in_repo: + by_root[str(git_root)].append(in_repo) + else: + for proj in config.get("projects", []): + name = proj.get("name") + if not name or name in readonly: + continue + from cecli.helpers.monorepo.local_workspace import project_git_root + + git_root = project_git_root(Path(self.workspace_path), proj, layout=layout) + if not git_root: + continue + sub = GitRepo(self.io, [str(git_root)], None) + for rel in sub.get_dirty_files() or []: + by_root[str(git_root)].append(rel) + + last = None + for root, rels in by_root.items(): + sub = GitRepo(self.io, [root], None) + last = await sub.commit( + rels, context=context, message=message, coder_edits=coder_edits, coder=coder + ) + return last + def get_workspace_files(self): """ If in a workspace, return all tracked files from all projects. @@ -601,6 +700,34 @@ def get_workspace_files(self): return self.get_tracked_files() import hashlib + + layout = getattr(self, "workspace_layout", "clone") + config = self.workspace_config or {} + if not config.get("projects"): + return self.get_tracked_files() + + from cecli.helpers.monorepo.local_workspace import project_head_shas, union_tracked_files + + project_shas = project_head_shas( + Path(self.workspace_path), config, layout=layout + ) + cache_key = hashlib.sha1(",".join(project_shas).encode()).hexdigest() + + if hasattr(self, "_workspace_files_cache"): + cached_key, cached_files = self._workspace_files_cache + if cached_key == cache_key: + return cached_files + + if layout == "local": + all_files = union_tracked_files( + Path(self.workspace_path), + config, + layout=layout, + ignored_file=self.ignored_file, + ) + self._workspace_files_cache = (cache_key, all_files) + return all_files + import json import subprocess @@ -614,36 +741,8 @@ def get_workspace_files(self): except Exception: return self.get_tracked_files() - # Generate a cache key based on the SHAs of all project HEADs - # This is similar to how base_coder uses staged files hash - projects = config.get("projects", []) - project_shas = [] - for proj in projects: - proj_name = proj.get("name") - if not proj_name: - continue - proj_root = self.workspace_path / proj_name / "main" - if not proj_root.exists(): - continue - try: - sha = subprocess.check_output( - ["git", "-C", str(proj_root), "rev-parse", "HEAD"], - stderr=subprocess.DEVNULL, - encoding="utf-8", - ).strip() - project_shas.append(f"{proj_name}:{sha}") - except Exception: - project_shas.append(f"{proj_name}:unknown") - - cache_key = hashlib.sha1(",".join(project_shas).encode()).hexdigest() - - if hasattr(self, "_workspace_files_cache"): - cached_key, cached_files = self._workspace_files_cache - if cached_key == cache_key: - return cached_files - all_files = [] - for proj in projects: + for proj in config.get("projects", []): proj_name = proj.get("name") if not proj_name: continue @@ -865,7 +964,8 @@ def ignored_file_raw(self, fname): ): # Check against project-specific spec # The spec expects paths relative to the project root (usually proj/main/) - if len(parts) > 2 and parts[1] == "main": + layout = getattr(self, "workspace_layout", "clone") + if layout == "clone" and len(parts) > 2 and parts[1] == "main": proj_rel_path = str(Path(*parts[2:])) else: proj_rel_path = str(Path(*parts[1:])) @@ -926,6 +1026,17 @@ def path_in_repo(self, path): return self.normalize_path(path) in tracked_files def abs_root_path(self, path): + if self.is_workspace and getattr(self, "workspace_layout", "clone") == "local": + from cecli.helpers.monorepo.local_workspace import resolve_workspace_file_path + + resolved = resolve_workspace_file_path( + Path(self.workspace_path), + str(path), + self.workspace_config or {}, + layout="local", + ) + if resolved: + return utils.safe_abs_path(resolved[1]) res = Path(self.root) / path return utils.safe_abs_path(res) diff --git a/tests/helpers/monorepo/LOCAL_WORKSPACE.md b/tests/helpers/monorepo/LOCAL_WORKSPACE.md new file mode 100644 index 00000000000..dd4311195cb --- /dev/null +++ b/tests/helpers/monorepo/LOCAL_WORKSPACE.md @@ -0,0 +1,66 @@ +# PR: Local `path:` projects for cecli workspaces + +## Summary + +Extends cecli’s existing **clone** workspace mode (`repo:` URLs under `~/.cecli/workspaces/`, paths like `project/main/file.py`) with **local** layout: multiple git roots on disk referenced by absolute `path:` in a repo-local config file. + +## Motivation + +IDE clients (e.g. BrightVision) open a **primary git repo** but need agent context across **sibling repos** without cloning into `~/.cecli/workspaces/`. Submodule-only setups are a different layout; this PR adds an explicit, reviewable config surface. + +## Config + +Place at the workspace root (walked up from any listed project path): + +```yaml +# .cecli.workspaces.yml +name: my-workspace +projects: + - name: app + path: /abs/path/to/app + primary: true + - name: lib + path: /abs/path/to/lib + readonly: true +``` + +Rules (enforced in `validate_config`): + +- Each project: `name` + **exactly one** of `path` or `repo` +- At most one `primary: true` + +## Path layout + +| Layout | Prefix | Example | +|--------|--------|---------| +| **local** (this PR) | `{project}/{file}` | `app/src/main.py` | +| **clone** (existing) | `{project}/main/{file}` | `app/main/src/main.py` | + +## Behavior changes + +| Area | Change | +|------|--------| +| `GitRepo.__init__` | Multiple git roots allowed when `.cecli.workspaces.yml` is found on a common ancestor | +| `get_workspace_files` | Local layout unions `git ls-files` from each `path:` root | +| `commit` | Local layout commits per underlying repo (`_commit_local_workspace`) | +| `abs_root_path` | Resolves prefixed paths to the correct project root | + +Clone workspaces and `.cecli-workspace.json` metadata are unchanged. + +## Tests + +- `tests/helpers/monorepo/test_config.py` — validation (`path` / `repo` XOR) +- `tests/helpers/monorepo/test_local_workspace.py` — helpers + `GitRepo` integration +- Existing `test_repomap_workspace.py`, `test_workspace.py`, etc. — still pass (clone layout) + +Run: + +```bash +pytest tests/helpers/monorepo -q +``` + +## Non-goals (follow-up PRs) + +- Auto-registering git submodules into the workspace registry +- Combining submodule `RepoSet` with local YAML in one facade +- New global config file formats (reuse `.cecli.workspaces.yml` only) diff --git a/tests/helpers/monorepo/test_config.py b/tests/helpers/monorepo/test_config.py index bade92cb61b..0d85669753c 100644 --- a/tests/helpers/monorepo/test_config.py +++ b/tests/helpers/monorepo/test_config.py @@ -13,11 +13,36 @@ def test_validate_config_no_name(): validate_config({"projects": []}) -def test_validate_config_invalid_project(): - with pytest.raises(ValueError, match="Each project must have a 'name' and 'repo' URL"): +def test_validate_config_invalid_project_missing_source(): + with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"): validate_config({"name": "test", "projects": [{"name": "p1"}]}) +def test_validate_config_invalid_project_both_sources(): + with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"): + validate_config( + { + "name": "test", + "projects": [ + { + "name": "p1", + "path": "/tmp/p1", + "repo": "https://github.com/org/r.git", + } + ], + } + ) + + +def test_validate_config_path_project(): + validate_config( + { + "name": "local", + "projects": [{"name": "app", "path": "/abs/app", "primary": True}], + } + ) + + def test_validate_config_duplicate_project(): with pytest.raises(ValueError, match="Duplicate project name: p1"): validate_config( diff --git a/tests/helpers/monorepo/test_local_workspace.py b/tests/helpers/monorepo/test_local_workspace.py new file mode 100644 index 00000000000..7ba179aafbe --- /dev/null +++ b/tests/helpers/monorepo/test_local_workspace.py @@ -0,0 +1,236 @@ +"""Tests for repo-local workspaces (``path:`` git roots, ``.cecli.workspaces.yml``).""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest +import yaml + +from cecli.helpers.monorepo.config import load_workspace_config_file, validate_config +from cecli.helpers.monorepo.local_workspace import ( + find_workspace_config_file, + load_workspace_file, + primary_project, + project_git_root, + project_path_prefix, + read_workspace_metadata, + resolve_workspace_file_path, + union_tracked_files, + write_workspace_metadata, +) +from cecli.io import InputOutput +from cecli.repo import GitRepo +from cecli.utils import make_repo + + +def _init_git_repo(path: Path, readme: str = "# repo\n") -> None: + make_repo(path) + readme_path = path / "README.md" + readme_path.write_text(readme, encoding="utf-8") + subprocess.run(["git", "add", "README.md"], cwd=path, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "init", "--no-gpg-sign"], + cwd=path, + check=True, + capture_output=True, + ) + + +@pytest.fixture +def two_path_projects(tmp_path: Path): + """ + Workspace root with ``.cecli.workspaces.yml`` and two sibling git checkouts. + + Layout:: + + ws/ + .cecli.workspaces.yml + app/ (git) + lib/ (git) + """ + ws = tmp_path / "ws" + app = ws / "app" + lib = ws / "lib" + app.mkdir(parents=True) + lib.mkdir(parents=True) + _init_git_repo(app, "# app\n") + _init_git_repo(lib, "# lib\n") + + config = { + "name": "pair", + "projects": [ + {"name": "app", "path": str(app.resolve()), "primary": True}, + {"name": "lib", "path": str(lib.resolve())}, + ], + } + (ws / ".cecli.workspaces.yml").write_text( + yaml.dump(config, sort_keys=False), + encoding="utf-8", + ) + return ws, config, app, lib + + +class TestValidateConfigPathProjects: + def test_path_only_project_valid(self): + validate_config( + { + "name": "local", + "projects": [{"name": "app", "path": "/tmp/app", "primary": True}], + } + ) + + def test_repo_only_project_valid(self): + validate_config( + { + "name": "clone", + "projects": [{"name": "p1", "repo": "https://github.com/org/r.git"}], + } + ) + + def test_missing_path_and_repo(self): + with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"): + validate_config({"name": "test", "projects": [{"name": "p1"}]}) + + def test_both_path_and_repo(self): + with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"): + validate_config( + { + "name": "test", + "projects": [ + { + "name": "p1", + "path": "/tmp/a", + "repo": "https://github.com/org/r.git", + } + ], + } + ) + + def test_multiple_primary(self): + with pytest.raises(ValueError, match="Only one project may be marked primary"): + validate_config( + { + "name": "test", + "projects": [ + {"name": "a", "path": "/a", "primary": True}, + {"name": "b", "path": "/b", "primary": True}, + ], + } + ) + + +class TestLocalWorkspaceHelpers: + def test_find_workspace_config_file_walks_up(self, two_path_projects): + ws, _config, app, _lib = two_path_projects + expected = (ws / ".cecli.workspaces.yml").resolve() + assert find_workspace_config_file(ws).resolve() == expected + # YAML lives at workspace root; project checkout is a subdirectory. + assert find_workspace_config_file(app).resolve() == expected + assert find_workspace_config_file(app / "README.md").resolve() == expected + + def test_load_workspace_config_file(self, two_path_projects): + ws, _config, _app, _lib = two_path_projects + loaded = load_workspace_config_file(ws / ".cecli.workspaces.yml") + assert loaded["name"] == "pair" + assert len(loaded["projects"]) == 2 + + def test_union_tracked_files(self, two_path_projects): + ws, config, _app, _lib = two_path_projects + files = union_tracked_files(ws, config, layout="local") + assert "app/README.md" in files + assert "lib/README.md" in files + + def test_resolve_workspace_file_path_prefixed(self, two_path_projects): + ws, config, _app, _lib = two_path_projects + resolved = resolve_workspace_file_path(ws, "lib/README.md", config, layout="local") + assert resolved is not None + git_root, abs_path, in_repo = resolved + assert in_repo == "README.md" + assert abs_path.name == "README.md" + assert git_root.name == "lib" + + def test_project_path_prefix_local_vs_clone(self): + proj = {"name": "app"} + assert project_path_prefix(proj, layout="local") == "app" + assert project_path_prefix(proj, layout="clone") == "app/main" + + def test_primary_project_explicit_and_implicit(self): + cfg = { + "projects": [ + {"name": "a", "path": "/a"}, + {"name": "b", "path": "/b", "primary": True}, + ] + } + assert primary_project(cfg)["name"] == "b" + + single = {"projects": [{"name": "only", "path": "/only"}]} + assert primary_project(single)["name"] == "only" + + def test_workspace_metadata_roundtrip(self, two_path_projects): + ws, config, _app, _lib = two_path_projects + write_workspace_metadata(ws, config, layout="local") + meta = read_workspace_metadata(ws) + assert meta is not None + loaded, layout = meta + assert layout == "local" + assert loaded["name"] == config["name"] + meta_path = ws / ".cecli" / ".workspace-meta.json" + assert meta_path.is_file() + on_disk = json.loads(meta_path.read_text(encoding="utf-8")) + assert on_disk.get("_layout") == "local" + + +class TestGitRepoLocalWorkspace: + def test_detects_local_workspace_and_unions_files(self, two_path_projects): + ws, _config, app, lib = two_path_projects + io = InputOutput(yes=True) + repo = GitRepo(io, [str(app / "README.md"), str(lib / "README.md")], None) + + assert repo.is_workspace + assert repo.workspace_layout == "local" + assert repo.workspace_path == ws.resolve() + + files = repo.get_workspace_files() + assert "app/README.md" in files + assert "lib/README.md" in files + + def test_abs_root_path_resolves_prefixed_path(self, two_path_projects): + _ws, _config, app, _lib = two_path_projects + io = InputOutput(yes=True) + repo = GitRepo(io, [str(app)], None) + + abs_path = Path(repo.abs_root_path("app/README.md")) + assert abs_path == (app / "README.md").resolve() + + def test_without_workspace_file_multi_repo_fails(self, tmp_path: Path): + root = tmp_path / "orphan" + a = root / "a" + b = root / "b" + a.mkdir(parents=True) + b.mkdir(parents=True) + _init_git_repo(a) + _init_git_repo(b) + io = InputOutput(yes=True) + with pytest.raises(FileNotFoundError): + GitRepo(io, [str(a / "README.md"), str(b / "README.md")], None) + + def test_load_workspace_file_defaults(self, tmp_path: Path): + path = tmp_path / ".cecli.workspaces.yml" + path.write_text("projects: []\n", encoding="utf-8") + loaded = load_workspace_file(path) + assert "name" in loaded + assert loaded["projects"] == [] + + +class TestGitRepoLocalWorkspaceNoYaml: + def test_single_repo_without_yaml_is_not_local_workspace(self, tmp_path: Path): + repo_dir = tmp_path / "solo" + repo_dir.mkdir() + _init_git_repo(repo_dir) + io = InputOutput(yes=True) + repo = GitRepo(io, [str(repo_dir)], None) + assert find_workspace_config_file(repo_dir) is None + assert getattr(repo, "workspace_layout", "clone") != "local" or not repo.is_workspace