diff --git a/CHANGELOG.md b/CHANGELOG.md index f8d929a..5178421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 34d0ad1..3b96a49 100644 --- a/README.md +++ b/README.md @@ -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 }} @@ -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 $(...)` diff --git a/docs/sprout-cli/overview.md b/docs/sprout-cli/overview.md index 6a00b8a..e5b4c17 100644 --- a/docs/sprout-cli/overview.md +++ b/docs/sprout-cli/overview.md @@ -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 @@ -42,9 +43,9 @@ ├── .git/ ├── .sprout/ # sprout management directory │ └── / # 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 ``` diff --git a/docs/sprout-cli/usage.md b/docs/sprout-cli/usage.md index cd5121f..b073075 100644 --- a/docs/sprout-cli/usage.md +++ b/docs/sprout-cli/usage.md @@ -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 @@ -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 diff --git a/src/sprout/commands/create.py b/src/sprout/commands/create.py index 427f28c..5a01a91 100644 --- a/src/sprout/commands/create.py +++ b/src/sprout/commands/create.py @@ -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): @@ -87,58 +85,59 @@ 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: @@ -146,8 +145,17 @@ def create_worktree(branch_name: BranchName, path_only: bool = False) -> Never: 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) diff --git a/tests/test_commands.py b/tests/test_commands.py index 3aa89dd..379f0e5 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -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.""" @@ -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.""" diff --git a/tests/test_integration.py b/tests/test_integration.py index 6eeb5a8..e5e5d13 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -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