diff --git a/agent_cli/_tools.py b/agent_cli/_tools.py index 1a7ce8957..9f068b024 100644 --- a/agent_cli/_tools.py +++ b/agent_cli/_tools.py @@ -130,7 +130,7 @@ def execute_code(code: str) -> str: except subprocess.CalledProcessError as e: return f"Error executing code: {e.stderr}" except FileNotFoundError: - return f"Error: Command not found: {code.split()[0]}" + return f"Error: Command not found: {code.split(maxsplit=1)[0]}" def add_memory(content: str, category: str = "general", tags: str = "") -> str: diff --git a/agent_cli/agents/transcribe_live.py b/agent_cli/agents/transcribe_live.py index f5e8640c3..d4c61220c 100644 --- a/agent_cli/agents/transcribe_live.py +++ b/agent_cli/agents/transcribe_live.py @@ -188,7 +188,7 @@ async def _process_segment( # noqa: PLR0912 if cfg.clipboard: import pyperclip # noqa: PLC0415 - text_to_copy = processed if processed else transcript + text_to_copy = processed or transcript pyperclip.copy(text_to_copy) # Log diff --git a/agent_cli/config.py b/agent_cli/config.py index 636de280e..2a3f86cbd 100644 --- a/agent_cli/config.py +++ b/agent_cli/config.py @@ -264,6 +264,9 @@ class Dev(BaseModel): default_agent: str | None = None default_editor: str | None = None + branch_name_mode: Literal["random", "auto", "ai"] = "random" + branch_name_agent: Literal["claude", "codex", "gemini"] | None = None + branch_name_timeout: float = 20.0 # seconds agent_args: dict[str, list[str]] | None = ( None # Per-agent args, e.g. {"claude": ["--dangerously-skip-permissions"]} ) @@ -319,16 +322,28 @@ def normalize_provider_defaults(cfg: dict[str, Any]) -> dict[str, Any]: return normalized -def _replace_dashed_keys(cfg: dict[str, Any]) -> dict[str, Any]: - return {k.replace("-", "_"): v for k, v in cfg.items()} +def _replace_dashed_keys(cfg: Any) -> Any: + """Recursively replace dashed keys in dictionaries.""" + if isinstance(cfg, dict): + return {k.replace("-", "_"): _replace_dashed_keys(v) for k, v in cfg.items()} + return cfg def _flatten_nested_sections(cfg: dict[str, Any], prefix: str = "") -> dict[str, Any]: - """Flatten nested TOML sections: {"a": {"b": {"x": 1}}} -> {"a.b": {"x": 1}}.""" - result = {} + """Flatten nested TOML sections while preserving scalar parent options. + + Example: + {"a": {"x": 1, "b": {"y": 2}}} -> {"a": {"x": 1}, "a.b": {"y": 2}} + {"a": {"b": {"x": 1}}} -> {"a.b": {"x": 1}} + + """ + result: dict[str, Any] = {} for key, value in cfg.items(): full_key = f"{prefix}.{key}" if prefix else key if isinstance(value, dict) and any(isinstance(v, dict) for v in value.values()): + scalar_items = {k: v for k, v in value.items() if not isinstance(v, dict)} + if scalar_items: + result[full_key] = scalar_items result.update(_flatten_nested_sections(value, full_key)) else: result[full_key] = value diff --git a/agent_cli/core/deps.py b/agent_cli/core/deps.py index 7ee0d8747..10254ebd0 100644 --- a/agent_cli/core/deps.py +++ b/agent_cli/core/deps.py @@ -61,7 +61,7 @@ def _is_uvx_cache() -> bool: def _check_package_installed(pkg: str) -> bool: """Check if a single package is installed.""" - top_module = pkg.split(".")[0] + top_module = pkg.split(".", maxsplit=1)[0] try: return find_spec(top_module) is not None except (ValueError, ModuleNotFoundError): diff --git a/agent_cli/dev/_branch_name.py b/agent_cli/dev/_branch_name.py new file mode 100644 index 000000000..4cce52b35 --- /dev/null +++ b/agent_cli/dev/_branch_name.py @@ -0,0 +1,371 @@ +"""Branch name generation for dev worktrees.""" + +from __future__ import annotations + +import json +import random +import re +import shutil +import subprocess +from typing import TYPE_CHECKING + +from agent_cli.core.utils import err_console + +from . import worktree + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + +AGENTS: tuple[str, ...] = ("claude", "codex", "gemini") + +_ADJECTIVES = [ + "happy", + "clever", + "swift", + "bright", + "calm", + "eager", + "fancy", + "gentle", + "jolly", + "keen", + "lively", + "merry", + "nice", + "proud", + "quick", + "sharp", + "smart", + "sunny", + "witty", + "zesty", + "bold", + "cool", + "fresh", + "grand", +] +_NOUNS = [ + "fox", + "owl", + "bear", + "wolf", + "hawk", + "lion", + "tiger", + "eagle", + "falcon", + "otter", + "panda", + "raven", + "shark", + "whale", + "zebra", + "bison", + "crane", + "dolphin", + "gecko", + "heron", + "koala", + "lemur", + "moose", + "newt", + "oriole", +] + +_MAX_BRANCH_NAME_LEN = 80 +_MAX_BRANCH_TASK_LEN = 1200 +_CLAUDE_BRANCH_SCHEMA = json.dumps( + { + "type": "object", + "properties": { + "branch": { + "type": "string", + "pattern": r"^[a-z0-9][a-z0-9._/-]{1,79}$", + }, + }, + "required": ["branch"], + "additionalProperties": False, + }, + separators=(",", ":"), +) + + +def _branch_exists_in_repo(repo_root: Path, branch_name: str) -> bool: + """Check whether a branch already exists locally or on origin.""" + return any(worktree.check_branch_exists(branch_name, repo_root)) + + +def _ensure_unique_branch_name( + base_name: str, + existing_branches: set[str] | None = None, + *, + repo_root: Path | None = None, +) -> str: + """Add a numeric suffix when a branch name collides.""" + existing = existing_branches or set() + + def is_available(candidate: str) -> bool: + if candidate in existing: + return False + return repo_root is None or not _branch_exists_in_repo(repo_root, candidate) + + if is_available(base_name): + return base_name + + for i in range(2, 100): + candidate = f"{base_name}-{i}" + if is_available(candidate): + return candidate + + for _ in range(20): + candidate = f"{base_name}-{random.randint(100, 999)}" # noqa: S311 + if is_available(candidate): + return candidate + + # Last resort: large range, unchecked (98 sequential + 20 random exhausted) + return f"{base_name}-{random.randint(1000, 9999)}" # noqa: S311 + + +def _parse_json_lines(output: str) -> list[dict[str, object]]: + """Parse JSONL output and ignore non-JSON lines.""" + parsed: list[dict[str, object]] = [] + for raw_line in output.splitlines(): + stripped_line = raw_line.strip() + if not stripped_line: + continue + try: + item = json.loads(stripped_line) + except json.JSONDecodeError: + continue + if isinstance(item, dict): + parsed.append(item) + return parsed + + +def _extract_branch_from_claude_output(output: str) -> str | None: + """Extract branch name from `claude -p --output-format json` output.""" + for event in reversed(_parse_json_lines(output)): + structured = event.get("structured_output") + if isinstance(structured, dict): + branch = structured.get("branch") + if isinstance(branch, str) and branch.strip(): + return branch + result = event.get("result") + if isinstance(result, str) and result.strip(): + return result + return None + + +def _extract_branch_from_codex_output(output: str) -> str | None: + """Extract branch name from `codex exec --json` output.""" + branch: str | None = None + for event in _parse_json_lines(output): + if event.get("type") != "item.completed": + continue + item = event.get("item") + if not isinstance(item, dict): + continue + if item.get("type") != "agent_message": + continue + text = item.get("text") + if isinstance(text, str) and text.strip(): + branch = text + return branch + + +def _extract_branch_from_gemini_output(output: str) -> str | None: + """Extract branch name from `gemini -p -o json` output.""" + for raw_line in output.splitlines(): + stripped = raw_line.strip() + if not stripped: + continue + try: + item = json.loads(stripped) + except json.JSONDecodeError: + continue + if isinstance(item, dict): + response = item.get("response") + if isinstance(response, str) and response.strip(): + return response + return None + + +def _normalize_ai_branch_candidate(candidate: str, repo_root: Path) -> str | None: + """Normalize model output into a safe branch slug.""" + lines = [line.strip() for line in candidate.replace("`", "").splitlines() if line.strip()] + if not lines: + return None + + branch = lines[0].strip().strip("'\"") + branch = re.sub(r"^(branch|name)\s*:\s*", "", branch, flags=re.IGNORECASE) + branch = branch.lower() + branch = re.sub(r"\s+", "-", branch) + branch = re.sub(r"[^a-z0-9._/-]", "-", branch) + branch = re.sub(r"/{2,}", "/", branch) + branch = re.sub(r"-{2,}", "-", branch) + branch = branch.strip("./-") + if len(branch) > _MAX_BRANCH_NAME_LEN: + branch = branch[:_MAX_BRANCH_NAME_LEN].rstrip("./-") + if not branch: + return None + + try: + result = subprocess.run( + ["git", "check-ref-format", "--branch", branch], # noqa: S607 + cwd=repo_root, + check=False, + capture_output=True, + text=True, + ) + except OSError: + return None + return branch if result.returncode == 0 else None + + +def _build_branch_naming_prompt( + repo_root: Path, + prompt: str | None, + from_ref: str | None, +) -> str: + """Build a constrained prompt for branch name generation.""" + task = (prompt or "").strip() + if not task: + task = "General maintenance task." + if len(task) > _MAX_BRANCH_TASK_LEN: + task = task[:_MAX_BRANCH_TASK_LEN] + "..." + + base_ref = from_ref or "default branch" + return ( + "Generate exactly one git branch name.\n" + "Return only the branch name and nothing else.\n" + "Do not use tools, do not inspect files, and do not ask follow-up questions.\n" + "Rules:\n" + "- lowercase ascii only\n" + "- allowed characters: a-z 0-9 / - _ .\n" + "- no spaces, no backticks, no explanation\n" + "- max 80 characters\n" + f"Repository: {repo_root.name}\n" + f"Base ref: {base_ref}\n" + f"Task: {task}\n" + ) + + +def _generate_branch_name_with_agent( + agent_name: str, + repo_root: Path, + prompt: str | None, + from_ref: str | None, + timeout_seconds: float, +) -> str | None: + """Run a headless agent to generate a branch name.""" + naming_prompt = _build_branch_naming_prompt(repo_root, prompt, from_ref) + + agent_commands: dict[str, tuple[list[str], Callable[[str], str | None]]] = { + "claude": ( + [ + "claude", + "-p", + "--output-format", + "json", + "--permission-mode", + "plan", + "--no-session-persistence", + "--json-schema", + _CLAUDE_BRANCH_SCHEMA, + naming_prompt, + ], + _extract_branch_from_claude_output, + ), + "codex": ( + [ + "codex", + "-a", + "never", + "exec", + "-s", + "read-only", + "--json", + naming_prompt, + ], + _extract_branch_from_codex_output, + ), + "gemini": ( + ["gemini", "-p", naming_prompt, "-o", "json"], + _extract_branch_from_gemini_output, + ), + } + + entry = agent_commands.get(agent_name) + if entry is None: + return None + command, extractor = entry + + try: + result = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + timeout=timeout_seconds, + cwd=repo_root, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + + raw_branch = extractor(result.stdout) + if not raw_branch: + return None + return _normalize_ai_branch_candidate(raw_branch, repo_root) + + +def generate_ai_branch_name( + repo_root: Path, + existing_branches: set[str], + prompt: str | None, + from_ref: str | None, + preferred_agent: str | None, + timeout_seconds: float, +) -> str | None: + """Generate an AI branch name, trying available agents in order.""" + if preferred_agent: + agent = preferred_agent.lower().strip() + if agent not in AGENTS or shutil.which(agent) is None: + return None + agents = [agent] + else: + agents = [a for a in AGENTS if shutil.which(a)] + + for agent_name in agents: + with err_console.status(f"Generating branch name with {agent_name}..."): + branch = _generate_branch_name_with_agent( + agent_name, + repo_root, + prompt, + from_ref, + timeout_seconds, + ) + if branch: + return _ensure_unique_branch_name( + branch, + existing_branches, + repo_root=repo_root, + ) + + return None + + +def generate_random_branch_name( + existing_branches: set[str] | None = None, + *, + repo_root: Path | None = None, +) -> str: + """Generate a unique random branch name like 'clever-fox'. + + If the name already exists, adds a numeric suffix (clever-fox-2). + """ + existing = existing_branches or set() + base = f"{random.choice(_ADJECTIVES)}-{random.choice(_NOUNS)}" # noqa: S311 + return _ensure_unique_branch_name(base, existing, repo_root=repo_root) diff --git a/agent_cli/dev/cli.py b/agent_cli/dev/cli.py index 2cdaa6bdd..d8eb66687 100644 --- a/agent_cli/dev/cli.py +++ b/agent_cli/dev/cli.py @@ -4,13 +4,12 @@ import json import os -import random import shlex import shutil import subprocess import tempfile from pathlib import Path -from typing import TYPE_CHECKING, Annotated, NoReturn +from typing import TYPE_CHECKING, Annotated, Literal, NoReturn import typer from rich.panel import Panel @@ -22,85 +21,11 @@ from agent_cli.core.process import set_process_title from agent_cli.core.utils import console, err_console -# Word lists for generating random branch names (like Docker container names) -_ADJECTIVES = [ - "happy", - "clever", - "swift", - "bright", - "calm", - "eager", - "fancy", - "gentle", - "jolly", - "keen", - "lively", - "merry", - "nice", - "proud", - "quick", - "sharp", - "smart", - "sunny", - "witty", - "zesty", - "bold", - "cool", - "fresh", - "grand", -] -_NOUNS = [ - "fox", - "owl", - "bear", - "wolf", - "hawk", - "lion", - "tiger", - "eagle", - "falcon", - "otter", - "panda", - "raven", - "shark", - "whale", - "zebra", - "bison", - "crane", - "dolphin", - "gecko", - "heron", - "koala", - "lemur", - "moose", - "newt", - "oriole", -] - - -def _generate_branch_name(existing_branches: set[str] | None = None) -> str: - """Generate a unique random branch name like 'clever-fox'. - - If the name already exists, adds a numeric suffix (clever-fox-2). - """ - existing = existing_branches or set() - base = f"{random.choice(_ADJECTIVES)}-{random.choice(_NOUNS)}" # noqa: S311 - - if base not in existing: - return base - - # Add numeric suffix to avoid collision - for i in range(2, 100): - candidate = f"{base}-{i}" - if candidate not in existing: - return candidate - - # Fallback: add random digits - return f"{base}-{random.randint(100, 999)}" # noqa: S311 - - -from . import coding_agents, editors, terminals, worktree # noqa: E402 -from .project import ( # noqa: E402 +from . import coding_agents, editors, terminals, worktree +from ._branch_name import AGENTS as _BRANCH_NAME_AGENTS +from ._branch_name import generate_ai_branch_name as _generate_ai_branch_name +from ._branch_name import generate_random_branch_name as _generate_branch_name +from .project import ( copy_env_files, detect_project_type, is_direnv_available, @@ -156,6 +81,17 @@ def dev_callback( has its own branch, allowing parallel development without stashing changes. """ set_config_defaults(ctx, config_file) + + # The [dev] section config is intended for `dev new` options. + # Click expects subcommand defaults under ctx.default_map["new"]. + if isinstance(ctx.default_map, dict): + flat_defaults = {k: v for k, v in ctx.default_map.items() if not isinstance(v, dict)} + nested_defaults = {k: dict(v) for k, v in ctx.default_map.items() if isinstance(v, dict)} + + if flat_defaults: + nested_defaults["new"] = {**flat_defaults, **nested_defaults.get("new", {})} + ctx.default_map = nested_defaults + if ctx.invoked_subcommand is not None: set_process_title(f"dev-{ctx.invoked_subcommand}") @@ -311,7 +247,7 @@ def _get_config_agent_env() -> dict[str, dict[str, str]] | None: agent_name = key[len(prefix) :] result[agent_name] = value - return result if result else None + return result or None def _get_agent_env(agent: CodingAgent) -> dict[str, str]: @@ -350,7 +286,7 @@ def _merge_agent_args( if cli_args: result.extend(cli_args) - return result if result else None + return result or None def _is_ssh_session() -> bool: @@ -488,7 +424,7 @@ def new( # noqa: C901, PLR0912, PLR0915 branch: Annotated[ str | None, typer.Argument( - help="Branch name for the worktree. If omitted, generates a random name like 'clever-fox'", + help="Branch name for the worktree. If omitted, auto-generates one (random by default, AI with --branch-name-mode)", ), ] = None, from_ref: Annotated[ @@ -558,6 +494,29 @@ def new( # noqa: C901, PLR0912, PLR0915 help="Run 'git fetch' before creating the worktree to ensure refs are up-to-date", ), ] = True, + branch_name_mode: Annotated[ + Literal["random", "auto", "ai"], + typer.Option( + "--branch-name-mode", + case_sensitive=False, + help="How to auto-name branches when BRANCH is omitted: random (default), auto (AI only when --prompt/--prompt-file is set), or ai (always try AI first)", + ), + ] = "random", + branch_name_agent: Annotated[ + str | None, + typer.Option( + "--branch-name-agent", + help="Headless agent for AI branch naming: claude, codex, or gemini. If omitted, uses --with-agent when supported, otherwise tries available agents in that order", + ), + ] = None, + branch_name_timeout: Annotated[ + float, + typer.Option( + "--branch-name-timeout", + min=1.0, + help="Timeout in seconds for AI branch naming command", + ), + ] = 20.0, direnv: Annotated[ bool | None, typer.Option( @@ -619,6 +578,7 @@ def new( # noqa: C901, PLR0912, PLR0915 - `dev new feature-x -a` — Create + start Claude/detected agent - `dev new feature-x -e -a` — Create + open editor + start agent - `dev new -a --prompt "Fix auth bug"` — Auto-named branch + agent with task + - `dev new --branch-name-mode ai -a --prompt "Refactor auth flow"` — AI-generated branch name - `dev new hotfix --from v1.2.3` — Branch from a tag instead of main - `dev new feature-x --from origin/develop` — Branch from develop instead - `dev new feature-x --with-agent aider --with-editor cursor` — Specific tools @@ -637,8 +597,36 @@ def new( # noqa: C901, PLR0912, PLR0915 if branch is None: # Get existing branches to avoid collisions existing = {wt.branch for wt in worktree.list_worktrees() if wt.branch} - branch = _generate_branch_name(existing) - _info(f"Generated branch name: {branch}") + # In auto mode, only use AI naming when we have task context. + if branch_name_mode == "auto" and not prompt: + use_ai = False + else: + use_ai = branch_name_mode != "random" + + if not use_ai: + branch = _generate_branch_name(existing, repo_root=repo_root) + _info(f"Generated branch name: {branch}") + else: + effective_branch_name_agent = branch_name_agent + if effective_branch_name_agent is None and agent_name: + candidate = agent_name.lower().strip() + if candidate in _BRANCH_NAME_AGENTS: + effective_branch_name_agent = candidate + + branch = _generate_ai_branch_name( + repo_root, + existing, + prompt, + from_ref, + effective_branch_name_agent, + branch_name_timeout, + ) + if branch: + _info(f"AI-generated branch name: {branch}") + else: + _warn("Could not generate branch name with AI. Falling back to random naming.") + branch = _generate_branch_name(existing, repo_root=repo_root) + _info(f"Generated branch name: {branch}") # Create the worktree _info(f"Creating worktree for branch '{branch}'...") @@ -1469,6 +1457,8 @@ def _clean_merged_worktrees( repo_root: Path, dry_run: bool, yes: bool, + *, + force: bool = False, ) -> None: """Remove worktrees with merged PRs (requires gh CLI).""" _info("Checking for worktrees with merged PRs...") @@ -1509,7 +1499,7 @@ def _clean_merged_worktrees( for wt, _pr_url in to_remove: success, error = worktree.remove_worktree( wt.path, - force=False, + force=force, delete_branch=True, repo_path=repo_root, ) @@ -1523,6 +1513,8 @@ def _clean_no_commits_worktrees( repo_root: Path, dry_run: bool, yes: bool, + *, + force: bool = False, ) -> None: """Remove worktrees with no commits ahead of the default branch.""" _info("Checking for worktrees with no commits...") @@ -1546,7 +1538,7 @@ def _clean_no_commits_worktrees( for wt in to_remove: success, error = worktree.remove_worktree( wt.path, - force=False, + force=force, delete_branch=True, repo_path=repo_root, ) @@ -1584,6 +1576,14 @@ def clean( bool, typer.Option("--yes", "-y", help="Skip confirmation prompts"), ] = False, + force: Annotated[ + bool, + typer.Option( + "--force", + "-f", + help="Force removal of worktrees with modified or untracked files", + ), + ] = False, ) -> None: """Clean up stale worktrees and empty directories. @@ -1601,6 +1601,7 @@ def clean( - `dev clean` — Basic cleanup - `dev clean --merged` — Remove worktrees with merged PRs - `dev clean --merged --dry-run` — Preview what would be removed + - `dev clean --no-commits --force` — Force remove abandoned worktrees with local changes """ repo_root = _ensure_git_repo() @@ -1635,11 +1636,11 @@ def clean( # --merged mode: remove worktrees with merged PRs if merged: - _clean_merged_worktrees(repo_root, dry_run, yes) + _clean_merged_worktrees(repo_root, dry_run, yes, force=force) # --no-commits mode: remove worktrees with no commits ahead of default branch if no_commits: - _clean_no_commits_worktrees(repo_root, dry_run, yes) + _clean_no_commits_worktrees(repo_root, dry_run, yes, force=force) @app.command("doctor") diff --git a/agent_cli/dev/worktree.py b/agent_cli/dev/worktree.py index a8be8a81d..306c6216e 100644 --- a/agent_cli/dev/worktree.py +++ b/agent_cli/dev/worktree.py @@ -318,7 +318,7 @@ class CreateWorktreeResult: warning: str | None = None -def _check_branch_exists(branch_name: str, repo_root: Path) -> tuple[bool, bool]: +def check_branch_exists(branch_name: str, repo_root: Path) -> tuple[bool, bool]: """Check if a branch exists remotely and/or locally. Returns: @@ -660,7 +660,7 @@ def create_worktree( from_ref = _resolve_ref_to_sha(from_ref, cwd=repo_path) # Check if branch exists remotely or locally - remote_exists, local_exists = _check_branch_exists(branch_name, repo_root) + remote_exists, local_exists = check_branch_exists(branch_name, repo_root) # Generate warning if --from was specified but will be ignored warning: str | None = None diff --git a/agent_cli/install/service_config.py b/agent_cli/install/service_config.py index 057a41b14..dbe157767 100644 --- a/agent_cli/install/service_config.py +++ b/agent_cli/install/service_config.py @@ -135,7 +135,7 @@ def build_service_command( args.extend(["--python", service.python_version]) # Build the command: either custom command path or default "server " - cmd_path = service.command if service.command else ["server", service.name] + cmd_path = service.command or ["server", service.name] args.extend( [ diff --git a/docs/commands/dev.md b/docs/commands/dev.md index a90d9e4e0..f4fecc92e 100644 --- a/docs/commands/dev.md +++ b/docs/commands/dev.md @@ -19,9 +19,12 @@ Like [git-worktree-runner (gtr)](https://github.com/CodeRabbitAI/git-worktree-ru ## Quick Start ```bash -# Create a new dev environment (auto-generates branch name like "clever-fox") +# Create a new dev environment (auto-generates a random branch name like "clever-fox") agent-cli dev new +# Create a new dev environment with AI-generated branch name from the prompt +agent-cli dev new --branch-name-mode ai -a --prompt "Refactor auth flow" + # Create a dev environment with a specific branch name agent-cli dev new my-feature @@ -73,6 +76,9 @@ agent-cli dev new [BRANCH] [OPTIONS] | `--setup/--no-setup` | `true` | Run project setup after creation: npm/pnpm/yarn install, poetry/uv sync, cargo build, etc. Auto-detects project type | | `--copy-env/--no-copy-env` | `true` | Copy .env, .env.local, .env.example from main repo to worktree | | `--fetch/--no-fetch` | `true` | Run 'git fetch' before creating the worktree to ensure refs are up-to-date | +| `--branch-name-mode` | `random` | How to auto-name branches when BRANCH is omitted: random (default), auto (AI only when --prompt/--prompt-file is set), or ai (always try AI first) | +| `--branch-name-agent` | - | Headless agent for AI branch naming: claude, codex, or gemini. If omitted, uses --with-agent when supported, otherwise tries available agents in that order | +| `--branch-name-timeout` | `20.0` | Timeout in seconds for AI branch naming command | | `--direnv/--no-direnv` | - | Generate .envrc based on project type and run 'direnv allow'. Auto-enabled if direnv is installed | | `--agent-args` | - | Extra CLI args for the agent. Can be repeated. Example: --agent-args='--dangerously-skip-permissions' | | `--prompt, -p` | - | Initial task for the AI agent. Saved to .claude/TASK.md. Implies --agent. Example: --prompt='Fix the login bug' | @@ -333,6 +339,7 @@ agent-cli dev clean [OPTIONS] | `--no-commits` | `false` | Also remove worktrees with 0 commits ahead of default branch (abandoned branches) | | `--dry-run, -n` | `false` | Preview what would be removed without actually removing | | `--yes, -y` | `false` | Skip confirmation prompts | +| `--force, -f` | `false` | Force removal of worktrees with modified or untracked files | @@ -562,6 +569,11 @@ setup = true # Run project setup (npm install, etc.) copy_env = true # Copy .env files from main repo fetch = true # Git fetch before creating +# Branch naming behavior when BRANCH argument is omitted +branch_name_mode = "random" # random | auto | ai +branch_name_agent = "claude" # claude | codex | gemini (optional) +branch_name_timeout = 20.0 # seconds + # Which editor/agent to use when flags are enabled default_editor = "cursor" default_agent = "claude" diff --git a/tests/dev/test_cli.py b/tests/dev/test_cli.py index 3f5067977..e0584dc92 100644 --- a/tests/dev/test_cli.py +++ b/tests/dev/test_cli.py @@ -2,31 +2,41 @@ from __future__ import annotations +import subprocess from pathlib import Path from unittest.mock import patch from typer.testing import CliRunner from agent_cli.cli import app +from agent_cli.dev._branch_name import ( + _build_branch_naming_prompt, + _extract_branch_from_claude_output, + _extract_branch_from_codex_output, + _extract_branch_from_gemini_output, + _normalize_ai_branch_candidate, + generate_ai_branch_name, + generate_random_branch_name, +) from agent_cli.dev.cli import ( + _clean_no_commits_worktrees, _format_env_prefix, - _generate_branch_name, _get_agent_env, _get_config_agent_args, _get_config_agent_env, ) from agent_cli.dev.coding_agents.base import CodingAgent -from agent_cli.dev.worktree import WorktreeInfo +from agent_cli.dev.worktree import CreateWorktreeResult, WorktreeInfo runner = CliRunner(env={"NO_COLOR": "1", "TERM": "dumb"}) class TestGenerateBranchName: - """Tests for _generate_branch_name function.""" + """Tests for generate_random_branch_name function.""" def test_generates_adjective_noun(self) -> None: """Generates name in adjective-noun format.""" - name = _generate_branch_name() + name = generate_random_branch_name() parts = name.split("-") assert len(parts) >= 2 @@ -36,7 +46,7 @@ def test_avoids_existing_branches(self) -> None: # Run multiple times to ensure it generates unique names names = set() for _ in range(10): - name = _generate_branch_name(existing) + name = generate_random_branch_name(existing) assert name not in existing names.add(name) @@ -45,9 +55,462 @@ def test_deterministic_with_collision(self) -> None: # This test is a bit tricky since names are random # We just verify it doesn't crash with a full set existing: set[str] = set() - name = _generate_branch_name(existing) + name = generate_random_branch_name(existing) assert name # Non-empty + def test_avoids_existing_repo_branches(self) -> None: + """Adds suffix when branch exists in repo refs (not just worktrees).""" + with ( + patch("agent_cli.dev._branch_name.random.choice", side_effect=["happy", "fox"]), + patch( + "agent_cli.dev._branch_name._branch_exists_in_repo", + side_effect=lambda _repo, branch: branch == "happy-fox", + ), + ): + name = generate_random_branch_name(repo_root=Path("/repo")) + assert name == "happy-fox-2" + + def test_random_fallback_checks_availability(self) -> None: + """Random fallback skips names that already exist.""" + # All sequential suffixes (2-99) are taken + existing = {"happy-fox"} | {f"happy-fox-{i}" for i in range(2, 100)} + with ( + patch("agent_cli.dev._branch_name.random.choice", side_effect=["happy", "fox"]), + patch("agent_cli.dev._branch_name.random.randint", side_effect=[500, 501]), + ): + name = generate_random_branch_name(existing | {"happy-fox-500"}) + assert name == "happy-fox-501" + + +class TestAiBranchNameParsers: + """Tests for AI branch-name response parsing helpers.""" + + def test_extract_branch_from_claude_output(self) -> None: + """Claude parser prefers structured_output branch.""" + output = ( + '{"type":"result","result":"ignored",' + '"structured_output":{"branch":"feat/login-retry"}}\n' + ) + assert _extract_branch_from_claude_output(output) == "feat/login-retry" + + def test_extract_branch_from_codex_output(self) -> None: + """Codex parser extracts agent_message text from JSONL.""" + output = ( + '{"type":"turn.started"}\n' + '{"type":"item.completed","item":{"type":"reasoning","text":"thinking"}}\n' + '{"type":"item.completed","item":{"type":"agent_message","text":"fix/login-retry"}}' + ) + assert _extract_branch_from_codex_output(output) == "fix/login-retry" + + def test_extract_branch_from_gemini_output(self) -> None: + """Gemini parser handles non-JSON preamble lines.""" + output = 'Hook registry initialized\n{"response":"chore/update-tests"}\n' + assert _extract_branch_from_gemini_output(output) == "chore/update-tests" + + +class TestAiBranchNameNormalization: + """Tests for AI branch-name normalization and validation.""" + + def test_normalize_branch_candidate(self) -> None: + """Normalizes markdown/spacing and validates via git check-ref-format.""" + with patch("agent_cli.dev._branch_name.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess([], 0, "", "") + branch = _normalize_ai_branch_candidate("`Feature/Login Retry Logic`", Path("/repo")) + assert branch == "feature/login-retry-logic" + + def test_normalize_returns_none_when_invalid(self) -> None: + """Invalid names are rejected when git check-ref-format fails.""" + with patch("agent_cli.dev._branch_name.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess([], 1, "", "invalid") + branch = _normalize_ai_branch_candidate("invalid branch name", Path("/repo")) + assert branch is None + + +class TestAiBranchNamingPrompt: + """Tests for branch naming prompt construction.""" + + def test_prompt_includes_no_tools_instruction(self) -> None: + """Prompt explicitly forbids tool usage for faster headless responses.""" + prompt = _build_branch_naming_prompt( + repo_root=Path("/repo"), + prompt="Refactor auth flow", + from_ref="origin/main", + ) + assert "Do not use tools, do not inspect files" in prompt + assert "Task: Refactor auth flow" in prompt + + def test_prompt_without_task_uses_generic_fallback(self) -> None: + """Missing task uses a generic fallback instead of asking for repo context.""" + prompt = _build_branch_naming_prompt( + repo_root=Path("/repo"), + prompt=None, + from_ref=None, + ) + assert "Task: General maintenance task." in prompt + assert "Use repository context" not in prompt + + +class TestGenerateAiBranchName: + """Tests for AI branch-name generation orchestration.""" + + def test_uses_second_agent_if_first_fails(self) -> None: + """Falls through to next available agent when one fails.""" + with ( + patch( + "agent_cli.dev._branch_name.shutil.which", + side_effect=lambda name: "/usr/bin/bin" if name in {"claude", "codex"} else None, + ), + patch( + "agent_cli.dev._branch_name._generate_branch_name_with_agent", + side_effect=[None, "feat/login-retry"], + ), + ): + branch = generate_ai_branch_name( + Path("/repo"), + set(), + "Fix login retries", + None, + None, + 20.0, + ) + assert branch == "feat/login-retry" + + def test_returns_none_when_no_agents_available(self) -> None: + """Returns None when no agents are installed.""" + with patch("agent_cli.dev._branch_name.shutil.which", return_value=None): + branch = generate_ai_branch_name( + Path("/repo"), + set(), + "Fix login retries", + None, + None, + 20.0, + ) + assert branch is None + + def test_adds_suffix_when_ai_name_exists_in_repo(self) -> None: + """AI-generated branch gets de-duplicated against existing git refs.""" + with ( + patch("agent_cli.dev._branch_name.shutil.which", return_value="/usr/bin/claude"), + patch( + "agent_cli.dev._branch_name._generate_branch_name_with_agent", + return_value="feat/login-retry", + ), + patch( + "agent_cli.dev._branch_name._branch_exists_in_repo", + side_effect=lambda _repo, branch: branch == "feat/login-retry", + ), + ): + branch = generate_ai_branch_name( + Path("/repo"), + set(), + "Fix login retries", + None, + "claude", + 20.0, + ) + assert branch == "feat/login-retry-2" + + +class TestDevClean: + """Tests for dev clean command.""" + + def test_clean_forwards_force_to_modes(self, tmp_path: Path) -> None: + """--force is forwarded to both merged and no-commits clean modes.""" + base_dir = tmp_path / "repo-worktrees" + base_dir.mkdir() + + with ( + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch( + "agent_cli.dev.cli.subprocess.run", + return_value=subprocess.CompletedProcess([], 0, "", ""), + ), + patch("agent_cli.dev.worktree.resolve_worktree_base_dir", return_value=base_dir), + patch("agent_cli.dev.cli._clean_merged_worktrees") as mock_merged, + patch("agent_cli.dev.cli._clean_no_commits_worktrees") as mock_no_commits, + ): + result = runner.invoke( + app, + ["dev", "clean", "--merged", "--no-commits", "--force", "--yes"], + ) + + assert result.exit_code == 0 + assert mock_merged.call_args.kwargs["force"] is True + assert mock_no_commits.call_args.kwargs["force"] is True + + def test_clean_no_commits_force_passed_to_remove_worktree(self) -> None: + """No-commits cleaner passes force through to worktree removal.""" + wt = WorktreeInfo( + path=Path("/repo-worktrees/feature"), + branch="feature", + commit="abc", + is_main=False, + is_detached=False, + is_locked=False, + is_prunable=False, + ) + with ( + patch("agent_cli.dev.cli._find_worktrees_with_no_commits", return_value=[wt]), + patch("agent_cli.dev.cli.worktree.get_default_branch", return_value="main"), + patch( + "agent_cli.dev.cli.worktree.remove_worktree", + return_value=(True, None), + ) as mock_remove, + ): + _clean_no_commits_worktrees( + Path("/repo"), + dry_run=False, + yes=True, + force=True, + ) + + assert mock_remove.call_args.kwargs["force"] is True + + +class TestDevNewBranchNaming: + """Tests for branch naming behavior in `dev new`.""" + + def test_new_uses_ai_branch_name_when_enabled(self, tmp_path: Path) -> None: + """--branch-name-mode ai uses AI-generated branch names.""" + wt_path = tmp_path / "repo-worktrees" / "feat-login-retry" + wt_path.mkdir(parents=True) + + with ( + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.worktree.list_worktrees", return_value=[]), + patch( + "agent_cli.dev.cli._generate_ai_branch_name", + return_value="feat/login-retry", + ) as mock_ai, + patch( + "agent_cli.dev.cli._generate_branch_name", + return_value="happy-fox", + ) as mock_random, + patch( + "agent_cli.dev.worktree.create_worktree", + return_value=CreateWorktreeResult( + success=True, + path=wt_path, + branch="feat/login-retry", + ), + ), + patch( + "agent_cli.dev.cli._write_prompt_to_worktree", + return_value=wt_path / ".claude/TASK.md", + ), + patch("agent_cli.dev.cli._resolve_editor", return_value=None), + patch("agent_cli.dev.cli._resolve_agent", return_value=None), + ): + result = runner.invoke( + app, + [ + "dev", + "new", + "--branch-name-mode", + "ai", + "--prompt", + "Fix login retries", + "--no-setup", + "--no-copy-env", + "--no-fetch", + "--no-direnv", + ], + ) + + assert result.exit_code == 0 + assert "AI-generated branch name: feat/login-retry" in result.output + mock_ai.assert_called_once() + mock_random.assert_not_called() + + def test_new_uses_with_agent_for_branch_naming_when_supported(self, tmp_path: Path) -> None: + """When --branch-name-agent is omitted, --with-agent is used if supported.""" + wt_path = tmp_path / "repo-worktrees" / "feat-login-retry" + wt_path.mkdir(parents=True) + + with ( + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.worktree.list_worktrees", return_value=[]), + patch( + "agent_cli.dev.cli._generate_ai_branch_name", + return_value="feat/login-retry", + ) as mock_ai, + patch( + "agent_cli.dev.cli._generate_branch_name", + return_value="happy-fox", + ) as mock_random, + patch( + "agent_cli.dev.worktree.create_worktree", + return_value=CreateWorktreeResult( + success=True, + path=wt_path, + branch="feat/login-retry", + ), + ), + patch("agent_cli.dev.cli._resolve_editor", return_value=None), + patch("agent_cli.dev.cli._resolve_agent", return_value=None), + ): + result = runner.invoke( + app, + [ + "dev", + "new", + "--branch-name-mode", + "ai", + "--with-agent", + "codex", + "--no-setup", + "--no-copy-env", + "--no-fetch", + "--no-direnv", + ], + ) + + assert result.exit_code == 0 + mock_random.assert_not_called() + mock_ai.assert_called_once_with( + Path("/repo"), + set(), + None, + None, + "codex", + 20.0, + ) + + def test_new_falls_back_to_random_when_ai_fails(self, tmp_path: Path) -> None: + """AI naming failure falls back to random naming.""" + wt_path = tmp_path / "repo-worktrees" / "happy-fox" + wt_path.mkdir(parents=True) + + with ( + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.worktree.list_worktrees", return_value=[]), + patch( + "agent_cli.dev.cli._generate_ai_branch_name", + return_value=None, + ), + patch( + "agent_cli.dev.cli._generate_branch_name", + return_value="happy-fox", + ) as mock_random, + patch( + "agent_cli.dev.worktree.create_worktree", + return_value=CreateWorktreeResult( + success=True, + path=wt_path, + branch="happy-fox", + ), + ), + patch("agent_cli.dev.cli._resolve_editor", return_value=None), + patch("agent_cli.dev.cli._resolve_agent", return_value=None), + ): + result = runner.invoke( + app, + [ + "dev", + "new", + "--branch-name-mode", + "ai", + "--no-setup", + "--no-copy-env", + "--no-fetch", + "--no-direnv", + ], + ) + + assert result.exit_code == 0 + assert "Falling back to random naming" in result.output + assert "Generated branch name: happy-fox" in result.output + mock_random.assert_called_once() + + def test_new_auto_without_prompt_uses_random(self, tmp_path: Path) -> None: + """Auto mode without prompt should not call AI naming.""" + wt_path = tmp_path / "repo-worktrees" / "happy-fox" + wt_path.mkdir(parents=True) + + with ( + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.worktree.list_worktrees", return_value=[]), + patch("agent_cli.dev.cli._generate_ai_branch_name") as mock_ai, + patch("agent_cli.dev.cli._generate_branch_name", return_value="happy-fox"), + patch( + "agent_cli.dev.worktree.create_worktree", + return_value=CreateWorktreeResult( + success=True, + path=wt_path, + branch="happy-fox", + ), + ), + patch("agent_cli.dev.cli._resolve_editor", return_value=None), + patch("agent_cli.dev.cli._resolve_agent", return_value=None), + ): + result = runner.invoke( + app, + [ + "dev", + "new", + "--branch-name-mode", + "auto", + "--no-setup", + "--no-copy-env", + "--no-fetch", + "--no-direnv", + ], + ) + + assert result.exit_code == 0 + mock_ai.assert_not_called() + + def test_new_uses_dev_config_defaults_for_branch_naming(self, tmp_path: Path) -> None: + """[dev] defaults should apply to `dev new` options via parent callback.""" + config_path = tmp_path / "config.toml" + config_path.write_text( + """[dev] +branch_name_mode = "ai" +branch_name_agent = "codex" +branch_name_timeout = 7.5 +setup = false +copy_env = false +fetch = false +direnv = false +""", + ) + wt_path = tmp_path / "repo-worktrees" / "feat-login-retry" + wt_path.mkdir(parents=True) + + with ( + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.worktree.list_worktrees", return_value=[]), + patch( + "agent_cli.dev.cli._generate_ai_branch_name", + return_value="feat/login-retry", + ) as mock_ai, + patch("agent_cli.dev.cli._generate_branch_name") as mock_random, + patch( + "agent_cli.dev.worktree.create_worktree", + return_value=CreateWorktreeResult( + success=True, + path=wt_path, + branch="feat/login-retry", + ), + ) as mock_create, + patch("agent_cli.dev.cli._resolve_editor", return_value=None), + patch("agent_cli.dev.cli._resolve_agent", return_value=None), + ): + result = runner.invoke(app, ["dev", "--config", str(config_path), "new"]) + + assert result.exit_code == 0 + mock_random.assert_not_called() + mock_ai.assert_called_once_with( + Path("/repo"), + set(), + None, + None, + "codex", + 7.5, + ) + assert mock_create.call_args.kwargs["fetch"] is False + class TestDevHelp: """Tests for dev command help.""" diff --git a/tests/dev/test_worktree.py b/tests/dev/test_worktree.py index fbfde895d..bcdd9fe73 100644 --- a/tests/dev/test_worktree.py +++ b/tests/dev/test_worktree.py @@ -443,7 +443,7 @@ def test_warning_when_local_branch_exists_and_from_specified(self) -> None: return_value=Path("/worktrees"), ), patch("agent_cli.dev.worktree._run_git") as mock_run, - patch("agent_cli.dev.worktree._check_branch_exists", return_value=(False, True)), + patch("agent_cli.dev.worktree.check_branch_exists", return_value=(False, True)), patch("agent_cli.dev.worktree._add_worktree"), patch("agent_cli.dev.worktree._init_submodules"), patch("agent_cli.dev.worktree._pull_lfs"), @@ -475,7 +475,7 @@ def test_warning_when_remote_branch_exists_and_from_specified(self) -> None: return_value=Path("/worktrees"), ), patch("agent_cli.dev.worktree._run_git") as mock_run, - patch("agent_cli.dev.worktree._check_branch_exists", return_value=(True, False)), + patch("agent_cli.dev.worktree.check_branch_exists", return_value=(True, False)), patch("agent_cli.dev.worktree._add_worktree"), patch("agent_cli.dev.worktree._init_submodules"), patch("agent_cli.dev.worktree._pull_lfs"), @@ -505,7 +505,7 @@ def test_no_warning_when_from_not_specified(self) -> None: return_value=Path("/worktrees"), ), patch("agent_cli.dev.worktree._run_git") as mock_run, - patch("agent_cli.dev.worktree._check_branch_exists", return_value=(False, True)), + patch("agent_cli.dev.worktree.check_branch_exists", return_value=(False, True)), patch("agent_cli.dev.worktree._add_worktree"), patch("agent_cli.dev.worktree._init_submodules"), patch("agent_cli.dev.worktree.get_default_branch", return_value="main"), @@ -534,7 +534,7 @@ def test_no_warning_when_branch_is_new(self) -> None: return_value=Path("/worktrees"), ), patch("agent_cli.dev.worktree._run_git") as mock_run, - patch("agent_cli.dev.worktree._check_branch_exists", return_value=(False, False)), + patch("agent_cli.dev.worktree.check_branch_exists", return_value=(False, False)), patch("agent_cli.dev.worktree._add_worktree"), patch("agent_cli.dev.worktree._init_submodules"), patch("agent_cli.dev.worktree._pull_lfs"), @@ -665,7 +665,7 @@ def test_uses_local_branch_when_no_origin(self) -> None: return_value=Path("/worktrees"), ), patch("agent_cli.dev.worktree._run_git") as mock_run, - patch("agent_cli.dev.worktree._check_branch_exists", return_value=(False, False)), + patch("agent_cli.dev.worktree.check_branch_exists", return_value=(False, False)), patch("agent_cli.dev.worktree._add_worktree") as mock_add_worktree, patch("agent_cli.dev.worktree._init_submodules"), patch("agent_cli.dev.worktree.get_default_branch", return_value="main"), @@ -710,7 +710,7 @@ def mock_run_git(*args: str, **_kwargs: object) -> MagicMock: return_value=Path("/worktrees"), ), patch("agent_cli.dev.worktree._run_git", side_effect=mock_run_git), - patch("agent_cli.dev.worktree._check_branch_exists", return_value=(False, False)), + patch("agent_cli.dev.worktree.check_branch_exists", return_value=(False, False)), patch("agent_cli.dev.worktree._add_worktree"), patch("agent_cli.dev.worktree._init_submodules"), patch("agent_cli.dev.worktree.get_default_branch", return_value="main"), @@ -737,7 +737,7 @@ def test_uses_origin_when_available(self) -> None: return_value=Path("/worktrees"), ), patch("agent_cli.dev.worktree._run_git") as mock_run, - patch("agent_cli.dev.worktree._check_branch_exists", return_value=(False, False)), + patch("agent_cli.dev.worktree.check_branch_exists", return_value=(False, False)), patch("agent_cli.dev.worktree._add_worktree") as mock_add_worktree, patch("agent_cli.dev.worktree._init_submodules"), patch("agent_cli.dev.worktree.get_default_branch", return_value="main"), diff --git a/tests/test_config.py b/tests/test_config.py index 7f89429d4..284cdd9d4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -159,6 +159,28 @@ def test_config_supports_both_flat_and_nested_sections(tmp_path: Path) -> None: assert config["defaults"]["log_level"] == "INFO" +def test_config_preserves_scalar_options_when_section_has_nested_subsections( + tmp_path: Path, +) -> None: + """Mixed scalar + nested sections should keep both available.""" + config_content = """ +[dev] +branch-name-mode = "ai" +setup = false + +[dev.agent_args] +claude = ["--dangerously-skip-permissions"] +""" + config_path = tmp_path / "config.toml" + config_path.write_text(config_content) + + config = load_config(str(config_path)) + + assert config["dev"]["branch_name_mode"] == "ai" + assert config["dev"]["setup"] is False + assert config["dev.agent_args"]["claude"] == ["--dangerously-skip-permissions"] + + def test_provider_alias_normalization(config_file: Path) -> None: """Ensure deprecated provider names are normalized.""" config = load_config(str(config_file))