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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- Support for repositories without `.env.example` files - `sprout create` now works in any git repository

### Changed
- `sprout create` behavior when no `.env.example` files exist: shows warning instead of error and continues creating worktree

### Deprecated

Expand All @@ -24,11 +26,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Support for multiple `.env.example` files throughout the repository, enabling monorepo workflows
- Recursive scanning of `.env` files for port allocation to ensure global uniqueness across all services
- Support for repositories without `.env.example` files - `sprout create` now works in any git repository

### Changed
- Port allocation now ensures uniqueness across all services in all worktrees, preventing Docker host port conflicts
- `sprout create` now processes all `.env.example` files found in the repository while maintaining directory structure
- Only git-tracked `.env.example` files are now processed, preventing unwanted processing of files in `.sprout/` worktrees
- `sprout create` behavior when no `.env.example` files exist: shows warning instead of error and continues creating worktree

### Deprecated

Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ pip install -e ".[dev]"

## Quick Start

1. Create a `.env.example` template in your project root (and optionally in subdirectories):
**Note**: Sprout works in any git repository. `.env.example` files are optional - if you don't have them, sprout will simply create worktrees without `.env` generation.

1. (Optional) Create a `.env.example` template in your project root (and optionally in subdirectories) for automatic `.env` generation:
```env
# API Configuration
API_KEY={{ API_KEY }}
Expand Down Expand Up @@ -58,9 +60,13 @@ repo/
cd $(sprout create feature-branch --path)
```

**What happens when you run `sprout create`:**
- If `.env.example` files exist: Sprout will generate corresponding `.env` files with populated variables and unique port assignments
- If no `.env.example` files exist: Sprout will show a warning and create the worktree without `.env` generation

This single command:
- Creates a new git worktree for `feature-branch`
- Generates a `.env` file from your template
- Generates `.env` files from your templates (if `.env.example` files exist)
- Outputs the path to the new environment
- Changes to that directory when wrapped in `cd $(...)`

Expand Down
7 changes: 4 additions & 3 deletions docs/sprout-cli/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@

### 1. Automated Development Environment Setup
- Create git worktrees
- Automatically generate `.env` files from `.env.example` templates
- Automatically generate `.env` files from `.env.example` templates (when templates exist)
- Automatic port number assignment (collision avoidance)
- Interactive environment variable configuration
- Works in any git repository, with or without `.env.example` files

### 2. Unified Management
- Centralize all worktrees in `.sprout/` directory
Expand Down Expand Up @@ -42,9 +43,9 @@
├── .git/
├── .sprout/ # sprout management directory
│ └── <branch-name>/ # each worktree
│ ├── .env # auto-generated environment config
│ ├── .env # auto-generated environment config (if .env.example exists)
│ └── ... # source code
├── .env.example # template
├── .env.example # template (optional)
└── compose.yaml # Docker Compose config
```

Expand Down
18 changes: 13 additions & 5 deletions docs/sprout-cli/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ sprout create feature-branch

This command performs:
1. Creates worktree in `.sprout/feature-branch`
2. Generates `.env` from `.env.example` template
3. Prompts for required environment variables
4. Automatically assigns port numbers
2. Generates `.env` from `.env.example` template (if template exists)
3. Prompts for required environment variables (if `.env.example` exists)
4. Automatically assigns port numbers (if `.env.example` exists)

**Note**: If no `.env.example` files are found, sprout will show a warning but continue creating the worktree without `.env` generation.

### 2. List Development Environments

Expand Down Expand Up @@ -177,8 +179,14 @@ REDIS_PORT={{ auto_port() }} # Might assign 3003
- Execute from Git repository root directory
- Ensure `.git` directory exists

### ".env.example file not found" Error
- Create `.env.example` in project root
### Working Without .env.example Files
As of recent versions, sprout works perfectly fine without `.env.example` files:
- If no `.env.example` files exist, sprout will show a warning but continue
- The worktree will be created successfully without `.env` generation
- This is useful for projects that don't need environment variable templating

If you want to add `.env` generation later:
- Create `.env.example` in project root or subdirectories
- Use the template syntax described above

### "Could not find an available port" Error
Expand Down
122 changes: 65 additions & 57 deletions src/sprout/commands/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,9 @@ def create_worktree(branch_name: BranchName, path_only: bool = False) -> Never:

if not env_examples:
if not path_only:
console.print("[red]Error: No .env.example files found[/red]")
console.print(f"Expected at least one .env.example file in: {git_root}")
else:
typer.echo(f"Error: No .env.example files found in {git_root}", err=True)
raise typer.Exit(1)
console.print("[yellow]Warning: No .env.example files found[/yellow]")
console.print(f"Proceeding without .env generation in: {git_root}")
# Continue execution without exiting

# Check if worktree already exists
if worktree_exists(branch_name):
Expand Down Expand Up @@ -87,67 +85,77 @@ def create_worktree(branch_name: BranchName, path_only: bool = False) -> Never:
typer.echo(f"Error creating worktree: {e}", err=True)
raise typer.Exit(1) from e

# Generate .env files
if not path_only:
console.print(f"Generating .env files from {len(env_examples)} template(s)...")

# Get all currently used ports to avoid conflicts
all_used_ports = get_used_ports()
session_ports: set[int] = set()

try:
for env_example in env_examples:
# Calculate relative path from git root
relative_dir = env_example.parent.relative_to(git_root)

# Create target directory in worktree if needed
if relative_dir != Path("."):
target_dir = worktree_path / relative_dir
target_dir.mkdir(parents=True, exist_ok=True)
env_file = target_dir / ".env"
else:
env_file = worktree_path / ".env"

# Parse template with combined used ports
env_content = parse_env_template(
env_example, silent=path_only, used_ports=all_used_ports | session_ports
)

# Extract ports from generated content and add to session_ports
port_matches = re.findall(r"=(\d{4,5})\b", env_content)
for port_str in port_matches:
port = int(port_str)
if 1024 <= port <= 65535:
session_ports.add(port)

# Write the .env file
env_file.write_text(env_content)

except SproutError as e:
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:
# Generate .env files only if .env.example files exist
if env_examples:
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
console.print(f"Generating .env files from {len(env_examples)} template(s)...")

# Get all currently used ports to avoid conflicts
all_used_ports = get_used_ports()
session_ports: set[int] = set()

try:
for env_example in env_examples:
# Calculate relative path from git root
relative_dir = env_example.parent.relative_to(git_root)

# Create target directory in worktree if needed
if relative_dir != Path("."):
target_dir = worktree_path / relative_dir
target_dir.mkdir(parents=True, exist_ok=True)
env_file = target_dir / ".env"
else:
env_file = worktree_path / ".env"

# Parse template with combined used ports
env_content = parse_env_template(
env_example, silent=path_only, used_ports=all_used_ports | session_ports
)

# Extract ports from generated content and add to session_ports
port_matches = re.findall(r"=(\d{4,5})\b", env_content)
for port_str in port_matches:
port = int(port_str)
if 1024 <= port <= 65535:
session_ports.add(port)

# Write the .env file
env_file.write_text(env_content)

except SproutError as e:
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:
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 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")
if env_examples:
console.print(f"Generated .env files from {len(env_examples)} template(s)")
else:
console.print("No .env files generated (no .env.example templates found)")
console.print("Navigate to your new environment with:")
console.print(f" [cyan]cd {worktree_path.relative_to(Path.cwd())}[/cyan]")
try:
relative_path = worktree_path.relative_to(Path.cwd())
console.print(f" [cyan]cd {relative_path}[/cyan]")
except ValueError:
# If worktree_path is not relative to current directory, show absolute path
console.print(f" [cyan]cd {worktree_path}[/cyan]")

# Exit successfully
raise typer.Exit(0)
53 changes: 46 additions & 7 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,20 +126,37 @@ def test_create_with_path_flag_error(self, mocker):
# stdout should be empty
assert result.stdout == ""

def test_create_no_env_example(self, mocker):
"""Test error when .env.example doesn't exist."""
def test_create_no_env_example(self, mocker, tmp_path):
"""Test success when .env.example doesn't exist."""
mocker.patch("sprout.commands.create.is_git_repository", return_value=True)
mock_git_root = Path("/project")
mock_git_root = tmp_path / "project"
mock_git_root.mkdir()
mocker.patch("sprout.commands.create.get_git_root", return_value=mock_git_root)
mocker.patch("sprout.commands.create.worktree_exists", return_value=False)
sprout_dir = tmp_path / ".sprout"
sprout_dir.mkdir()
mocker.patch("sprout.commands.create.ensure_sprout_dir", return_value=sprout_dir)
mocker.patch("sprout.commands.create.branch_exists", return_value=False)

# Mock git ls-files to return empty list
# Mock git ls-files to return empty list (no .env.example files)
mock_run = mocker.patch("sprout.commands.create.run_command")
mock_run.return_value = Mock(stdout="", returncode=0)

result = runner.invoke(app, ["create", "feature-branch"])
# Change to project directory to make relative path calculation work
import os

assert result.exit_code == 1
assert "No .env.example files found" in result.stdout
old_cwd = os.getcwd()
os.chdir(str(mock_git_root))

try:
result = runner.invoke(app, ["create", "feature-branch"])

assert result.exit_code == 0
assert "Warning: No .env.example files found" in result.stdout
assert "Workspace 'feature-branch' created successfully!" in result.stdout
assert "No .env files generated (no .env.example templates found)" in result.stdout
finally:
os.chdir(old_cwd)

def test_create_worktree_exists(self, mocker):
"""Test error when worktree already exists."""
Expand All @@ -161,6 +178,28 @@ def test_create_worktree_exists(self, mocker):
assert result.exit_code == 1
assert "Worktree for branch 'feature-branch' already exists" in result.stdout

def test_create_without_env_example_path_mode(self, mocker, tmp_path):
"""Test path mode with no .env.example files."""
mocker.patch("sprout.commands.create.is_git_repository", return_value=True)
mock_git_root = tmp_path / "project"
mock_git_root.mkdir()
mocker.patch("sprout.commands.create.get_git_root", return_value=mock_git_root)
mocker.patch("sprout.commands.create.worktree_exists", return_value=False)
sprout_dir = tmp_path / ".sprout"
sprout_dir.mkdir()
mocker.patch("sprout.commands.create.ensure_sprout_dir", return_value=sprout_dir)
mocker.patch("sprout.commands.create.branch_exists", return_value=False)

# Mock git ls-files to return empty list (no .env.example files)
mock_run = mocker.patch("sprout.commands.create.run_command")
mock_run.return_value = Mock(stdout="", returncode=0)

result = runner.invoke(app, ["create", "feature-branch", "--path"])

assert result.exit_code == 0
# In path mode, only the path should be printed to stdout
assert result.stdout.strip() == str(sprout_dir / "feature-branch")


class TestLsCommand:
"""Test sprout ls command."""
Expand Down
7 changes: 4 additions & 3 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,12 @@ def test_error_cases(self, git_repo, monkeypatch, tmp_path):
result = runner.invoke(app, ["path", "nonexistent"])
assert result.exit_code == 1

# Remove .env.example and try to create
# Remove .env.example and try to create (should succeed now)
(git_repo / ".env.example").unlink()
result = runner.invoke(app, ["create", "another-branch"])
assert result.exit_code == 1
assert "No .env.example files found" in result.stdout
assert result.exit_code == 0
assert "Warning: No .env.example files found" in result.stdout
assert "Workspace 'another-branch' created successfully!" in result.stdout

# Test outside git repo using a separate temp directory
import tempfile
Expand Down