From 8ba10d3f181e49d15baa0679d203d8e818739a3c Mon Sep 17 00:00:00 2001 From: Sho Nakatani Date: Sat, 28 Jun 2025 09:06:57 +0900 Subject: [PATCH 1/7] feat: Enhance support for multiple .env.example files and ensure unique port allocation across services --- CHANGELOG.md | 4 + README.md | 15 ++- src/sprout/commands/create.py | 55 +++++++++-- src/sprout/utils.py | 19 +++- tests/test_commands.py | 19 ++-- tests/test_integration.py | 2 +- tests/test_multi_env.py | 172 ++++++++++++++++++++++++++++++++++ 7 files changed, 260 insertions(+), 26 deletions(-) create mode 100644 tests/test_multi_env.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d19a38..e35d9ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### 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 ### 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 ### Deprecated diff --git a/README.md b/README.md index a88cb75..5090cdf 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ pip install -e ".[dev]" ## Quick Start -1. Create a `.env.example` template in your project root: +1. Create a `.env.example` template in your project root (and optionally in subdirectories): ```env # API Configuration API_KEY={{ API_KEY }} @@ -43,6 +43,16 @@ DB_PORT={{ auto_port() }} # DB_NAME=${DB_NAME} ``` +For monorepo or multi-service projects, you can create `.env.example` files in subdirectories: +``` +repo/ + .env.example # Root configuration + service-a/ + .env.example # Service A specific config + service-b/ + .env.example # Service B specific config +``` + 2. Create and navigate to a new development environment in one command: ```bash cd $(sprout create feature-branch --path) @@ -145,8 +155,9 @@ sprout supports two types of placeholders in `.env.example`: 2. **Auto Port Assignment**: `{{ auto_port() }}` - Automatically assigns available ports - - Avoids conflicts with other sprout environments + - Avoids conflicts across ALL services in ALL sprout environments - Checks system port availability + - Ensures global uniqueness even in monorepo setups 3. **Docker Compose Syntax (Preserved)**: `${VARIABLE}` - NOT processed by sprout - passed through as-is diff --git a/src/sprout/commands/create.py b/src/sprout/commands/create.py index 00d9f3f..463ff40 100644 --- a/src/sprout/commands/create.py +++ b/src/sprout/commands/create.py @@ -1,5 +1,6 @@ """Implementation of the create command.""" +import re from pathlib import Path from typing import Never @@ -12,6 +13,7 @@ branch_exists, ensure_sprout_dir, get_git_root, + get_used_ports, is_git_repository, parse_env_template, run_command, @@ -33,14 +35,16 @@ def create_worktree(branch_name: BranchName, path_only: bool = False) -> Never: raise typer.Exit(1) git_root = get_git_root() - env_example = git_root / ".env.example" - if not env_example.exists(): + # Find all .env.example files in the repository + env_examples = list(git_root.rglob(".env.example")) + + if not env_examples: if not path_only: - console.print("[red]Error: .env.example file not found[/red]") - console.print(f"Expected at: {env_example}") + 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: .env.example file not found at {env_example}", err=True) + typer.echo(f"Error: No .env.example files found in {git_root}", err=True) raise typer.Exit(1) # Check if worktree already exists @@ -77,13 +81,44 @@ 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 file + # Generate .env files if not path_only: - console.print("Generating .env file...") + 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: - env_content = parse_env_template(env_example, silent=path_only) - env_file = worktree_path / ".env" - env_file.write_text(env_content) + 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]") diff --git a/src/sprout/utils.py b/src/sprout/utils.py index 885b317..18b3550 100644 --- a/src/sprout/utils.py +++ b/src/sprout/utils.py @@ -83,8 +83,8 @@ def get_used_ports() -> PortSet: if not sprout_dir.exists(): return used_ports - # Scan all .env files in .sprout/*/ - for env_file in sprout_dir.glob("*/.env"): + # Scan all .env files recursively in .sprout/ + for env_file in sprout_dir.rglob("*.env"): if env_file.is_file(): try: content = env_file.read_text() @@ -125,8 +125,16 @@ def find_available_port() -> PortNumber: raise SproutError("Could not find an available port after 1000 attempts") -def parse_env_template(template_path: Path, silent: bool = False) -> str: - """Parse .env.example template and process placeholders.""" +def parse_env_template( + template_path: Path, silent: bool = False, used_ports: PortSet | None = None +) -> str: + """Parse .env.example template and process placeholders. + + Args: + template_path: Path to the .env.example template file + silent: If True, use stderr for prompts to keep stdout clean + used_ports: Set of ports already in use (in addition to system-wide used ports) + """ if not template_path.exists(): raise SproutError(f".env.example file not found at {template_path}") @@ -138,6 +146,9 @@ def parse_env_template(template_path: Path, silent: bool = False) -> str: lines: list[str] = [] # Track used ports within this file to avoid duplicates file_ports: PortSet = set() + # Include any additional used ports passed in + if used_ports: + file_ports.update(used_ports) for line in content.splitlines(): # Process {{ auto_port() }} placeholders diff --git a/tests/test_commands.py b/tests/test_commands.py index 983308b..eda9970 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -111,26 +111,27 @@ def test_create_with_path_flag_error(self, mocker): 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) - mocker.patch("sprout.commands.create.get_git_root", return_value=Path("/project")) + mock_git_root = Path("/project") + mocker.patch("sprout.commands.create.get_git_root", return_value=mock_git_root) - mock_env_example = Mock() - mock_env_example.exists.return_value = False - mocker.patch("pathlib.Path.__truediv__", return_value=mock_env_example) + # Mock rglob to return empty list + mocker.patch.object(Path, "rglob", return_value=[]) result = runner.invoke(app, ["create", "feature-branch"]) assert result.exit_code == 1 - assert ".env.example file not found" in result.stdout + assert "No .env.example files found" in result.stdout def test_create_worktree_exists(self, mocker): """Test error when worktree already exists.""" mocker.patch("sprout.commands.create.is_git_repository", return_value=True) - mocker.patch("sprout.commands.create.get_git_root", return_value=Path("/project")) + mock_git_root = Path("/project") + mocker.patch("sprout.commands.create.get_git_root", return_value=mock_git_root) mocker.patch("sprout.commands.create.worktree_exists", return_value=True) - mock_env_example = Mock() - mock_env_example.exists.return_value = True - mocker.patch("pathlib.Path.__truediv__", return_value=mock_env_example) + # Mock rglob to return a fake .env.example + mock_env_example = Path("/project/.env.example") + mocker.patch.object(Path, "rglob", return_value=[mock_env_example]) result = runner.invoke(app, ["create", "feature-branch"]) diff --git a/tests/test_integration.py b/tests/test_integration.py index 2c98a58..59ba664 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -219,7 +219,7 @@ def test_error_cases(self, git_repo, monkeypatch, tmp_path): (git_repo / ".env.example").unlink() result = runner.invoke(app, ["create", "another-branch"]) assert result.exit_code == 1 - assert ".env.example file not found" in result.stdout + assert "No .env.example files found" in result.stdout # Test outside git repo using a separate temp directory import tempfile diff --git a/tests/test_multi_env.py b/tests/test_multi_env.py new file mode 100644 index 0000000..fc4e25d --- /dev/null +++ b/tests/test_multi_env.py @@ -0,0 +1,172 @@ +"""Tests for multiple .env.example files functionality.""" + +from typer.testing import CliRunner + +from sprout.cli import app +from sprout.utils import get_used_ports + +from .test_integration import git_repo # noqa: F401 + +runner = CliRunner() + + +class TestMultipleEnvExamples: + """Test handling of multiple .env.example files.""" + + def test_create_with_multiple_env_examples(self, git_repo, monkeypatch): # noqa: F811 + """Test creating worktree with multiple .env.example files.""" + git_repo, default_branch = git_repo + monkeypatch.chdir(git_repo) + monkeypatch.setenv("API_KEY", "test_key") + monkeypatch.setenv("DB_PASSWORD", "test_password") + + # Create service directories with .env.example files + service_a = git_repo / "service-a" + service_a.mkdir() + (service_a / ".env.example").write_text("""# Service A Configuration +API_KEY={{ API_KEY }} +API_PORT={{ auto_port() }} +""") + + service_b = git_repo / "service-b" + service_b.mkdir() + (service_b / ".env.example").write_text("""# Service B Configuration +DB_PASSWORD={{ DB_PASSWORD }} +DB_PORT={{ auto_port() }} +""") + + # Create worktree + result = runner.invoke(app, ["create", "feature-multi"]) + assert result.exit_code == 0 + assert "Generating .env files from 3 template(s)" in result.stdout + + # Check that all .env files were created with correct structure + worktree_path = git_repo / ".sprout" / "feature-multi" + assert (worktree_path / ".env").exists() + assert (worktree_path / "service-a" / ".env").exists() + assert (worktree_path / "service-b" / ".env").exists() + + # Check content + root_env = (worktree_path / ".env").read_text() + assert "API_KEY=test_key" in root_env + + service_a_env = (worktree_path / "service-a" / ".env").read_text() + assert "API_KEY=test_key" in service_a_env + assert "API_PORT=" in service_a_env + + service_b_env = (worktree_path / "service-b" / ".env").read_text() + assert "DB_PASSWORD=test_password" in service_b_env + assert "DB_PORT=" in service_b_env + + def test_port_uniqueness_across_services(self, git_repo, monkeypatch): # noqa: F811 + """Test that auto_port() generates unique ports across all services.""" + git_repo, default_branch = git_repo + monkeypatch.chdir(git_repo) + monkeypatch.setenv("API_KEY", "test_key") # Set env var for root .env.example + + # Create multiple services with port requirements + for i in range(3): + service_dir = git_repo / f"service-{i}" + service_dir.mkdir() + (service_dir / ".env.example").write_text(f"""# Service {i} +PORT1={{{{ auto_port() }}}} +PORT2={{{{ auto_port() }}}} +""") + + # Create worktree + result = runner.invoke(app, ["create", "test-ports"]) + assert result.exit_code == 0 + + # Collect all ports + all_ports = set() + worktree_path = git_repo / ".sprout" / "test-ports" + + for i in range(3): + env_content = (worktree_path / f"service-{i}" / ".env").read_text() + lines = env_content.strip().split('\n') + for line in lines: + if '=' in line and not line.startswith('#'): + port = int(line.split('=')[1]) + assert port not in all_ports, f"Port {port} is duplicated" + all_ports.add(port) + + # Should have 6 unique ports (2 per service × 3 services) + assert len(all_ports) == 6 + + def test_global_port_uniqueness_across_worktrees(self, git_repo, monkeypatch): # noqa: F811 + """Test that ports are unique across different worktrees.""" + git_repo, default_branch = git_repo + monkeypatch.chdir(git_repo) + monkeypatch.setenv("API_KEY", "test_key") # Set env var for root .env.example + + # Create service with ports + service_dir = git_repo / "service" + service_dir.mkdir() + (service_dir / ".env.example").write_text(""" +PORT1={{ auto_port() }} +PORT2={{ auto_port() }} +""") + + # Create first worktree + result = runner.invoke(app, ["create", "branch1"]) + assert result.exit_code == 0 + + # Get ports from first worktree + env1 = (git_repo / ".sprout" / "branch1" / "service" / ".env").read_text() + ports1 = set() + for line in env1.strip().split('\n'): + if '=' in line and not line.startswith('#'): + ports1.add(int(line.split('=')[1])) + + # Create second worktree + result = runner.invoke(app, ["create", "branch2"]) + assert result.exit_code == 0 + + # Get ports from second worktree + env2 = (git_repo / ".sprout" / "branch2" / "service" / ".env").read_text() + ports2 = set() + for line in env2.strip().split('\n'): + if '=' in line and not line.startswith('#'): + ports2.add(int(line.split('=')[1])) + + # Ensure no overlap + assert len(ports1.intersection(ports2)) == 0, "Ports should not overlap between worktrees" + + def test_nested_directory_structure(self, git_repo, monkeypatch): # noqa: F811 + """Test handling of nested directory structures.""" + git_repo, default_branch = git_repo + monkeypatch.chdir(git_repo) + monkeypatch.setenv("API_KEY", "test_key") # Set env var for root .env.example + + # Create nested structure + nested_path = git_repo / "services" / "backend" / "api" + nested_path.mkdir(parents=True) + (nested_path / ".env.example").write_text("NESTED_PORT={{ auto_port() }}") + + # Create worktree + result = runner.invoke(app, ["create", "nested-test"]) + assert result.exit_code == 0 + + # Check nested .env was created + worktree_path = git_repo / ".sprout" / "nested-test" + nested_env_path = worktree_path / "services" / "backend" / "api" / ".env" + assert nested_env_path.exists() + assert "NESTED_PORT=" in nested_env_path.read_text() + + def test_get_used_ports_recursive(self, tmp_path, monkeypatch): + """Test that get_used_ports now searches recursively.""" + monkeypatch.setattr("sprout.utils.get_sprout_dir", lambda: tmp_path) + + # Create nested structure with .env files + (tmp_path / "branch1").mkdir() + (tmp_path / "branch1" / ".env").write_text("PORT1=8080") + + (tmp_path / "branch1" / "service-a").mkdir() + (tmp_path / "branch1" / "service-a" / ".env").write_text("PORT2=8081") + + (tmp_path / "branch2" / "nested" / "deep").mkdir(parents=True) + (tmp_path / "branch2" / "nested" / "deep" / ".env").write_text("PORT3=8082") + + # Test recursive port collection + ports = get_used_ports() + assert ports == {8080, 8081, 8082} From 11c39d7cd628c609d2a1b2539191d102864e9fa8 Mon Sep 17 00:00:00 2001 From: Sho Nakatani Date: Sat, 28 Jun 2025 09:38:17 +0900 Subject: [PATCH 2/7] feat: Add monorepo tutorial and environment configuration for backend, frontend, and shared services --- README.md | 48 ++++++++++++++++++ sample/monorepo/.env.example | 17 +++++++ sample/monorepo/README.md | 70 +++++++++++++++++++++++++++ sample/monorepo/backend/.env.example | 21 ++++++++ sample/monorepo/frontend/.env.example | 15 ++++++ sample/monorepo/shared/.env.example | 16 ++++++ 6 files changed, 187 insertions(+) create mode 100644 sample/monorepo/.env.example create mode 100644 sample/monorepo/README.md create mode 100644 sample/monorepo/backend/.env.example create mode 100644 sample/monorepo/frontend/.env.example create mode 100644 sample/monorepo/shared/.env.example diff --git a/README.md b/README.md index 5090cdf..cf7b34d 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,54 @@ sprout create another-branch # → Enter a value for 'DATABASE_URL': [user input required] ``` +## Monorepo Tutorial + +Try out the monorepo functionality with the included sample: + +1. **Navigate to the sample monorepo**: + ```bash + cd sample/monorepo + ``` + +2. **Set required environment variables**: + ```bash + export API_KEY="your-api-key" + export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/myapp" + export REACT_APP_API_KEY="your-frontend-api-key" + export JWT_SECRET="your-jwt-secret" + export SMTP_USER="your-smtp-username" + export SMTP_PASS="your-smtp-password" + ``` + +3. **Create a development environment**: + ```bash + sprout create monorepo-feature + ``` + +4. **Navigate to the created environment**: + ```bash + cd .sprout/monorepo-feature + ``` + +5. **Verify all services have unique ports**: + ```bash + find . -name "*.env" -exec echo "=== {} ===" \; -exec cat {} \; + ``` + +6. **Start all services**: + ```bash + cd sample/monorepo + docker-compose up -d + ``` + +The sample includes: +- **Root service**: Database and Redis with shared configuration +- **Frontend**: React app with API integration +- **Backend**: REST API with authentication +- **Shared**: Utilities with message queue and monitoring + +Each service gets unique, conflict-free ports automatically! + ## Documentation - [Architecture Overview](docs/sprout-cli/overview.md) - Design philosophy, architecture, and implementation details diff --git a/sample/monorepo/.env.example b/sample/monorepo/.env.example new file mode 100644 index 0000000..01073d2 --- /dev/null +++ b/sample/monorepo/.env.example @@ -0,0 +1,17 @@ +# Root configuration for the entire application +APP_NAME=MyMonorepoApp +APP_ENV=development + +# Database configuration (shared across services) +DATABASE_URL={{ DATABASE_URL }} +DB_HOST=localhost +DB_PORT={{ auto_port() }} +DB_NAME=myapp + +# Redis configuration +REDIS_HOST=localhost +REDIS_PORT={{ auto_port() }} + +# Common API settings +API_VERSION=v1 +LOG_LEVEL=info \ No newline at end of file diff --git a/sample/monorepo/README.md b/sample/monorepo/README.md new file mode 100644 index 0000000..b97ce13 --- /dev/null +++ b/sample/monorepo/README.md @@ -0,0 +1,70 @@ +# Sample Monorepo for Sprout Testing + +This directory contains a sample monorepo structure to demonstrate sprout's multiple `.env.example` file support. + +## Structure + +``` +sample/monorepo/ +├── .env.example # Root configuration (database, redis, common settings) +├── docker-compose.yml # Multi-service Docker setup +├── frontend/ +│ └── .env.example # Frontend-specific environment variables +├── backend/ +│ └── .env.example # Backend API configuration +└── shared/ + └── .env.example # Shared utilities configuration +``` + +## Environment Variables Required + +Before testing, set these environment variables: + +```bash +export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/myapp" +export REACT_APP_API_KEY="your-frontend-api-key" +export JWT_SECRET="your-jwt-secret-key" +export SMTP_USER="your-smtp-username" +export SMTP_PASS="your-smtp-password" +``` + +## Testing with Sprout + +1. Navigate to this directory: + ```bash + cd sample/monorepo + ``` + +2. Initialize as a git repository (if not already): + ```bash + git init + git add . + git commit -m "Initial monorepo setup" + ``` + +3. Create a sprout worktree: + ```bash + sprout create feature-test + ``` + +4. Navigate to the created worktree: + ```bash + cd .sprout/feature-test + ``` + +5. Verify all `.env` files were created with unique ports: + ```bash + find . -name "*.env" -exec echo "=== {} ===" \; -exec cat {} \; + ``` + +6. Start the services: + ```bash + docker-compose up -d + ``` + +## Expected Behavior + +- Sprout should detect all 4 `.env.example` files +- Each `{{ auto_port() }}` should get a unique port number +- All `.env` files should be created in their respective directories +- No port conflicts should occur when running multiple worktrees \ No newline at end of file diff --git a/sample/monorepo/backend/.env.example b/sample/monorepo/backend/.env.example new file mode 100644 index 0000000..ff042d1 --- /dev/null +++ b/sample/monorepo/backend/.env.example @@ -0,0 +1,21 @@ +# Backend API service configuration +API_PORT={{ auto_port() }} +API_HOST=0.0.0.0 + +# Authentication +JWT_SECRET={{ JWT_SECRET }} +JWT_EXPIRY=24h + +# External services +SMTP_HOST=localhost +SMTP_PORT=587 +SMTP_USER={{ SMTP_USER }} +SMTP_PASS={{ SMTP_PASS }} + +# File upload +UPLOAD_MAX_SIZE=10MB +UPLOAD_PATH=/tmp/uploads + +# Rate limiting +RATE_LIMIT_WINDOW=15m +RATE_LIMIT_MAX=100 \ No newline at end of file diff --git a/sample/monorepo/frontend/.env.example b/sample/monorepo/frontend/.env.example new file mode 100644 index 0000000..9a1b8b1 --- /dev/null +++ b/sample/monorepo/frontend/.env.example @@ -0,0 +1,15 @@ +# Frontend service configuration +FRONTEND_PORT={{ auto_port() }} +FRONTEND_HOST=0.0.0.0 + +# API endpoints +API_BASE_URL=http://localhost:3001 +WEBSOCKET_URL=ws://localhost:3002 + +# Development settings +REACT_APP_DEBUG=true +REACT_APP_API_KEY={{ REACT_APP_API_KEY }} + +# Build settings +PUBLIC_URL=/ +BUILD_PATH=dist \ No newline at end of file diff --git a/sample/monorepo/shared/.env.example b/sample/monorepo/shared/.env.example new file mode 100644 index 0000000..42dfa52 --- /dev/null +++ b/sample/monorepo/shared/.env.example @@ -0,0 +1,16 @@ +# Shared utilities and services configuration +SHARED_SERVICE_PORT={{ auto_port() }} + +# Message queue +RABBITMQ_HOST=localhost +RABBITMQ_PORT={{ auto_port() }} +RABBITMQ_USER=guest +RABBITMQ_PASS=guest + +# Monitoring +METRICS_PORT={{ auto_port() }} +HEALTH_CHECK_INTERVAL=30s + +# Cache settings +CACHE_TTL=3600 +CACHE_MAX_KEYS=10000 \ No newline at end of file From 76fe1f5a4d88a44553c2ad5a67ca23b476391cc9 Mon Sep 17 00:00:00 2001 From: Sho Nakatani Date: Sat, 28 Jun 2025 09:42:35 +0900 Subject: [PATCH 3/7] chore: Remove obsolete .env.example file --- .env.example | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 .env.example diff --git a/.env.example b/.env.example deleted file mode 100644 index 1c04d61..0000000 --- a/.env.example +++ /dev/null @@ -1,12 +0,0 @@ -# Example environment configuration -API_KEY={{ API_KEY }} -WEB_PORT={{ auto_port() }} -DB_PORT={{ auto_port() }} -REDIS_PORT={{ auto_port() }} - -# Static configuration -NODE_ENV=development -LOG_LEVEL=debug - -# Docker Compose variables (preserved) -COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME:-sprout} \ No newline at end of file From 3cdbce0a6e902ac336dbff68cb0f964c5516241a Mon Sep 17 00:00:00 2001 From: Sho Nakatani Date: Sat, 28 Jun 2025 10:08:05 +0900 Subject: [PATCH 4/7] feat: Remove obsolete .env.example files and simplify backend and frontend configurations --- sample/monorepo/.env.example | 17 ----------------- sample/monorepo/backend/.env.example | 22 ++-------------------- sample/monorepo/frontend/.env.example | 16 ++-------------- sample/monorepo/shared/.env.example | 16 ---------------- src/sprout/utils.py | 12 +++++++++--- 5 files changed, 13 insertions(+), 70 deletions(-) delete mode 100644 sample/monorepo/.env.example delete mode 100644 sample/monorepo/shared/.env.example diff --git a/sample/monorepo/.env.example b/sample/monorepo/.env.example deleted file mode 100644 index 01073d2..0000000 --- a/sample/monorepo/.env.example +++ /dev/null @@ -1,17 +0,0 @@ -# Root configuration for the entire application -APP_NAME=MyMonorepoApp -APP_ENV=development - -# Database configuration (shared across services) -DATABASE_URL={{ DATABASE_URL }} -DB_HOST=localhost -DB_PORT={{ auto_port() }} -DB_NAME=myapp - -# Redis configuration -REDIS_HOST=localhost -REDIS_PORT={{ auto_port() }} - -# Common API settings -API_VERSION=v1 -LOG_LEVEL=info \ No newline at end of file diff --git a/sample/monorepo/backend/.env.example b/sample/monorepo/backend/.env.example index ff042d1..5bcce62 100644 --- a/sample/monorepo/backend/.env.example +++ b/sample/monorepo/backend/.env.example @@ -1,21 +1,3 @@ -# Backend API service configuration -API_PORT={{ auto_port() }} -API_HOST=0.0.0.0 - -# Authentication +# Backend service JWT_SECRET={{ JWT_SECRET }} -JWT_EXPIRY=24h - -# External services -SMTP_HOST=localhost -SMTP_PORT=587 -SMTP_USER={{ SMTP_USER }} -SMTP_PASS={{ SMTP_PASS }} - -# File upload -UPLOAD_MAX_SIZE=10MB -UPLOAD_PATH=/tmp/uploads - -# Rate limiting -RATE_LIMIT_WINDOW=15m -RATE_LIMIT_MAX=100 \ No newline at end of file +API_PORT={{ auto_port() }} \ No newline at end of file diff --git a/sample/monorepo/frontend/.env.example b/sample/monorepo/frontend/.env.example index 9a1b8b1..c03781c 100644 --- a/sample/monorepo/frontend/.env.example +++ b/sample/monorepo/frontend/.env.example @@ -1,15 +1,3 @@ -# Frontend service configuration -FRONTEND_PORT={{ auto_port() }} -FRONTEND_HOST=0.0.0.0 - -# API endpoints -API_BASE_URL=http://localhost:3001 -WEBSOCKET_URL=ws://localhost:3002 - -# Development settings -REACT_APP_DEBUG=true +# Frontend service REACT_APP_API_KEY={{ REACT_APP_API_KEY }} - -# Build settings -PUBLIC_URL=/ -BUILD_PATH=dist \ No newline at end of file +FRONTEND_PORT={{ auto_port() }} \ No newline at end of file diff --git a/sample/monorepo/shared/.env.example b/sample/monorepo/shared/.env.example deleted file mode 100644 index 42dfa52..0000000 --- a/sample/monorepo/shared/.env.example +++ /dev/null @@ -1,16 +0,0 @@ -# Shared utilities and services configuration -SHARED_SERVICE_PORT={{ auto_port() }} - -# Message queue -RABBITMQ_HOST=localhost -RABBITMQ_PORT={{ auto_port() }} -RABBITMQ_USER=guest -RABBITMQ_PASS=guest - -# Monitoring -METRICS_PORT={{ auto_port() }} -HEALTH_CHECK_INTERVAL=30s - -# Cache settings -CACHE_TTL=3600 -CACHE_MAX_KEYS=10000 \ No newline at end of file diff --git a/src/sprout/utils.py b/src/sprout/utils.py index 18b3550..2354420 100644 --- a/src/sprout/utils.py +++ b/src/sprout/utils.py @@ -167,13 +167,19 @@ def replace_variable(match: re.Match[str]) -> str: # Check environment variable first value = os.environ.get(var_name) if value is None: - # Prompt user for value + # Create a relative path for display + try: + display_path = template_path.relative_to(Path.cwd()) + except ValueError: + display_path = template_path + + # Prompt user for value with file context 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) + typer.echo(f"Enter a value for '{var_name}' (from {display_path}): ", err=True, nl=False) value = input() else: - value = console.input(f"Enter a value for '{var_name}': ") + value = console.input(f"Enter a value for '[cyan]{var_name}[/cyan]' (from [dim]{display_path}[/dim]): ") return value line = re.sub(r"{{\s*([^}]+)\s*}}", replace_variable, line) From 60292754e3f794f07d0bc8d45ce5559a795cfb13 Mon Sep 17 00:00:00 2001 From: Sho Nakatani Date: Sat, 28 Jun 2025 10:22:15 +0900 Subject: [PATCH 5/7] feat: Update worktree creation to find tracked .env.example files using git command --- src/sprout/commands/create.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/sprout/commands/create.py b/src/sprout/commands/create.py index 463ff40..4ec1fa8 100644 --- a/src/sprout/commands/create.py +++ b/src/sprout/commands/create.py @@ -36,8 +36,14 @@ def create_worktree(branch_name: BranchName, path_only: bool = False) -> Never: git_root = get_git_root() - # Find all .env.example files in the repository - env_examples = list(git_root.rglob(".env.example")) + # Find all .env.example files that are tracked by git + result = run_command(["git", "ls-files", "*.env.example", "**/*.env.example"]) + env_examples = [] + if result.stdout.strip(): + for file_path in result.stdout.strip().split("\n"): + full_path = git_root / file_path + if full_path.exists(): + env_examples.append(full_path) if not env_examples: if not path_only: From 7b1701e4f454c3a41256cf345b593b39df400284 Mon Sep 17 00:00:00 2001 From: Sho Nakatani Date: Sat, 28 Jun 2025 10:33:58 +0900 Subject: [PATCH 6/7] fix: Apply formatter changes and update tests for git-tracked .env.example files - Apply ruff formatter changes to improve code quality - Update tests to properly mock git ls-files command - Add CHANGELOG entry about git-tracked files behavior - Ensure all test .env.example files are properly committed to git --- CHANGELOG.md | 1 + src/sprout/utils.py | 11 ++++++++--- tests/test_commands.py | 30 +++++++++++++++++++++++++----- tests/test_integration.py | 8 ++++++++ tests/test_multi_env.py | 18 ++++++++++++++++++ 5 files changed, 60 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e35d9ef..53b4e93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 ### Deprecated diff --git a/src/sprout/utils.py b/src/sprout/utils.py index 2354420..156328d 100644 --- a/src/sprout/utils.py +++ b/src/sprout/utils.py @@ -172,14 +172,19 @@ def replace_variable(match: re.Match[str]) -> str: display_path = template_path.relative_to(Path.cwd()) except ValueError: display_path = template_path - + # Prompt user for value with file context if silent: # Use stderr for prompts in silent mode to keep stdout clean - typer.echo(f"Enter a value for '{var_name}' (from {display_path}): ", err=True, nl=False) + prompt = f"Enter a value for '{var_name}' (from {display_path}): " + typer.echo(prompt, err=True, nl=False) value = input() else: - value = console.input(f"Enter a value for '[cyan]{var_name}[/cyan]' (from [dim]{display_path}[/dim]): ") + prompt = ( + f"Enter a value for '[cyan]{var_name}[/cyan]' " + f"(from [dim]{display_path}[/dim]): " + ) + value = console.input(prompt) return value line = re.sub(r"{{\s*([^}]+)\s*}}", replace_variable, line) diff --git a/tests/test_commands.py b/tests/test_commands.py index eda9970..25135ee 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -38,6 +38,16 @@ def test_create_success_new_branch(self, mocker, tmp_path): # Mock command execution mock_run = mocker.patch("sprout.commands.create.run_command") + # Mock git ls-files to return .env.example + mock_run.side_effect = lambda cmd, **kwargs: ( + Mock(stdout=".env.example\n", returncode=0) if cmd[1] == "ls-files" + else Mock(returncode=0) + ) + # Mock git ls-files to return .env.example + mock_run.side_effect = lambda cmd, **kwargs: ( + Mock(stdout=".env.example\n", returncode=0) if cmd[1] == "ls-files" + else Mock(returncode=0) + ) # Run command result = runner.invoke(app, ["create", "feature-branch"]) @@ -84,6 +94,11 @@ def test_create_with_path_flag_success(self, mocker, tmp_path): # Mock command execution mock_run = mocker.patch("sprout.commands.create.run_command") + # Mock git ls-files to return .env.example + mock_run.side_effect = lambda cmd, **kwargs: ( + Mock(stdout=".env.example\n", returncode=0) if cmd[1] == "ls-files" + else Mock(returncode=0) + ) # Run command with --path flag result = runner.invoke(app, ["create", "feature-branch", "--path"]) @@ -114,8 +129,9 @@ def test_create_no_env_example(self, mocker): mock_git_root = Path("/project") mocker.patch("sprout.commands.create.get_git_root", return_value=mock_git_root) - # Mock rglob to return empty list - mocker.patch.object(Path, "rglob", return_value=[]) + # Mock git ls-files to return empty list + mock_run = mocker.patch("sprout.commands.create.run_command") + mock_run.return_value = Mock(stdout="", returncode=0) result = runner.invoke(app, ["create", "feature-branch"]) @@ -129,9 +145,13 @@ def test_create_worktree_exists(self, mocker): mocker.patch("sprout.commands.create.get_git_root", return_value=mock_git_root) mocker.patch("sprout.commands.create.worktree_exists", return_value=True) - # Mock rglob to return a fake .env.example - mock_env_example = Path("/project/.env.example") - mocker.patch.object(Path, "rglob", return_value=[mock_env_example]) + # Mock git ls-files to return .env.example + mock_run = mocker.patch("sprout.commands.create.run_command") + mock_run.return_value = Mock(stdout=".env.example\n", returncode=0) + + # Mock Path.exists to return True for .env.example + mock_exists = mocker.patch("pathlib.Path.exists") + mock_exists.return_value = True result = runner.invoke(app, ["create", "feature-branch"]) diff --git a/tests/test_integration.py b/tests/test_integration.py index 59ba664..6eeb5a8 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -57,6 +57,10 @@ def git_repo(tmp_path): "COMPOSE_VAR=${COMPOSE_VAR:-default}\n" ) + # Add .env.example to git + subprocess.run(["git", "add", ".env.example"], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "-m", "Add .env.example"], cwd=tmp_path, check=True) + return tmp_path, default_branch @@ -84,6 +88,10 @@ def test_placeholder_substitution_from_env(self, git_repo, monkeypatch): "COMPOSE_VAR=${COMPOSE_VAR:-default}\n" ) + # Add to git + subprocess.run(["git", "add", ".env.example"], cwd=git_repo, check=True) + subprocess.run(["git", "commit", "-m", "Add .env.example"], cwd=git_repo, check=True) + # Create worktree - SECRET_TOKEN should be prompted but we can't test that # So let's set it too monkeypatch.setenv("SECRET_TOKEN", "secret123") diff --git a/tests/test_multi_env.py b/tests/test_multi_env.py index fc4e25d..dc21c35 100644 --- a/tests/test_multi_env.py +++ b/tests/test_multi_env.py @@ -1,5 +1,7 @@ """Tests for multiple .env.example files functionality.""" +import subprocess + from typer.testing import CliRunner from sprout.cli import app @@ -35,6 +37,10 @@ def test_create_with_multiple_env_examples(self, git_repo, monkeypatch): # noqa DB_PORT={{ auto_port() }} """) + # Add files to git + subprocess.run(["git", "add", "."], cwd=git_repo, check=True) + subprocess.run(["git", "commit", "-m", "Add services"], cwd=git_repo, check=True) + # Create worktree result = runner.invoke(app, ["create", "feature-multi"]) assert result.exit_code == 0 @@ -73,6 +79,10 @@ def test_port_uniqueness_across_services(self, git_repo, monkeypatch): # noqa: PORT2={{{{ auto_port() }}}} """) + # Add files to git + subprocess.run(["git", "add", "."], cwd=git_repo, check=True) + subprocess.run(["git", "commit", "-m", "Add services"], cwd=git_repo, check=True) + # Create worktree result = runner.invoke(app, ["create", "test-ports"]) assert result.exit_code == 0 @@ -107,6 +117,10 @@ def test_global_port_uniqueness_across_worktrees(self, git_repo, monkeypatch): PORT2={{ auto_port() }} """) + # Add files to git + subprocess.run(["git", "add", "."], cwd=git_repo, check=True) + subprocess.run(["git", "commit", "-m", "Add service"], cwd=git_repo, check=True) + # Create first worktree result = runner.invoke(app, ["create", "branch1"]) assert result.exit_code == 0 @@ -143,6 +157,10 @@ def test_nested_directory_structure(self, git_repo, monkeypatch): # noqa: F811 nested_path.mkdir(parents=True) (nested_path / ".env.example").write_text("NESTED_PORT={{ auto_port() }}") + # Add files to git + subprocess.run(["git", "add", "."], cwd=git_repo, check=True) + subprocess.run(["git", "commit", "-m", "Add nested service"], cwd=git_repo, check=True) + # Create worktree result = runner.invoke(app, ["create", "nested-test"]) assert result.exit_code == 0 From cd17b42370bc372d840855768a9304372ed31a4d Mon Sep 17 00:00:00 2001 From: Sho Nakatani Date: Sat, 28 Jun 2025 10:38:31 +0900 Subject: [PATCH 7/7] style: Apply ruff formatting for CI compliance --- src/sprout/commands/create.py | 4 +--- tests/test_commands.py | 9 ++++++--- tests/test_multi_env.py | 18 +++++++++--------- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/sprout/commands/create.py b/src/sprout/commands/create.py index 4ec1fa8..427f28c 100644 --- a/src/sprout/commands/create.py +++ b/src/sprout/commands/create.py @@ -110,9 +110,7 @@ def create_worktree(branch_name: BranchName, path_only: bool = False) -> Never: # Parse template with combined used ports env_content = parse_env_template( - env_example, - silent=path_only, - used_ports=all_used_ports | session_ports + env_example, silent=path_only, used_ports=all_used_ports | session_ports ) # Extract ports from generated content and add to session_ports diff --git a/tests/test_commands.py b/tests/test_commands.py index 25135ee..3aa89dd 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -40,12 +40,14 @@ def test_create_success_new_branch(self, mocker, tmp_path): mock_run = mocker.patch("sprout.commands.create.run_command") # Mock git ls-files to return .env.example mock_run.side_effect = lambda cmd, **kwargs: ( - Mock(stdout=".env.example\n", returncode=0) if cmd[1] == "ls-files" + Mock(stdout=".env.example\n", returncode=0) + if cmd[1] == "ls-files" else Mock(returncode=0) ) # Mock git ls-files to return .env.example mock_run.side_effect = lambda cmd, **kwargs: ( - Mock(stdout=".env.example\n", returncode=0) if cmd[1] == "ls-files" + Mock(stdout=".env.example\n", returncode=0) + if cmd[1] == "ls-files" else Mock(returncode=0) ) @@ -96,7 +98,8 @@ def test_create_with_path_flag_success(self, mocker, tmp_path): mock_run = mocker.patch("sprout.commands.create.run_command") # Mock git ls-files to return .env.example mock_run.side_effect = lambda cmd, **kwargs: ( - Mock(stdout=".env.example\n", returncode=0) if cmd[1] == "ls-files" + Mock(stdout=".env.example\n", returncode=0) + if cmd[1] == "ls-files" else Mock(returncode=0) ) diff --git a/tests/test_multi_env.py b/tests/test_multi_env.py index dc21c35..ef581fa 100644 --- a/tests/test_multi_env.py +++ b/tests/test_multi_env.py @@ -93,10 +93,10 @@ def test_port_uniqueness_across_services(self, git_repo, monkeypatch): # noqa: for i in range(3): env_content = (worktree_path / f"service-{i}" / ".env").read_text() - lines = env_content.strip().split('\n') + lines = env_content.strip().split("\n") for line in lines: - if '=' in line and not line.startswith('#'): - port = int(line.split('=')[1]) + if "=" in line and not line.startswith("#"): + port = int(line.split("=")[1]) assert port not in all_ports, f"Port {port} is duplicated" all_ports.add(port) @@ -128,9 +128,9 @@ def test_global_port_uniqueness_across_worktrees(self, git_repo, monkeypatch): # Get ports from first worktree env1 = (git_repo / ".sprout" / "branch1" / "service" / ".env").read_text() ports1 = set() - for line in env1.strip().split('\n'): - if '=' in line and not line.startswith('#'): - ports1.add(int(line.split('=')[1])) + for line in env1.strip().split("\n"): + if "=" in line and not line.startswith("#"): + ports1.add(int(line.split("=")[1])) # Create second worktree result = runner.invoke(app, ["create", "branch2"]) @@ -139,9 +139,9 @@ def test_global_port_uniqueness_across_worktrees(self, git_repo, monkeypatch): # Get ports from second worktree env2 = (git_repo / ".sprout" / "branch2" / "service" / ".env").read_text() ports2 = set() - for line in env2.strip().split('\n'): - if '=' in line and not line.startswith('#'): - ports2.add(int(line.split('=')[1])) + for line in env2.strip().split("\n"): + if "=" in line and not line.startswith("#"): + ports2.add(int(line.split("=")[1])) # Ensure no overlap assert len(ports1.intersection(ports2)) == 0, "Ports should not overlap between worktrees"