Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
38 changes: 31 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch-name>`
### `sprout create <branch-name> [--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.

Expand Down
7 changes: 6 additions & 1 deletion src/sprout/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
59 changes: 42 additions & 17 deletions src/sprout/commands/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,37 +21,48 @@
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
sprout_dir = ensure_sprout_dir()
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:
Expand All @@ -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)
10 changes: 8 additions & 2 deletions src/sprout/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pathlib import Path
from typing import TypeAlias

import typer
from rich.console import Console

from sprout.exceptions import SproutError
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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)
Expand Down
48 changes: 48 additions & 0 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down