From ce165a447833cb600aaa838f850525cbf817ee1b Mon Sep 17 00:00:00 2001 From: Sho Nakatani Date: Fri, 27 Jun 2025 21:40:35 +0900 Subject: [PATCH] feat: add --path flag to create command for streamlined navigation and output --- CHANGELOG.md | 1 + README.md | 38 +++++++++++++++++----- pyproject.toml | 2 +- src/sprout/cli.py | 7 ++++- src/sprout/commands/create.py | 59 +++++++++++++++++++++++++---------- src/sprout/utils.py | 10 ++++-- tests/test_commands.py | 48 ++++++++++++++++++++++++++++ 7 files changed, 137 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4583db..c3b82fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `--path` flag for `sprout create` command to output only the worktree path, enabling one-liner usage like `cd $(sprout create feature --path)` ### Changed diff --git a/README.md b/README.md index 348c0a1..11c3d02 100644 --- a/README.md +++ b/README.md @@ -43,26 +43,50 @@ DB_PORT={{ auto_port() }} # DB_NAME=${DB_NAME} ``` -2. Create a new development environment: +2. Create and navigate to a new development environment in one command: ```bash -sprout create feature-branch +cd $(sprout create feature-branch --path) ``` -3. Navigate to your new environment: +This single command: +- Creates a new git worktree for `feature-branch` +- Generates a `.env` file from your template +- Outputs the path to the new environment +- Changes to that directory when wrapped in `cd $(...)` + +3. Start your services: ```bash -cd $(sprout path feature-branch) +docker compose up -d ``` -4. Start your services: +### Alternative: Two-Step Process + +If you prefer to see the creation output first: ```bash -docker compose up -d +# Create the environment +sprout create feature-branch + +# Then navigate to it +cd $(sprout path feature-branch) ``` ## Commands -### `sprout create ` +### `sprout create [--path]` Create a new development environment with automated setup. +Options: +- `--path`: Output only the worktree path (useful for shell command substitution) + +Examples: +```bash +# Create and see progress messages +sprout create feature-xyz + +# Create and navigate in one command +cd $(sprout create feature-xyz --path) +``` + ### `sprout ls` List all managed development environments with their status. diff --git a/pyproject.toml b/pyproject.toml index 721657f..51605ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ include = [ ] [tool.ruff] -target-version = "1.0.0" +target-version = "py311" line-length = 100 [tool.ruff.lint] diff --git a/src/sprout/cli.py b/src/sprout/cli.py index 29b66ef..c240a6f 100644 --- a/src/sprout/cli.py +++ b/src/sprout/cli.py @@ -46,9 +46,14 @@ def create( ..., help="Name of the branch to create worktree for", ), + path: bool = typer.Option( + False, + "--path", + help="Output only the worktree path (for use with shell command substitution)", + ), ) -> None: """Create a new development environment.""" - create_worktree(branch_name) + create_worktree(branch_name, path_only=path) @app.command() diff --git a/src/sprout/commands/create.py b/src/sprout/commands/create.py index 4a87704..00d9f3f 100644 --- a/src/sprout/commands/create.py +++ b/src/sprout/commands/create.py @@ -21,25 +21,34 @@ console = Console() -def create_worktree(branch_name: BranchName) -> Never: +def create_worktree(branch_name: BranchName, path_only: bool = False) -> Never: """Create a new worktree with development environment.""" # Check prerequisites if not is_git_repository(): - console.print("[red]Error: Not in a git repository[/red]") - console.print("Please run this command from the root of a git repository.") + if not path_only: + console.print("[red]Error: Not in a git repository[/red]") + console.print("Please run this command from the root of a git repository.") + else: + typer.echo("Error: Not in a git repository", err=True) raise typer.Exit(1) git_root = get_git_root() env_example = git_root / ".env.example" if not env_example.exists(): - console.print("[red]Error: .env.example file not found[/red]") - console.print(f"Expected at: {env_example}") + if not path_only: + console.print("[red]Error: .env.example file not found[/red]") + console.print(f"Expected at: {env_example}") + else: + typer.echo(f"Error: .env.example file not found at {env_example}", err=True) raise typer.Exit(1) # Check if worktree already exists if worktree_exists(branch_name): - console.print(f"[red]Error: Worktree for branch '{branch_name}' already exists[/red]") + if not path_only: + console.print(f"[red]Error: Worktree for branch '{branch_name}' already exists[/red]") + else: + typer.echo(f"Error: Worktree for branch '{branch_name}' already exists", err=True) raise typer.Exit(1) # Ensure .sprout directory exists @@ -47,11 +56,13 @@ def create_worktree(branch_name: BranchName) -> Never: worktree_path = sprout_dir / branch_name # Create the worktree - console.print(f"Creating worktree for branch [cyan]{branch_name}[/cyan]...") + if not path_only: + console.print(f"Creating worktree for branch [cyan]{branch_name}[/cyan]...") # Check if branch exists, create if it doesn't if not branch_exists(branch_name): - console.print(f"Branch '{branch_name}' doesn't exist. Creating new branch...") + if not path_only: + console.print(f"Branch '{branch_name}' doesn't exist. Creating new branch...") # Create branch with -b flag cmd = ["git", "worktree", "add", "-b", branch_name, str(worktree_path)] else: @@ -60,30 +71,44 @@ def create_worktree(branch_name: BranchName) -> Never: try: run_command(cmd) except SproutError as e: - console.print(f"[red]Error creating worktree: {e}[/red]") + if not path_only: + console.print(f"[red]Error creating worktree: {e}[/red]") + else: + typer.echo(f"Error creating worktree: {e}", err=True) raise typer.Exit(1) from e # Generate .env file - console.print("Generating .env file...") + if not path_only: + console.print("Generating .env file...") try: - env_content = parse_env_template(env_example) + env_content = parse_env_template(env_example, silent=path_only) env_file = worktree_path / ".env" env_file.write_text(env_content) except SproutError as e: - console.print(f"[red]Error generating .env file: {e}[/red]") + if not path_only: + console.print(f"[red]Error generating .env file: {e}[/red]") + else: + typer.echo(f"Error generating .env file: {e}", err=True) # Clean up worktree on failure run_command(["git", "worktree", "remove", str(worktree_path)], check=False) raise typer.Exit(1) from e except KeyboardInterrupt: - console.print("\n[yellow]Cancelled by user[/yellow]") + if not path_only: + console.print("\n[yellow]Cancelled by user[/yellow]") + else: + typer.echo("Cancelled by user", err=True) # Clean up worktree on cancellation run_command(["git", "worktree", "remove", str(worktree_path)], check=False) raise typer.Exit(130) from None - # Success message - console.print(f"\n[green]✅ Workspace '{branch_name}' created successfully![/green]\n") - console.print("Navigate to your new environment with:") - console.print(f" [cyan]cd {worktree_path.relative_to(Path.cwd())}[/cyan]") + # Success message or path output + if path_only: + # Output only the path for shell command substitution + print(str(worktree_path)) + else: + console.print(f"\n[green]✅ Workspace '{branch_name}' created successfully![/green]\n") + console.print("Navigate to your new environment with:") + console.print(f" [cyan]cd {worktree_path.relative_to(Path.cwd())}[/cyan]") # Exit successfully raise typer.Exit(0) diff --git a/src/sprout/utils.py b/src/sprout/utils.py index c96148a..fc2bec9 100644 --- a/src/sprout/utils.py +++ b/src/sprout/utils.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import TypeAlias +import typer from rich.console import Console from sprout.exceptions import SproutError @@ -123,7 +124,7 @@ def find_available_port() -> PortNumber: raise SproutError("Could not find an available port after 1000 attempts") -def parse_env_template(template_path: Path) -> str: +def parse_env_template(template_path: Path, silent: bool = False) -> str: """Parse .env.example template and process placeholders.""" if not template_path.exists(): raise SproutError(f".env.example file not found at {template_path}") @@ -155,7 +156,12 @@ def replace_variable(match: re.Match[str]) -> str: value = os.environ.get(var_name) if value is None: # Prompt user for value - value = console.input(f"Enter a value for '{var_name}': ") + if silent: + # Use stderr for prompts in silent mode to keep stdout clean + typer.echo(f"Enter a value for '{var_name}': ", err=True, nl=False) + value = input() + else: + value = console.input(f"Enter a value for '{var_name}': ") return value line = re.sub(r"{{\s*([^}]+)\s*}}", replace_variable, line) diff --git a/tests/test_commands.py b/tests/test_commands.py index 817bf78..8db793e 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -60,6 +60,54 @@ def test_create_not_in_git_repo(self, mocker): assert result.exit_code == 1 assert "Not in a git repository" in result.stdout + def test_create_with_path_flag_success(self, mocker, tmp_path): + """Test successful creation with --path flag outputs only the path.""" + # Set up test directory structure + project_dir = tmp_path / "project" + project_dir.mkdir() + sprout_dir = project_dir / ".sprout" + sprout_dir.mkdir() + env_example = project_dir / ".env.example" + env_example.write_text("TEST=value") + + # Create the worktree directory that would be created by git command + worktree_dir = sprout_dir / "feature-branch" + worktree_dir.mkdir() + + # Mock prerequisites + mocker.patch("sprout.commands.create.is_git_repository", return_value=True) + mocker.patch("sprout.commands.create.get_git_root", return_value=project_dir) + mocker.patch("sprout.commands.create.worktree_exists", return_value=False) + mocker.patch("sprout.commands.create.branch_exists", return_value=False) + mocker.patch("sprout.commands.create.ensure_sprout_dir", return_value=sprout_dir) + mocker.patch("sprout.commands.create.parse_env_template", return_value="ENV_VAR=value") + + # Mock command execution + mock_run = mocker.patch("sprout.commands.create.run_command") + + # Run command with --path flag + result = runner.invoke(app, ["create", "feature-branch", "--path"]) + + assert result.exit_code == 0 + # Should output only the path, no other messages + assert result.stdout.strip() == str(worktree_dir) + # No Rich formatting in output + assert "[green]" not in result.stdout + assert "✅" not in result.stdout + assert mock_run.called + + def test_create_with_path_flag_error(self, mocker): + """Test error handling with --path flag uses stderr.""" + mocker.patch("sprout.commands.create.is_git_repository", return_value=False) + + result = runner.invoke(app, ["create", "feature-branch", "--path"]) + + assert result.exit_code == 1 + # Error should be in stderr, not stdout + assert "Error: Not in a git repository" in result.output + # stdout should be empty + assert result.stdout == "" + def test_create_no_env_example(self, mocker): """Test error when .env.example doesn't exist.""" mocker.patch("sprout.commands.create.is_git_repository", return_value=True)