diff --git a/.github/workflows/ubuntu-tests.yml b/.github/workflows/ubuntu-tests.yml index a70004f4e6c..fa5640f54e3 100644 --- a/.github/workflows/ubuntu-tests.yml +++ b/.github/workflows/ubuntu-tests.yml @@ -56,6 +56,12 @@ jobs: -r requirements/requirements-playwright.in \ ".[help,playwright]" + - name: Configure Git for tests + run: | + git config --global user.name "Test User" + git config --global user.email "test@example.com" + git config --global init.defaultBranch main + - name: Run tests run: | pytest diff --git a/.github/workflows/windows-tests.yml b/.github/workflows/windows-tests.yml index 223c3e4ea89..7dc96279288 100644 --- a/.github/workflows/windows-tests.yml +++ b/.github/workflows/windows-tests.yml @@ -44,6 +44,12 @@ jobs: pip install uv uv pip install --system pytest pytest-asyncio pytest-mock -r requirements/requirements.in -r requirements/requirements-help.in -r requirements/requirements-playwright.in '.[help,playwright]' + - name: Configure Git for tests + run: | + git config --global user.name "Test User" + git config --global user.email "test@example.com" + git config --global init.defaultBranch main + - name: Run tests run: | pytest diff --git a/cecli/__init__.py b/cecli/__init__.py index 2fd5d634443..770e502e99e 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.98.2.dev" +__version__ = "0.99.0.dev" safe_version = __version__ try: diff --git a/cecli/args.py b/cecli/args.py index 5d466b41275..395fbb17cd4 100644 --- a/cecli/args.py +++ b/cecli/args.py @@ -314,6 +314,19 @@ def get_parser(default_config_files, git_root): help="Specify TUI Mode configuration as a JSON string", default=None, ) + ######### + group = parser.add_argument_group("Workspace Settings") + group.add_argument( + "--workspaces", + type=str, + help="JSON/YAML configuration for workspace initialization", + ) + group.add_argument( + "--workspace-name", + type=str, + help="Specify the workspace name to activate", + ) + ######### group = parser.add_argument_group("Agent Settings") group.add_argument( diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index fb086c5c0aa..86916dd8ac3 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -1037,7 +1037,9 @@ def _generate_tool_context(self, repetitive_tools): ) self.model_kwargs["frequency_penalty"] = min(0, max(freq_penalty - 0.15, 0)) - self.model_kwargs["temperature"] = max(0, min(self.model_kwargs["temperature"], 1)) + self.model_kwargs["temperature"] = max( + 0, min(nested.getter(self.model_kwargs, "temperature", 1), 1) + ) # One twentieth of the time, just straight reset the randomness if random.random() < 0.05: self.model_kwargs = {} diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 6c107bf2f4d..c8462207b9a 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -58,6 +58,7 @@ ) from cecli.repo import ANY_GIT_ERROR, GitRepo from cecli.repomap import RepoMap +from cecli.report import update_error_prefix from cecli.run_cmd import run_cmd from cecli.sessions import SessionManager from cecli.tools.utils.output import print_tool_response @@ -531,6 +532,11 @@ def __init__( has_map_prompt = nested.getter(self, "gpt_prompts.repo_content_prefix") if use_repo_map and self.repo and has_map_prompt: + repo_root = ( + self.repo.workspace_path + if (self.repo and getattr(self.repo, "workspace_path", None)) + else self.root + ) self.repo_map = RepoMap( map_tokens, self.map_cache_dir, @@ -542,7 +548,7 @@ def __init__( map_mul_no_files=map_mul_no_files, refresh=map_refresh, max_code_line_length=map_max_line_length, - repo_root=self.root, + repo_root=repo_root, use_memory_cache=repomap_in_memory, use_enhanced_map=getattr(self.args, "use_enhanced_map", False), ) @@ -1508,6 +1514,9 @@ async def output_task(self, preproc): except (SwitchCoderSignal, SystemExit): raise except Exception as e: + traceback_str = traceback.format_exc() + update_error_prefix(traceback_str) + if self.verbose or self.args.debug: print(e) @@ -3651,13 +3660,14 @@ def get_all_relative_files(self): # Continue to get tracked files normally if self.repo: - if not self.repo.cecli_ignore_file or not self.repo.cecli_ignore_file.is_file(): + if hasattr(self.repo, "workspace_path") and self.repo.workspace_path: + files = self.repo.get_workspace_files() + elif not self.repo.cecli_ignore_file or not self.repo.cecli_ignore_file.is_file(): files = self.repo.get_tracked_files() else: files = self.repo.get_non_ignored_files_from_root() else: files = self.get_inchat_relative_files() - # This is quite slow in large repos # files = [fname for fname in files if self.is_file_safe(fname)] @@ -3919,6 +3929,13 @@ async def auto_commit(self, edited, context=None): if not self.repo or not self.auto_commits or self.dry_run: return + # Workspace-aware commit logic + if hasattr(self.args, "workspace") and self.args.workspace: + # We are in a workspace context. + # The GitRepo instance (self.repo) should already be pointing to the correct worktree/repo + # within the workspace because of the os.chdir in main.py and detection in repo.py. + pass + if not context: context = self.get_context_from_history( ConversationService.get_manager(self).get_messages_dict(MessageTag.CUR) diff --git a/cecli/commands/__init__.py b/cecli/commands/__init__.py index 9a3bcc6d08a..d608ffbd37f 100644 --- a/cecli/commands/__init__.py +++ b/cecli/commands/__init__.py @@ -79,6 +79,7 @@ from .voice import VoiceCommand from .weak_model import WeakModelCommand from .web import WebCommand +from .workspace import WorkspaceCommand # Register commands CommandRegistry.register(AddCommand) @@ -143,6 +144,7 @@ CommandRegistry.register(VoiceCommand) CommandRegistry.register(WeakModelCommand) CommandRegistry.register(WebCommand) +CommandRegistry.register(WorkspaceCommand) __all__ = [ @@ -220,4 +222,5 @@ "VoiceCommand", "WeakModelCommand", "WebCommand", + "WorkspaceCommand", ] diff --git a/cecli/commands/workspace.py b/cecli/commands/workspace.py new file mode 100644 index 00000000000..411dff86673 --- /dev/null +++ b/cecli/commands/workspace.py @@ -0,0 +1,72 @@ +import subprocess + +from cecli.commands.utils.base_command import BaseCommand + + +class WorkspaceCommand(BaseCommand): + NORM_NAME = "workspace" + DESCRIPTION = "Print information about the current workspace" + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + """Execute the workspace command.""" + if not coder or not coder.repo: + io.tool_output("No repository or workspace active.") + return + + workspace_path = getattr(coder.repo, "workspace_path", None) + if not workspace_path: + io.tool_output("Not currently working within a cecli workspace.") + return + + import json + + metadata_path = workspace_path / ".cecli-workspace.json" + config = {} + if metadata_path.exists(): + try: + with open(metadata_path, "r") as f: + config = json.load(f) + except Exception: + pass + + ws_name = config.get("name", workspace_path.name) + is_active = config.get("active", False) + + io.print(f"Current Workspace: {ws_name}{' (Active)' if is_active else ''}") + io.print(f"Root Directory: {workspace_path}") + io.print("-" * 40) + io.print("Projects:") + + projects = config.get("projects", []) + for proj in projects: + proj_name = proj.get("name") + if not proj_name: + continue + + proj_root = workspace_path / proj_name / "main" + branch_info = "Unknown" + if proj_root.exists(): + try: + branch_info = subprocess.check_output( + ["git", "-C", str(proj_root), "rev-parse", "--abbrev-ref", "HEAD"], + stderr=subprocess.DEVNULL, + encoding="utf-8", + ).strip() + except Exception: + branch_info = "Error retrieving branch" + + repo_url = proj.get("repo", "N/A") + io.print(f" - {proj_name}:") + io.print(f" Branch: {branch_info}") + io.print(f" Remote: {repo_url}") + io.print(f" Path: {proj_root}") + io.print("") + + @classmethod + def get_help(cls) -> str: + """Get help text for the workspace command.""" + help_text = super().get_help() + help_text += "\nUsage:\n" + help_text += " /workspace # Show details of the active monorepo workspace\n" + return help_text diff --git a/cecli/helpers/monorepo/__init__.py b/cecli/helpers/monorepo/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/cecli/helpers/monorepo/config.py b/cecli/helpers/monorepo/config.py new file mode 100644 index 00000000000..af8f4b39ad6 --- /dev/null +++ b/cecli/helpers/monorepo/config.py @@ -0,0 +1,149 @@ +import json +from pathlib import Path +from typing import Any, Dict, Optional + +import yaml + + +def resolve_workspace_config(config_arg: Optional[str] = None) -> Optional[Any]: + """ + Common logic to resolve workspace configuration from hierarchy: + 1. config_arg (JSON string) + 2. Local .cecli.workspaces.yml/yaml + 3. Global ~/.cecli/workspaces.yml/yaml + 4. Fallback to .cecli.conf.yml + """ + workspace_conf = None + + # 1. Try config_arg (JSON string from main.py) + if config_arg: + try: + loaded = json.loads(config_arg) + if isinstance(loaded, dict): + workspace_conf = loaded.get("workspaces") or loaded.get("workspace") or loaded + elif isinstance(loaded, list): + workspace_conf = loaded + except json.JSONDecodeError: + try: + loaded = yaml.safe_load(config_arg) + if isinstance(loaded, dict): + workspace_conf = loaded.get("workspaces") or loaded.get("workspace") or loaded + elif isinstance(loaded, list): + workspace_conf = loaded + except yaml.YAMLError: + pass + + # 2. Look for local .cecli.workspaces.yml/yaml + if not workspace_conf: + for local_name in [".cecli.workspaces.yml", ".cecli.workspaces.yaml"]: + local_path = Path(local_name) + if local_path.exists(): + try: + with open(local_path, "r") as f: + loaded = yaml.safe_load(f) + if loaded: + workspace_conf = ( + loaded.get("workspaces") or loaded.get("workspace") or loaded + ) + if workspace_conf: + break + except Exception: + pass + + # 3. Look for global ~/.cecli/workspaces.yml/yaml + if not workspace_conf: + for global_name in ["workspaces.yml", "workspaces.yaml"]: + global_path = Path.home() / ".cecli" / global_name + if global_path.exists(): + try: + with open(global_path, "r") as f: + loaded = yaml.safe_load(f) + if loaded: + workspace_conf = ( + loaded.get("workspaces") or loaded.get("workspace") or loaded + ) + if workspace_conf: + break + except Exception: + pass + + return workspace_conf + + +def load_workspace_config( + config_arg: Optional[str] = None, name: Optional[str] = None +) -> Dict[str, Any]: + """ + Load workspace configuration from hierarchy. + If name is provided, select that specific workspace from a list. + """ + workspace_conf = resolve_workspace_config(config_arg) + + config = {} + # Handle list of workspaces or single dict + if isinstance(workspace_conf, list): + if name: + selected_ws = next((ws for ws in workspace_conf if ws.get("name") == name), None) + if not selected_ws: + raise ValueError(f"Workspace '{name}' not found in configuration") + config = selected_ws + else: + active_workspaces = [ws for ws in workspace_conf if ws.get("active")] + if len(active_workspaces) > 1: + active_names = [ws.get("name", "unknown") for ws in active_workspaces] + raise ValueError(f"Multiple workspaces marked as active: {', '.join(active_names)}") + + active_ws = active_workspaces[0] if active_workspaces else None + + # If no workspace is explicitly marked active, but there is only one, use it + if not active_ws and len(workspace_conf) == 1: + active_ws = workspace_conf[0] + config = active_ws if active_ws else {} + elif isinstance(workspace_conf, dict): + config = workspace_conf + + validate_config(config) + return config + + +def validate_config(config: Dict[str, Any]) -> None: + """ + Minimal validation of required fields. + """ + if not config: + return + + if "name" not in config: + raise ValueError("Workspace configuration must include a 'name'") + + if "projects" not in config: + config["projects"] = [] + + project_names = set() + 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 project["name"] in project_names: + raise ValueError(f"Duplicate project name: {project['name']}") + project_names.add(project["name"]) + + +def find_active_workspace_name(config_arg: Optional[str] = None) -> Optional[str]: + """ + Find the name of the active workspace from the config without resolving it fully. + Used in main.py to automatically activate a workspace. + """ + workspace_conf = resolve_workspace_config(config_arg) + + if isinstance(workspace_conf, list): + active_ws = next((ws for ws in workspace_conf if ws.get("active")), None) + if active_ws: + return active_ws.get("name") + # If there's only one workspace, it's considered active + if len(workspace_conf) == 1: + return workspace_conf[0].get("name") + elif isinstance(workspace_conf, dict): + # If it's a single dict, it's considered active by default + return workspace_conf.get("name") + + return None diff --git a/cecli/helpers/monorepo/project.py b/cecli/helpers/monorepo/project.py new file mode 100644 index 00000000000..4c874508928 --- /dev/null +++ b/cecli/helpers/monorepo/project.py @@ -0,0 +1,89 @@ +import subprocess +from pathlib import Path +from typing import Any, Dict + +from cecli.helpers.monorepo.worktree import WorktreeManager + + +class Project: + def __init__(self, workspace_path: Path, config: Dict[str, Any]): + self.workspace_path = workspace_path + self.config = config + self.name = config["name"] + self.repo_url = config["repo"] + self.base_path = workspace_path / self.name + self.main_path = self.base_path / "main" + + def initialize(self) -> None: + """Clone the repository and setup worktrees.""" + if not self.main_path.exists(): + self.main_path.mkdir(parents=True, exist_ok=True) + + target_branch = self.config.get("branch") + use_current = self.config.get("use_current_branch", True) + + clone_cmd = ["git", "clone", "--depth", "1"] + if target_branch and not use_current: + clone_cmd += ["--branch", target_branch] + + clone_cmd += [self.repo_url, str(self.main_path)] + + subprocess.run(clone_cmd, check=True) + + # Ensure correct branch is checked out + target_branch = self.config.get("branch") + use_current = self.config.get("use_current_branch", True) + + if target_branch and not use_current: + try: + # Check current branch + current_branch = subprocess.check_output( + ["git", "-C", str(self.main_path), "rev-parse", "--abbrev-ref", "HEAD"], + encoding="utf-8", + ).strip() + + if current_branch != target_branch: + # Try to checkout directly + res = subprocess.run( + ["git", "-C", str(self.main_path), "checkout", target_branch], + check=False, + capture_output=True, + ) + + if res.returncode != 0: + # If checkout fails, check if it exists on origin + subprocess.run( + ["git", "-C", str(self.main_path), "fetch", "origin", target_branch], + check=False, + ) + + # Try checking out the remote branch + res = subprocess.run( + [ + "git", + "-C", + str(self.main_path), + "checkout", + "-b", + target_branch, + f"origin/{target_branch}", + ], + check=False, + capture_output=True, + ) + + if res.returncode != 0: + # If it still fails, it doesn't exist on origin, so create it locally + subprocess.run( + ["git", "-C", str(self.main_path), "checkout", "-b", target_branch], + check=True, + ) + except Exception: + # Fallback for unexpected errors + pass + # Handle worktrees + worktrees_config = self.config.get("worktrees", []) + if worktrees_config: + wt_manager = WorktreeManager(self.main_path) + for wt_cfg in worktrees_config: + wt_manager.create(wt_cfg["name"], wt_cfg["branch"]) diff --git a/cecli/helpers/monorepo/workspace.py b/cecli/helpers/monorepo/workspace.py new file mode 100644 index 00000000000..54fd22f4432 --- /dev/null +++ b/cecli/helpers/monorepo/workspace.py @@ -0,0 +1,36 @@ +import os +from pathlib import Path +from typing import Any, Dict + +from cecli.helpers.monorepo.project import Project + + +class WorkspaceManager: + def __init__(self, workspace_name: str, config: Dict[str, Any]): + self.name = workspace_name + self.config = config + self.path = Path(os.path.expanduser(f"~/.cecli/workspaces/{workspace_name}")) + + def exists(self) -> bool: + """Check if workspace directory exists""" + return self.path.exists() + + def initialize(self) -> None: + """Create workspace structure and clone repositories""" + self.path.mkdir(parents=True, exist_ok=True) + + projects_config = self.config.get("projects", []) + for proj_cfg in projects_config: + project = Project(self.path, proj_cfg) + project.initialize() + + # Write metadata + import json + + metadata_path = self.path / ".cecli-workspace.json" + with open(metadata_path, "w") as f: + json.dump(self.config, f, indent=2) + + def get_working_directory(self) -> Path: + """Return workspace root path for cd""" + return self.path diff --git a/cecli/helpers/monorepo/worktree.py b/cecli/helpers/monorepo/worktree.py new file mode 100644 index 00000000000..22be86b35f5 --- /dev/null +++ b/cecli/helpers/monorepo/worktree.py @@ -0,0 +1,21 @@ +import subprocess +from pathlib import Path + + +class WorktreeManager: + def __init__(self, main_repo_path: Path): + self.main_repo_path = main_repo_path + self.worktrees_dir = main_repo_path.parent / "worktrees" + + def create(self, name: str, branch: str) -> None: + """Create a git worktree.""" + wt_path = self.worktrees_dir / name + if wt_path.exists(): + return + + self.worktrees_dir.mkdir(parents=True, exist_ok=True) + + subprocess.run( + ["git", "-C", str(self.main_repo_path), "worktree", "add", str(wt_path), branch], + check=True, + ) diff --git a/cecli/main.py b/cecli/main.py index df8d5985558..3fe629c46c5 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -542,36 +542,56 @@ async def main_async(argv=None, input=None, output=None, force_git_root=None, re else: git_root = get_git_root() conf_fname = handle_core_files(Path(".cecli.conf.yml")) - default_config_files = [] - try: - default_config_files += [conf_fname.resolve()] - except OSError: - pass + default_config_files = [ + str(Path.home() / ".cecli.conf.yml"), + str(Path(".cecli.conf.yml")), + ] if git_root: - git_conf = Path(git_root) / conf_fname - if git_conf not in default_config_files: - default_config_files.append(git_conf) - default_config_files.append(Path.home() / conf_fname) - default_config_files = list(map(str, default_config_files)) - parser = get_parser(default_config_files, git_root) - try: - args, unknown = parser.parse_known_args(argv) - except AttributeError as e: - if all(word in str(e) for word in ["bool", "object", "has", "no", "attribute", "strip"]): - if check_config_files_for_yes(default_config_files): - return await graceful_exit(None, 1) - raise e - if args.verbose: - print("Config files search order, if no --config:") - for file in default_config_files: - exists = "(exists)" if Path(file).exists() else "" - print(f" - {file} {exists}") - default_config_files.reverse() + default_config_files.append(str(Path(git_root) / ".cecli.conf.yml")) parser = get_parser(default_config_files, git_root) args, unknown = parser.parse_known_args(argv) + + # Load dotenv files and re-parse args before workspace logic + # to allow environment variables to be used in workspace config loaded_dotenvs = load_dotenv_files(git_root, args.env_file, args.encoding) args, unknown = parser.parse_known_args(argv) + + uses_workspace = False + if args.workspaces or args.workspace_name: + from cecli.helpers.monorepo.config import ( + find_active_workspace_name, + load_workspace_config, + ) + from cecli.helpers.monorepo.workspace import WorkspaceManager + + # Interpolate environment variables in the workspaces argument + if args.workspaces: + args.workspaces = interpolate_env_vars(args.workspaces) + + ws_config_arg = convert_yaml_to_json_string(args.workspaces) if args.workspaces else None + ws_name = args.workspace_name or find_active_workspace_name(ws_config_arg) + if ws_name: + config = load_workspace_config(ws_config_arg, name=ws_name) + workspace_manager = WorkspaceManager(ws_name, config) + + if not workspace_manager.exists(): + workspace_manager.initialize() + + os.chdir(workspace_manager.get_working_directory()) + git_root = get_git_root() + uses_workspace = True + + if git_root: + git_conf = Path(git_root) / conf_fname + if git_conf not in default_config_files: + default_config_files.append(str(git_conf)) + + if uses_workspace: + parser = get_parser(default_config_files, git_root) + args, unknown = parser.parse_known_args(argv) + set_args_error_data(args) + if len(unknown): print("Unknown Args: ", unknown) @@ -589,6 +609,8 @@ async def main_async(argv=None, input=None, output=None, force_git_root=None, re args.retries = convert_yaml_to_json_string(args.retries) if hasattr(args, "hooks") and args.hooks is not None: args.hooks = convert_yaml_to_json_string(args.hooks) + if hasattr(args, "workspaces") and args.workspaces is not None: + args.hooks = convert_yaml_to_json_string(args.workspaces) # Interpolate environment variables in all string arguments for key, value in vars(args).items(): @@ -1152,7 +1174,7 @@ def apply_model_overrides(model_name): pre_init_io.tool_output() except KeyboardInterrupt: return await graceful_exit(coder, 1) - if args.git and not suppress_pre_init: + if args.git and not (suppress_pre_init or args.workspaces or args.workspace_name): git_root = await setup_git(git_root, pre_init_io) if args.gitignore: await check_gitignore(git_root, pre_init_io) diff --git a/cecli/repo.py b/cecli/repo.py index cd6e36a040e..0c508287a98 100644 --- a/cecli/repo.py +++ b/cecli/repo.py @@ -135,6 +135,34 @@ def __init__( if cecli_ignore_file: self.cecli_ignore_file = Path(cecli_ignore_file) + # Detect if we're in a workspace + self.workspace_path = self._detect_workspace_path(self.root) + if self.workspace_path: + self.io.tool_output(f"Working in workspace: {self.workspace_path.name}") + + def _detect_workspace_path(self, start_path: str): + """Check if current directory is within a workspace""" + current = Path(start_path).resolve() + workspace_root = Path("~/.cecli/workspaces").expanduser() + + # Walk up directory tree looking for workspace root + while current != current.parent: + if workspace_root in current.parents or current == workspace_root: + # If we are inside the workspace root, the workspace is the first child of workspace_root + try: + rel = current.relative_to(workspace_root) + if rel.parts: + return workspace_root / rel.parts[0] + except (IndexError, ValueError): + pass + + # Alternative check: look for .cecli-workspace.json + if (current / ".cecli-workspace.json").exists(): + return current + + current = current.parent + return None + def __del__(self): if self.repo: self.repo.close() @@ -503,6 +531,81 @@ def get_tracked_files(self): return res + def get_workspace_files(self): + """ + If in a workspace, return all tracked files from all projects. + Paths are relative to the workspace root. + """ + if not self.workspace_path: + return self.get_tracked_files() + + import hashlib + import json + import subprocess + + metadata_path = self.workspace_path / ".cecli-workspace.json" + if not metadata_path.exists(): + return self.get_tracked_files() + + try: + with open(metadata_path, "r") as f: + config = json.load(f) + 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: + 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: + res = subprocess.check_output( + ["git", "-C", str(proj_root), "ls-files"], + stderr=subprocess.DEVNULL, + encoding="utf-8", + ).splitlines() + + for f in res: + all_files.append(f"{proj_name}/main/{f}") + except Exception: + continue + + self._workspace_files_cache = (cache_key, all_files) + return all_files + def normalize_path(self, path): orig_path = path res = self.normalized_path.get(orig_path) diff --git a/cecli/tui/app.py b/cecli/tui/app.py index 7fac6e4a93b..e92feec1653 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -262,19 +262,25 @@ def compose(self) -> ComposeResult: coder_mode = getattr(coder, "edit_format", "code") or "code" # Get project name (just the folder name, not full path) - project_name = str(Path.cwd()) + home = str(Path.home()) + cwd = str(Path.cwd()) + if cwd.startswith(home): + project_name = cwd.replace(home, "~", 1) + else: + project_name = cwd if len(project_name) >= 64: project_name = project_name.split("/")[-1] if coder.repo: root_path = str(coder.repo.root) + if root_path.startswith(home): + root_path = root_path.replace(home, "~", 1) if len(root_path) <= 64: project_name = root_path else: project_name = root_path.split("/")[-1] - # Get history file path from coder's io history_file = getattr(coder.io, "input_history_file", None) diff --git a/cecli/website/docs/config/workspaces.md b/cecli/website/docs/config/workspaces.md new file mode 100644 index 00000000000..3841ce9b428 --- /dev/null +++ b/cecli/website/docs/config/workspaces.md @@ -0,0 +1,78 @@ +--- +parent: Configuration +nav_order: 41 +description: Workspaces allow you to work across multiple related repositories simultaneously +--- +# Workspaces + +Workspaces allow you to manage multiple independent git repositories within isolated environments. The system initializes workspaces with specified branches as git worktrees, enabling parallel development across multiple projects. + +## Configuration + +You can configure workspaces in multiple locations. `cecli` searches for configurations in the following order: + +1. **CLI Argument**: Via a JSON/YAML configuration or file path passed to the `--workspaces` argument. +2. **Local Workspaces File**: `.cecli.workspaces.yml` or `.cecli.workspaces.yaml` in the current directory. +3. **Global Workspaces File**: `~/.cecli/workspaces.yml` or `~/.cecli/workspaces.yaml`. + +### Example Configuration + +```yaml +workspaces: + name: "my-workspace" + projects: + - name: "frontend" + repo: "https://github.com/user/frontend.git" + branch: "main" + worktrees: + - name: "feature-auth" + branch: "feature/auth" + - name: "backend" + repo: "https://github.com/user/backend.git" + branch: "develop" + use_current_branch: true # Default: true. Set to false to force branch switching on init. +``` + +### Multiple Workspaces + +You can define a list of workspaces. Use the `active: true` flag to specify which one should be used by default when running `cecli` without the `--workspace-name` argument. **Note: At most one workspace can be marked as active.** +```yaml +workspaces: + - name: "project-a" + active: true + projects: + - name: "app" + repo: "https://github.com/user/app.git" + - name: "project-b" + projects: + - name: "api" + repo: "https://github.com/user/api.git" +``` + + +## Usage + +To use a workspace: + +```bash +cecli --workspace-name my-workspace +# OR if using a specific config file +cecli --workspaces path/to/workspaces.yml --workspace-name my-workspace + +If the workspace does not exist, `cecli` will create the directory structure at `~/.cecli/workspaces/my-workspace/` and clone the configured repositories. + +### Workspace Structure + +``` +~/.cecli/workspaces/ +└── workspace-name/ + ├── .cecli-workspace.json + └── project-name/ + ├── main/ # Main repository clone + └── worktrees/ # Additional worktrees +``` + +## Arguments + +- `--workspaces `: Provide a JSON/YAML configuration or file path for workspace initialization. +- `--workspace-name `: Specify the workspace name to activate from the configuration. diff --git a/pytest.ini b/pytest.ini index 34abfaea1f5..1c14c4bc82d 100644 --- a/pytest.ini +++ b/pytest.ini @@ -4,10 +4,13 @@ addopts = -p no:warnings asyncio_mode = auto testpaths = tests/basic - tests/tools - tests/help + tests/tools + tests/coders + tests/conversations + tests/helpers/monorepo tests/hooks tests/mcp + tests/help tests/browser tests/scrape diff --git a/tests/coders/test_copypaste_coder.py b/tests/coders/test_copypaste_coder.py index f7da13a5f1a..9a49165c28e 100644 --- a/tests/coders/test_copypaste_coder.py +++ b/tests/coders/test_copypaste_coder.py @@ -1,7 +1,7 @@ import hashlib import json from types import SimpleNamespace -from unittest.mock import MagicMock, call, patch +from unittest.mock import AsyncMock, MagicMock, call, patch import pytest @@ -51,7 +51,7 @@ async def test_send_uses_copy_paste_flow(monkeypatch): coder.partial_response_tool_calls = [] coder.partial_response_function_call = None coder.chat_completion_call_hashes = [] - coder.show_send_output = MagicMock() + coder.show_send_output = AsyncMock() coder.calculate_and_show_tokens_and_cost = MagicMock() def fake_preprocess_response(): diff --git a/tests/test_conversation_integration.py b/tests/conversations/test_conversation_integration.py similarity index 100% rename from tests/test_conversation_integration.py rename to tests/conversations/test_conversation_integration.py diff --git a/tests/test_conversation_system.py b/tests/conversations/test_conversation_system.py similarity index 100% rename from tests/test_conversation_system.py rename to tests/conversations/test_conversation_system.py diff --git a/tests/helpers/monorepo/test_config.py b/tests/helpers/monorepo/test_config.py new file mode 100644 index 00000000000..bade92cb61b --- /dev/null +++ b/tests/helpers/monorepo/test_config.py @@ -0,0 +1,50 @@ +import pytest + +from cecli.helpers.monorepo.config import validate_config + + +def test_validate_config_empty(): + # Should not raise + validate_config({}) + + +def test_validate_config_no_name(): + with pytest.raises(ValueError, match="Workspace configuration must include a 'name'"): + validate_config({"projects": []}) + + +def test_validate_config_invalid_project(): + with pytest.raises(ValueError, match="Each project must have a 'name' and 'repo' URL"): + validate_config({"name": "test", "projects": [{"name": "p1"}]}) + + +def test_validate_config_duplicate_project(): + with pytest.raises(ValueError, match="Duplicate project name: p1"): + validate_config( + { + "name": "test", + "projects": [{"name": "p1", "repo": "url1"}, {"name": "p1", "repo": "url2"}], + } + ) + + +def test_validate_config_valid(): + config = {"name": "test", "projects": [{"name": "p1", "repo": "url1"}]} + validate_config(config) + assert config["projects"][0]["name"] == "p1" + + +def test_load_workspace_config_json_string(): + from cecli.helpers.monorepo.config import load_workspace_config + + config_str = '{"name": "json-ws", "projects": []}' + config = load_workspace_config(config_str) + assert config["name"] == "json-ws" + + +def test_load_workspace_config_yaml_string(): + from cecli.helpers.monorepo.config import load_workspace_config + + config_str = "name: yaml-ws\nprojects: []" + config = load_workspace_config(config_str) + assert config["name"] == "yaml-ws" diff --git a/tests/helpers/monorepo/test_config_active.py b/tests/helpers/monorepo/test_config_active.py new file mode 100644 index 00000000000..95c27944e93 --- /dev/null +++ b/tests/helpers/monorepo/test_config_active.py @@ -0,0 +1,79 @@ +import pytest + +from cecli.helpers.monorepo.config import load_workspace_config + + +def test_load_workspace_config_multiple_active_error(): + config_list = [ + {"name": "ws1", "active": True, "projects": []}, + {"name": "ws2", "active": True, "projects": []}, + ] + + # Mocking what would be in the config file/arg + with pytest.raises(ValueError, match="Multiple workspaces marked as active: ws1, ws2"): + # We simulate the loaded config being a list + from unittest.mock import mock_open, patch + + import yaml + + with patch("pathlib.Path.exists", return_value=True): + with patch("builtins.open", mock_open(read_data=yaml.dump({"workspace": config_list}))): + load_workspace_config() + + +def test_load_workspace_config_select_by_name(): + config_list = [ + {"name": "ws1", "active": True, "projects": []}, + {"name": "ws2", "active": False, "projects": []}, + ] + + from unittest.mock import mock_open, patch + + import yaml + + with patch("pathlib.Path.exists", return_value=True): + with patch("builtins.open", mock_open(read_data=yaml.dump({"workspace": config_list}))): + # Should select ws2 even if ws1 is active + config = load_workspace_config(name="ws2") + assert config["name"] == "ws2" + + +def test_load_workspace_config_no_active_uses_first_if_only_one(): + config_list = [{"name": "ws1", "projects": []}] + + from unittest.mock import mock_open, patch + + import yaml + + with patch("pathlib.Path.exists", return_value=True): + with patch("builtins.open", mock_open(read_data=yaml.dump({"workspace": config_list}))): + config = load_workspace_config() + assert config["name"] == "ws1" + + +def test_load_workspace_config_single_dict_is_active_by_default(): + config_dict = {"name": "single-ws", "projects": []} + + from unittest.mock import mock_open, patch + + import yaml + + with patch("pathlib.Path.exists", return_value=True): + with patch("builtins.open", mock_open(read_data=yaml.dump({"workspace": config_dict}))): + # Should work even if no name is passed and active is not set + config = load_workspace_config() + assert config["name"] == "single-ws" + + +def test_load_workspace_config_multiple_in_list_none_active_picks_none(): + config_list = [{"name": "ws1", "projects": []}, {"name": "ws2", "projects": []}] + + from unittest.mock import mock_open, patch + + import yaml + + with patch("pathlib.Path.exists", return_value=True): + with patch("builtins.open", mock_open(read_data=yaml.dump({"workspace": config_list}))): + # With multiple and none active, it should return empty if no name provided + config = load_workspace_config() + assert config == {} diff --git a/tests/helpers/monorepo/test_repomap_workspace.py b/tests/helpers/monorepo/test_repomap_workspace.py new file mode 100644 index 00000000000..2ef3a1f514e --- /dev/null +++ b/tests/helpers/monorepo/test_repomap_workspace.py @@ -0,0 +1,227 @@ +import json +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from cecli.io import InputOutput +from cecli.repo import GitRepo + + +@pytest.fixture +def mock_workspace(tmp_path): + workspace_root = tmp_path / "workspace" + workspace_root.mkdir() + + # Project 1 + p1_dir = workspace_root / "p1" / "main" + p1_dir.mkdir(parents=True) + subprocess.run(["git", "init"], cwd=p1_dir, check=True) + (p1_dir / "file1.py").write_text("def func1(): pass") + subprocess.run(["git", "add", "file1.py"], cwd=p1_dir, check=True) + subprocess.run(["git", "commit", "-m", "p1 init"], cwd=p1_dir, check=True) + + # Project 2 + p2_dir = workspace_root / "p2" / "main" + p2_dir.mkdir(parents=True) + subprocess.run(["git", "init"], cwd=p2_dir, check=True) + (p2_dir / "file2.py").write_text("def func2(): pass") + subprocess.run(["git", "add", "file2.py"], cwd=p2_dir, check=True) + subprocess.run(["git", "commit", "-m", "p2 init"], cwd=p2_dir, check=True) + + # Workspace metadata + config = { + "name": "test-ws", + "projects": [{"name": "p1", "repo": "url1"}, {"name": "p2", "repo": "url2"}], + } + (workspace_root / ".cecli-workspace.json").write_text(json.dumps(config)) + + return workspace_root + + +def test_get_workspace_files(mock_workspace): + io = MagicMock(spec=InputOutput) + # Initialize GitRepo in p1 but it should detect the workspace + repo = GitRepo(io, [], str(mock_workspace / "p1" / "main")) + + # Force workspace_path for the test since _detect_workspace_path looks in ~/.cecli/workspaces + repo.workspace_path = mock_workspace + + files = repo.get_workspace_files() + assert "p1/main/file1.py" in files + assert "p2/main/file2.py" in files + assert len(files) == 2 + + +@pytest.mark.asyncio +async def test_coder_get_all_relative_files_workspace_integration(mock_workspace): + io = MagicMock() + repo = GitRepo(io, [], str(mock_workspace / "p1" / "main")) + repo.workspace_path = mock_workspace + + # Create a Coder-like object without full __init__ + class SimpleCoder: + def __init__(self, repo): + self.repo = repo + self.in_chat_files = [] + + def get_inchat_relative_files(self): + return self.in_chat_files + + def get_all_relative_files(self): + # Verify the logic we implemented in base_coder.py + if self.repo: + if hasattr(self.repo, "workspace_path") and self.repo.workspace_path: + files = self.repo.get_workspace_files() + elif not self.repo.cecli_ignore_file or not self.repo.cecli_ignore_file.is_file(): + files = self.repo.get_tracked_files() + else: + files = self.repo.get_non_ignored_files_from_root() + else: + files = self.get_inchat_relative_files() + return files + + coder = SimpleCoder(repo) + files = coder.get_all_relative_files() + + assert "p1/main/file1.py" in files + assert "p2/main/file2.py" in files + assert len(files) == 2 + + +def test_repo_root_detection_for_repomap(mock_workspace): + io = MagicMock() + repo = GitRepo(io, [], str(mock_workspace / "p1" / "main")) + repo.workspace_path = mock_workspace + + # Verify that the logic we added to base_coder.py picks the workspace root + repo_root = ( + repo.workspace_path if (repo and getattr(repo, "workspace_path", None)) else Path(repo.root) + ) + + assert repo_root == mock_workspace + assert (repo_root / ".cecli-workspace.json").exists() + + +def test_project_branch_switching(mock_workspace): + # Setup: Create a new branch in p1 + p1_dir = mock_workspace / "p1" / "main" + subprocess.run(["git", "checkout", "-b", "feature/xyz"], cwd=p1_dir, check=True) + (p1_dir / "feature.py").write_text("print('feature')") + subprocess.run(["git", "add", "feature.py"], cwd=p1_dir, check=True) + subprocess.run(["git", "commit", "-m", "feature commit"], cwd=p1_dir, check=True) + + # Go back to master + subprocess.run(["git", "checkout", "main"], cwd=p1_dir, check=True) + + # Define config with the new branch + config = { + "name": "test-ws", + "projects": [ + {"name": "p1", "repo": "url1", "branch": "feature/xyz", "use_current_branch": False} + ], + } + + from cecli.helpers.monorepo.workspace import WorkspaceManager + + wm = WorkspaceManager("test-ws", config) + wm.path = mock_workspace # Override path for test + + # Re-initialize should trigger checkout + wm.initialize() + + # Verify p1 is now on feature/xyz + current_branch = subprocess.check_output( + ["git", "-C", str(p1_dir), "rev-parse", "--abbrev-ref", "HEAD"], encoding="utf-8" + ).strip() + + assert current_branch == "feature/xyz" + assert (p1_dir / "feature.py").exists() + + +def test_project_use_current_branch_flag(mock_workspace): + # Setup: p1 is on master + p1_dir = mock_workspace / "p1" / "main" + subprocess.run(["git", "checkout", "main"], cwd=p1_dir, check=True) + + # Define config with a different branch but use_current_branch=True + config = { + "name": "test-ws", + "projects": [ + {"name": "p1", "repo": "url1", "branch": "feature/xyz", "use_current_branch": True} + ], + } + + from cecli.helpers.monorepo.workspace import WorkspaceManager + + wm = WorkspaceManager("test-ws", config) + wm.path = mock_workspace + + # Re-initialize should NOT trigger checkout + wm.initialize() + + # Verify p1 is still on main + current_branch = subprocess.check_output( + ["git", "-C", str(p1_dir), "rev-parse", "--abbrev-ref", "HEAD"], encoding="utf-8" + ).strip() + + assert current_branch == "main" + + +def test_get_workspace_files_caching(mock_workspace): + io = MagicMock() + repo = GitRepo(io, [], str(mock_workspace / "p1" / "main")) + repo.workspace_path = mock_workspace + + # First call - should populate cache + with patch("subprocess.check_output", wraps=subprocess.check_output) as mock_run: + files1 = repo.get_workspace_files() + # Should have called rev-parse (2x) and ls-files (2x) + assert mock_run.call_count >= 4 + + # Second call - should use cache + with patch("subprocess.check_output", wraps=subprocess.check_output) as mock_run: + files2 = repo.get_workspace_files() + # Should only call rev-parse to check SHAs (2x), NOT ls-files + # Total calls = number of projects (2) + assert mock_run.call_count == 2 + assert files1 == files2 + + # Modify a project - should invalidate cache + p1_dir = mock_workspace / "p1" / "main" + (p1_dir / "new_file.py").write_text("test") + subprocess.run(["git", "add", "new_file.py"], cwd=p1_dir, check=True) + subprocess.run(["git", "commit", "-m", "new file"], cwd=p1_dir, check=True) + + with patch("subprocess.check_output", wraps=subprocess.check_output) as mock_run: + files3 = repo.get_workspace_files() + # Should call rev-parse (2x) and then ls-files (2x) because SHAs changed + assert mock_run.call_count >= 4 + assert "p1/main/new_file.py" in files3 + + +@pytest.mark.asyncio +async def test_workspace_command(mock_workspace): + from cecli.commands.workspace import WorkspaceCommand + from cecli.io import InputOutput + + io = MagicMock(spec=InputOutput) + repo = MagicMock() + repo.workspace_path = mock_workspace + repo.root = str(mock_workspace / "p1" / "main") + + coder = MagicMock() + coder.repo = repo + + await WorkspaceCommand.execute(io, coder, None) + + # Check if io.print was called with workspace info + # WorkspaceCommand prints name, root, then projects + io.print.assert_any_call("Current Workspace: test-ws") + io.print.assert_any_call(f"Root Directory: {mock_workspace}") + + # Verify project details were printed + # The output includes project name, branch, remote, path + io.print.assert_any_call(" - p1:") + io.print.assert_any_call(" - p2:") diff --git a/tests/helpers/monorepo/test_workspace.py b/tests/helpers/monorepo/test_workspace.py new file mode 100644 index 00000000000..b1db56247b9 --- /dev/null +++ b/tests/helpers/monorepo/test_workspace.py @@ -0,0 +1,52 @@ +import json +from unittest.mock import patch + +import pytest + +from cecli.helpers.monorepo.workspace import WorkspaceManager + + +@pytest.fixture +def temp_workspace_root(tmp_path): + workspace_root = tmp_path / ".cecli" / "workspaces" + workspace_root.mkdir(parents=True) + + def mock_expand(path): + if path.startswith("~/.cecli/workspaces"): + return str(workspace_root / path.replace("~/.cecli/workspaces", "").lstrip("/")) + return path + + with patch("os.path.expanduser", side_effect=mock_expand): + yield workspace_root + + +def test_workspace_manager_exists(temp_workspace_root): + config = {"name": "test-ws", "projects": []} + wm = WorkspaceManager("test-ws", config) + assert not wm.exists() + + wm.path.mkdir(parents=True) + assert wm.exists() + + +@patch("cecli.helpers.monorepo.project.Project.initialize") +def test_workspace_manager_initialize(mock_proj_init, temp_workspace_root): + config = { + "name": "test-ws", + "projects": [{"name": "p1", "repo": "url1"}, {"name": "p2", "repo": "url2"}], + } + wm = WorkspaceManager("test-ws", config) + wm.initialize() + + assert wm.exists() + assert (wm.path / ".cecli-workspace.json").exists() + assert mock_proj_init.call_count == 2 + + with open(wm.path / ".cecli-workspace.json", "r") as f: + saved_config = json.load(f) + assert saved_config["name"] == "test-ws" + + +def test_workspace_manager_get_working_directory(temp_workspace_root): + wm = WorkspaceManager("test-ws", {}) + assert wm.get_working_directory() == temp_workspace_root / "test-ws"