sprout-cli 0.3.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sprout-cli
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: CLI tool to automate git worktree and Docker Compose development workflows
5
5
  Author: SecDevLab Inc.
6
6
  License: MIT
@@ -41,7 +41,7 @@ A CLI tool to automate git worktree and Docker Compose development workflows.
41
41
  ## Installation
42
42
 
43
43
  ```bash
44
- pip install sprout
44
+ pip install sprout-cli
45
45
  ```
46
46
 
47
47
  For development:
@@ -56,7 +56,9 @@ pip install -e ".[dev]"
56
56
 
57
57
  ## Quick Start
58
58
 
59
- 1. Create a `.env.example` template in your project root:
59
+ **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.
60
+
61
+ 1. (Optional) Create a `.env.example` template in your project root (and optionally in subdirectories) for automatic `.env` generation:
60
62
  ```env
61
63
  # API Configuration
62
64
  API_KEY={{ API_KEY }}
@@ -71,14 +73,28 @@ DB_PORT={{ auto_port() }}
71
73
  # DB_NAME=${DB_NAME}
72
74
  ```
73
75
 
76
+ For monorepo or multi-service projects, you can create `.env.example` files in subdirectories:
77
+ ```
78
+ repo/
79
+ .env.example # Root configuration
80
+ service-a/
81
+ .env.example # Service A specific config
82
+ service-b/
83
+ .env.example # Service B specific config
84
+ ```
85
+
74
86
  2. Create and navigate to a new development environment in one command:
75
87
  ```bash
76
88
  cd $(sprout create feature-branch --path)
77
89
  ```
78
90
 
91
+ **What happens when you run `sprout create`:**
92
+ - If `.env.example` files exist: Sprout will generate corresponding `.env` files with populated variables and unique port assignments
93
+ - If no `.env.example` files exist: Sprout will show a warning and create the worktree without `.env` generation
94
+
79
95
  This single command:
80
96
  - Creates a new git worktree for `feature-branch`
81
- - Generates a `.env` file from your template
97
+ - Generates `.env` files from your templates (if `.env.example` files exist)
82
98
  - Outputs the path to the new environment
83
99
  - Changes to that directory when wrapped in `cd $(...)`
84
100
 
@@ -173,8 +189,9 @@ sprout supports two types of placeholders in `.env.example`:
173
189
 
174
190
  2. **Auto Port Assignment**: `{{ auto_port() }}`
175
191
  - Automatically assigns available ports
176
- - Avoids conflicts with other sprout environments
192
+ - Avoids conflicts across ALL services in ALL sprout environments
177
193
  - Checks system port availability
194
+ - Ensures global uniqueness even in monorepo setups
178
195
 
179
196
  3. **Docker Compose Syntax (Preserved)**: `${VARIABLE}`
180
197
  - NOT processed by sprout - passed through as-is
@@ -196,6 +213,54 @@ sprout create another-branch
196
213
  # → Enter a value for 'DATABASE_URL': [user input required]
197
214
  ```
198
215
 
216
+ ## Monorepo Tutorial
217
+
218
+ Try out the monorepo functionality with the included sample:
219
+
220
+ 1. **Navigate to the sample monorepo**:
221
+ ```bash
222
+ cd sample/monorepo
223
+ ```
224
+
225
+ 2. **Set required environment variables**:
226
+ ```bash
227
+ export API_KEY="your-api-key"
228
+ export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/myapp"
229
+ export REACT_APP_API_KEY="your-frontend-api-key"
230
+ export JWT_SECRET="your-jwt-secret"
231
+ export SMTP_USER="your-smtp-username"
232
+ export SMTP_PASS="your-smtp-password"
233
+ ```
234
+
235
+ 3. **Create a development environment**:
236
+ ```bash
237
+ sprout create monorepo-feature
238
+ ```
239
+
240
+ 4. **Navigate to the created environment**:
241
+ ```bash
242
+ cd .sprout/monorepo-feature
243
+ ```
244
+
245
+ 5. **Verify all services have unique ports**:
246
+ ```bash
247
+ find . -name "*.env" -exec echo "=== {} ===" \; -exec cat {} \;
248
+ ```
249
+
250
+ 6. **Start all services**:
251
+ ```bash
252
+ cd sample/monorepo
253
+ docker-compose up -d
254
+ ```
255
+
256
+ The sample includes:
257
+ - **Root service**: Database and Redis with shared configuration
258
+ - **Frontend**: React app with API integration
259
+ - **Backend**: REST API with authentication
260
+ - **Shared**: Utilities with message queue and monitoring
261
+
262
+ Each service gets unique, conflict-free ports automatically!
263
+
199
264
  ## Documentation
200
265
 
201
266
  - [Architecture Overview](docs/sprout-cli/overview.md) - Design philosophy, architecture, and implementation details
@@ -13,7 +13,7 @@ A CLI tool to automate git worktree and Docker Compose development workflows.
13
13
  ## Installation
14
14
 
15
15
  ```bash
16
- pip install sprout
16
+ pip install sprout-cli
17
17
  ```
18
18
 
19
19
  For development:
@@ -28,7 +28,9 @@ pip install -e ".[dev]"
28
28
 
29
29
  ## Quick Start
30
30
 
31
- 1. Create a `.env.example` template in your project root:
31
+ **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.
32
+
33
+ 1. (Optional) Create a `.env.example` template in your project root (and optionally in subdirectories) for automatic `.env` generation:
32
34
  ```env
33
35
  # API Configuration
34
36
  API_KEY={{ API_KEY }}
@@ -43,14 +45,28 @@ DB_PORT={{ auto_port() }}
43
45
  # DB_NAME=${DB_NAME}
44
46
  ```
45
47
 
48
+ For monorepo or multi-service projects, you can create `.env.example` files in subdirectories:
49
+ ```
50
+ repo/
51
+ .env.example # Root configuration
52
+ service-a/
53
+ .env.example # Service A specific config
54
+ service-b/
55
+ .env.example # Service B specific config
56
+ ```
57
+
46
58
  2. Create and navigate to a new development environment in one command:
47
59
  ```bash
48
60
  cd $(sprout create feature-branch --path)
49
61
  ```
50
62
 
63
+ **What happens when you run `sprout create`:**
64
+ - If `.env.example` files exist: Sprout will generate corresponding `.env` files with populated variables and unique port assignments
65
+ - If no `.env.example` files exist: Sprout will show a warning and create the worktree without `.env` generation
66
+
51
67
  This single command:
52
68
  - Creates a new git worktree for `feature-branch`
53
- - Generates a `.env` file from your template
69
+ - Generates `.env` files from your templates (if `.env.example` files exist)
54
70
  - Outputs the path to the new environment
55
71
  - Changes to that directory when wrapped in `cd $(...)`
56
72
 
@@ -145,8 +161,9 @@ sprout supports two types of placeholders in `.env.example`:
145
161
 
146
162
  2. **Auto Port Assignment**: `{{ auto_port() }}`
147
163
  - Automatically assigns available ports
148
- - Avoids conflicts with other sprout environments
164
+ - Avoids conflicts across ALL services in ALL sprout environments
149
165
  - Checks system port availability
166
+ - Ensures global uniqueness even in monorepo setups
150
167
 
151
168
  3. **Docker Compose Syntax (Preserved)**: `${VARIABLE}`
152
169
  - NOT processed by sprout - passed through as-is
@@ -168,6 +185,54 @@ sprout create another-branch
168
185
  # → Enter a value for 'DATABASE_URL': [user input required]
169
186
  ```
170
187
 
188
+ ## Monorepo Tutorial
189
+
190
+ Try out the monorepo functionality with the included sample:
191
+
192
+ 1. **Navigate to the sample monorepo**:
193
+ ```bash
194
+ cd sample/monorepo
195
+ ```
196
+
197
+ 2. **Set required environment variables**:
198
+ ```bash
199
+ export API_KEY="your-api-key"
200
+ export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/myapp"
201
+ export REACT_APP_API_KEY="your-frontend-api-key"
202
+ export JWT_SECRET="your-jwt-secret"
203
+ export SMTP_USER="your-smtp-username"
204
+ export SMTP_PASS="your-smtp-password"
205
+ ```
206
+
207
+ 3. **Create a development environment**:
208
+ ```bash
209
+ sprout create monorepo-feature
210
+ ```
211
+
212
+ 4. **Navigate to the created environment**:
213
+ ```bash
214
+ cd .sprout/monorepo-feature
215
+ ```
216
+
217
+ 5. **Verify all services have unique ports**:
218
+ ```bash
219
+ find . -name "*.env" -exec echo "=== {} ===" \; -exec cat {} \;
220
+ ```
221
+
222
+ 6. **Start all services**:
223
+ ```bash
224
+ cd sample/monorepo
225
+ docker-compose up -d
226
+ ```
227
+
228
+ The sample includes:
229
+ - **Root service**: Database and Redis with shared configuration
230
+ - **Frontend**: React app with API integration
231
+ - **Backend**: REST API with authentication
232
+ - **Shared**: Utilities with message queue and monitoring
233
+
234
+ Each service gets unique, conflict-free ports automatically!
235
+
171
236
  ## Documentation
172
237
 
173
238
  - [Architecture Overview](docs/sprout-cli/overview.md) - Design philosophy, architecture, and implementation details
@@ -1,3 +1,3 @@
1
1
  """sprout - CLI tool to automate git worktree and Docker Compose development workflows."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.5.0"
@@ -0,0 +1,161 @@
1
+ """Implementation of the create command."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Never
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from sprout.exceptions import SproutError
11
+ from sprout.types import BranchName
12
+ from sprout.utils import (
13
+ branch_exists,
14
+ ensure_sprout_dir,
15
+ get_git_root,
16
+ get_used_ports,
17
+ is_git_repository,
18
+ parse_env_template,
19
+ run_command,
20
+ worktree_exists,
21
+ )
22
+
23
+ console = Console()
24
+
25
+
26
+ def create_worktree(branch_name: BranchName, path_only: bool = False) -> Never:
27
+ """Create a new worktree with development environment."""
28
+ # Check prerequisites
29
+ if not is_git_repository():
30
+ if not path_only:
31
+ console.print("[red]Error: Not in a git repository[/red]")
32
+ console.print("Please run this command from the root of a git repository.")
33
+ else:
34
+ typer.echo("Error: Not in a git repository", err=True)
35
+ raise typer.Exit(1)
36
+
37
+ git_root = get_git_root()
38
+
39
+ # Find all .env.example files that are tracked by git
40
+ result = run_command(["git", "ls-files", "*.env.example", "**/*.env.example"])
41
+ env_examples = []
42
+ if result.stdout.strip():
43
+ for file_path in result.stdout.strip().split("\n"):
44
+ full_path = git_root / file_path
45
+ if full_path.exists():
46
+ env_examples.append(full_path)
47
+
48
+ if not env_examples:
49
+ if not path_only:
50
+ console.print("[yellow]Warning: No .env.example files found[/yellow]")
51
+ console.print(f"Proceeding without .env generation in: {git_root}")
52
+ # Continue execution without exiting
53
+
54
+ # Check if worktree already exists
55
+ if worktree_exists(branch_name):
56
+ if not path_only:
57
+ console.print(f"[red]Error: Worktree for branch '{branch_name}' already exists[/red]")
58
+ else:
59
+ typer.echo(f"Error: Worktree for branch '{branch_name}' already exists", err=True)
60
+ raise typer.Exit(1)
61
+
62
+ # Ensure .sprout directory exists
63
+ sprout_dir = ensure_sprout_dir()
64
+ worktree_path = sprout_dir / branch_name
65
+
66
+ # Create the worktree
67
+ if not path_only:
68
+ console.print(f"Creating worktree for branch [cyan]{branch_name}[/cyan]...")
69
+
70
+ # Check if branch exists, create if it doesn't
71
+ if not branch_exists(branch_name):
72
+ if not path_only:
73
+ console.print(f"Branch '{branch_name}' doesn't exist. Creating new branch...")
74
+ # Create branch with -b flag
75
+ cmd = ["git", "worktree", "add", "-b", branch_name, str(worktree_path)]
76
+ else:
77
+ cmd = ["git", "worktree", "add", str(worktree_path), branch_name]
78
+
79
+ try:
80
+ run_command(cmd)
81
+ except SproutError as e:
82
+ if not path_only:
83
+ console.print(f"[red]Error creating worktree: {e}[/red]")
84
+ else:
85
+ typer.echo(f"Error creating worktree: {e}", err=True)
86
+ raise typer.Exit(1) from e
87
+
88
+ # Generate .env files only if .env.example files exist
89
+ if env_examples:
90
+ if not path_only:
91
+ console.print(f"Generating .env files from {len(env_examples)} template(s)...")
92
+
93
+ # Get all currently used ports to avoid conflicts
94
+ all_used_ports = get_used_ports()
95
+ session_ports: set[int] = set()
96
+
97
+ try:
98
+ for env_example in env_examples:
99
+ # Calculate relative path from git root
100
+ relative_dir = env_example.parent.relative_to(git_root)
101
+
102
+ # Create target directory in worktree if needed
103
+ if relative_dir != Path("."):
104
+ target_dir = worktree_path / relative_dir
105
+ target_dir.mkdir(parents=True, exist_ok=True)
106
+ env_file = target_dir / ".env"
107
+ else:
108
+ env_file = worktree_path / ".env"
109
+
110
+ # Parse template with combined used ports
111
+ env_content = parse_env_template(
112
+ env_example, silent=path_only, used_ports=all_used_ports | session_ports
113
+ )
114
+
115
+ # Extract ports from generated content and add to session_ports
116
+ port_matches = re.findall(r"=(\d{4,5})\b", env_content)
117
+ for port_str in port_matches:
118
+ port = int(port_str)
119
+ if 1024 <= port <= 65535:
120
+ session_ports.add(port)
121
+
122
+ # Write the .env file
123
+ env_file.write_text(env_content)
124
+
125
+ except SproutError as e:
126
+ if not path_only:
127
+ console.print(f"[red]Error generating .env file: {e}[/red]")
128
+ else:
129
+ typer.echo(f"Error generating .env file: {e}", err=True)
130
+ # Clean up worktree on failure
131
+ run_command(["git", "worktree", "remove", str(worktree_path)], check=False)
132
+ raise typer.Exit(1) from e
133
+ except KeyboardInterrupt:
134
+ if not path_only:
135
+ console.print("\n[yellow]Cancelled by user[/yellow]")
136
+ else:
137
+ typer.echo("Cancelled by user", err=True)
138
+ # Clean up worktree on cancellation
139
+ run_command(["git", "worktree", "remove", str(worktree_path)], check=False)
140
+ raise typer.Exit(130) from None
141
+
142
+ # Success message or path output
143
+ if path_only:
144
+ # Output only the path for shell command substitution
145
+ print(str(worktree_path))
146
+ else:
147
+ console.print(f"\n[green]✅ Workspace '{branch_name}' created successfully![/green]\n")
148
+ if env_examples:
149
+ console.print(f"Generated .env files from {len(env_examples)} template(s)")
150
+ else:
151
+ console.print("No .env files generated (no .env.example templates found)")
152
+ console.print("Navigate to your new environment with:")
153
+ try:
154
+ relative_path = worktree_path.relative_to(Path.cwd())
155
+ console.print(f" [cyan]cd {relative_path}[/cyan]")
156
+ except ValueError:
157
+ # If worktree_path is not relative to current directory, show absolute path
158
+ console.print(f" [cyan]cd {worktree_path}[/cyan]")
159
+
160
+ # Exit successfully
161
+ raise typer.Exit(0)
@@ -83,8 +83,8 @@ def get_used_ports() -> PortSet:
83
83
  if not sprout_dir.exists():
84
84
  return used_ports
85
85
 
86
- # Scan all .env files in .sprout/*/
87
- for env_file in sprout_dir.glob("*/.env"):
86
+ # Scan all .env files recursively in .sprout/
87
+ for env_file in sprout_dir.rglob("*.env"):
88
88
  if env_file.is_file():
89
89
  try:
90
90
  content = env_file.read_text()
@@ -125,8 +125,16 @@ def find_available_port() -> PortNumber:
125
125
  raise SproutError("Could not find an available port after 1000 attempts")
126
126
 
127
127
 
128
- def parse_env_template(template_path: Path, silent: bool = False) -> str:
129
- """Parse .env.example template and process placeholders."""
128
+ def parse_env_template(
129
+ template_path: Path, silent: bool = False, used_ports: PortSet | None = None
130
+ ) -> str:
131
+ """Parse .env.example template and process placeholders.
132
+
133
+ Args:
134
+ template_path: Path to the .env.example template file
135
+ silent: If True, use stderr for prompts to keep stdout clean
136
+ used_ports: Set of ports already in use (in addition to system-wide used ports)
137
+ """
130
138
  if not template_path.exists():
131
139
  raise SproutError(f".env.example file not found at {template_path}")
132
140
 
@@ -138,6 +146,9 @@ def parse_env_template(template_path: Path, silent: bool = False) -> str:
138
146
  lines: list[str] = []
139
147
  # Track used ports within this file to avoid duplicates
140
148
  file_ports: PortSet = set()
149
+ # Include any additional used ports passed in
150
+ if used_ports:
151
+ file_ports.update(used_ports)
141
152
 
142
153
  for line in content.splitlines():
143
154
  # Process {{ auto_port() }} placeholders
@@ -156,13 +167,24 @@ def parse_env_template(template_path: Path, silent: bool = False) -> str:
156
167
  # Check environment variable first
157
168
  value = os.environ.get(var_name)
158
169
  if value is None:
159
- # Prompt user for value
170
+ # Create a relative path for display
171
+ try:
172
+ display_path = template_path.relative_to(Path.cwd())
173
+ except ValueError:
174
+ display_path = template_path
175
+
176
+ # Prompt user for value with file context
160
177
  if silent:
161
178
  # Use stderr for prompts in silent mode to keep stdout clean
162
- typer.echo(f"Enter a value for '{var_name}': ", err=True, nl=False)
179
+ prompt = f"Enter a value for '{var_name}' (from {display_path}): "
180
+ typer.echo(prompt, err=True, nl=False)
163
181
  value = input()
164
182
  else:
165
- value = console.input(f"Enter a value for '{var_name}': ")
183
+ prompt = (
184
+ f"Enter a value for '[cyan]{var_name}[/cyan]' "
185
+ f"(from [dim]{display_path}[/dim]): "
186
+ )
187
+ value = console.input(prompt)
166
188
  return value
167
189
 
168
190
  line = re.sub(r"{{\s*([^}]+)\s*}}", replace_variable, line)
@@ -38,6 +38,18 @@ class TestCreateCommand:
38
38
 
39
39
  # Mock command execution
40
40
  mock_run = mocker.patch("sprout.commands.create.run_command")
41
+ # Mock git ls-files to return .env.example
42
+ mock_run.side_effect = lambda cmd, **kwargs: (
43
+ Mock(stdout=".env.example\n", returncode=0)
44
+ if cmd[1] == "ls-files"
45
+ else Mock(returncode=0)
46
+ )
47
+ # Mock git ls-files to return .env.example
48
+ mock_run.side_effect = lambda cmd, **kwargs: (
49
+ Mock(stdout=".env.example\n", returncode=0)
50
+ if cmd[1] == "ls-files"
51
+ else Mock(returncode=0)
52
+ )
41
53
 
42
54
  # Run command
43
55
  result = runner.invoke(app, ["create", "feature-branch"])
@@ -84,6 +96,12 @@ class TestCreateCommand:
84
96
 
85
97
  # Mock command execution
86
98
  mock_run = mocker.patch("sprout.commands.create.run_command")
99
+ # Mock git ls-files to return .env.example
100
+ mock_run.side_effect = lambda cmd, **kwargs: (
101
+ Mock(stdout=".env.example\n", returncode=0)
102
+ if cmd[1] == "ls-files"
103
+ else Mock(returncode=0)
104
+ )
87
105
 
88
106
  # Run command with --path flag
89
107
  result = runner.invoke(app, ["create", "feature-branch", "--path"])
@@ -108,35 +126,80 @@ class TestCreateCommand:
108
126
  # stdout should be empty
109
127
  assert result.stdout == ""
110
128
 
111
- def test_create_no_env_example(self, mocker):
112
- """Test error when .env.example doesn't exist."""
129
+ def test_create_no_env_example(self, mocker, tmp_path):
130
+ """Test success when .env.example doesn't exist."""
113
131
  mocker.patch("sprout.commands.create.is_git_repository", return_value=True)
114
- mocker.patch("sprout.commands.create.get_git_root", return_value=Path("/project"))
132
+ mock_git_root = tmp_path / "project"
133
+ mock_git_root.mkdir()
134
+ mocker.patch("sprout.commands.create.get_git_root", return_value=mock_git_root)
135
+ mocker.patch("sprout.commands.create.worktree_exists", return_value=False)
136
+ sprout_dir = tmp_path / ".sprout"
137
+ sprout_dir.mkdir()
138
+ mocker.patch("sprout.commands.create.ensure_sprout_dir", return_value=sprout_dir)
139
+ mocker.patch("sprout.commands.create.branch_exists", return_value=False)
115
140
 
116
- mock_env_example = Mock()
117
- mock_env_example.exists.return_value = False
118
- mocker.patch("pathlib.Path.__truediv__", return_value=mock_env_example)
141
+ # Mock git ls-files to return empty list (no .env.example files)
142
+ mock_run = mocker.patch("sprout.commands.create.run_command")
143
+ mock_run.return_value = Mock(stdout="", returncode=0)
119
144
 
120
- result = runner.invoke(app, ["create", "feature-branch"])
145
+ # Change to project directory to make relative path calculation work
146
+ import os
121
147
 
122
- assert result.exit_code == 1
123
- assert ".env.example file not found" in result.stdout
148
+ old_cwd = os.getcwd()
149
+ os.chdir(str(mock_git_root))
150
+
151
+ try:
152
+ result = runner.invoke(app, ["create", "feature-branch"])
153
+
154
+ assert result.exit_code == 0
155
+ assert "Warning: No .env.example files found" in result.stdout
156
+ assert "Workspace 'feature-branch' created successfully!" in result.stdout
157
+ assert "No .env files generated (no .env.example templates found)" in result.stdout
158
+ finally:
159
+ os.chdir(old_cwd)
124
160
 
125
161
  def test_create_worktree_exists(self, mocker):
126
162
  """Test error when worktree already exists."""
127
163
  mocker.patch("sprout.commands.create.is_git_repository", return_value=True)
128
- mocker.patch("sprout.commands.create.get_git_root", return_value=Path("/project"))
164
+ mock_git_root = Path("/project")
165
+ mocker.patch("sprout.commands.create.get_git_root", return_value=mock_git_root)
129
166
  mocker.patch("sprout.commands.create.worktree_exists", return_value=True)
130
167
 
131
- mock_env_example = Mock()
132
- mock_env_example.exists.return_value = True
133
- mocker.patch("pathlib.Path.__truediv__", return_value=mock_env_example)
168
+ # Mock git ls-files to return .env.example
169
+ mock_run = mocker.patch("sprout.commands.create.run_command")
170
+ mock_run.return_value = Mock(stdout=".env.example\n", returncode=0)
171
+
172
+ # Mock Path.exists to return True for .env.example
173
+ mock_exists = mocker.patch("pathlib.Path.exists")
174
+ mock_exists.return_value = True
134
175
 
135
176
  result = runner.invoke(app, ["create", "feature-branch"])
136
177
 
137
178
  assert result.exit_code == 1
138
179
  assert "Worktree for branch 'feature-branch' already exists" in result.stdout
139
180
 
181
+ def test_create_without_env_example_path_mode(self, mocker, tmp_path):
182
+ """Test path mode with no .env.example files."""
183
+ mocker.patch("sprout.commands.create.is_git_repository", return_value=True)
184
+ mock_git_root = tmp_path / "project"
185
+ mock_git_root.mkdir()
186
+ mocker.patch("sprout.commands.create.get_git_root", return_value=mock_git_root)
187
+ mocker.patch("sprout.commands.create.worktree_exists", return_value=False)
188
+ sprout_dir = tmp_path / ".sprout"
189
+ sprout_dir.mkdir()
190
+ mocker.patch("sprout.commands.create.ensure_sprout_dir", return_value=sprout_dir)
191
+ mocker.patch("sprout.commands.create.branch_exists", return_value=False)
192
+
193
+ # Mock git ls-files to return empty list (no .env.example files)
194
+ mock_run = mocker.patch("sprout.commands.create.run_command")
195
+ mock_run.return_value = Mock(stdout="", returncode=0)
196
+
197
+ result = runner.invoke(app, ["create", "feature-branch", "--path"])
198
+
199
+ assert result.exit_code == 0
200
+ # In path mode, only the path should be printed to stdout
201
+ assert result.stdout.strip() == str(sprout_dir / "feature-branch")
202
+
140
203
 
141
204
  class TestLsCommand:
142
205
  """Test sprout ls command."""
@@ -57,6 +57,10 @@ def git_repo(tmp_path):
57
57
  "COMPOSE_VAR=${COMPOSE_VAR:-default}\n"
58
58
  )
59
59
 
60
+ # Add .env.example to git
61
+ subprocess.run(["git", "add", ".env.example"], cwd=tmp_path, check=True)
62
+ subprocess.run(["git", "commit", "-m", "Add .env.example"], cwd=tmp_path, check=True)
63
+
60
64
  return tmp_path, default_branch
61
65
 
62
66
 
@@ -84,6 +88,10 @@ class TestIntegrationWorkflow:
84
88
  "COMPOSE_VAR=${COMPOSE_VAR:-default}\n"
85
89
  )
86
90
 
91
+ # Add to git
92
+ subprocess.run(["git", "add", ".env.example"], cwd=git_repo, check=True)
93
+ subprocess.run(["git", "commit", "-m", "Add .env.example"], cwd=git_repo, check=True)
94
+
87
95
  # Create worktree - SECRET_TOKEN should be prompted but we can't test that
88
96
  # So let's set it too
89
97
  monkeypatch.setenv("SECRET_TOKEN", "secret123")
@@ -215,11 +223,12 @@ class TestIntegrationWorkflow:
215
223
  result = runner.invoke(app, ["path", "nonexistent"])
216
224
  assert result.exit_code == 1
217
225
 
218
- # Remove .env.example and try to create
226
+ # Remove .env.example and try to create (should succeed now)
219
227
  (git_repo / ".env.example").unlink()
220
228
  result = runner.invoke(app, ["create", "another-branch"])
221
- assert result.exit_code == 1
222
- assert ".env.example file not found" in result.stdout
229
+ assert result.exit_code == 0
230
+ assert "Warning: No .env.example files found" in result.stdout
231
+ assert "Workspace 'another-branch' created successfully!" in result.stdout
223
232
 
224
233
  # Test outside git repo using a separate temp directory
225
234
  import tempfile
@@ -0,0 +1,190 @@
1
+ """Tests for multiple .env.example files functionality."""
2
+
3
+ import subprocess
4
+
5
+ from typer.testing import CliRunner
6
+
7
+ from sprout.cli import app
8
+ from sprout.utils import get_used_ports
9
+
10
+ from .test_integration import git_repo # noqa: F401
11
+
12
+ runner = CliRunner()
13
+
14
+
15
+ class TestMultipleEnvExamples:
16
+ """Test handling of multiple .env.example files."""
17
+
18
+ def test_create_with_multiple_env_examples(self, git_repo, monkeypatch): # noqa: F811
19
+ """Test creating worktree with multiple .env.example files."""
20
+ git_repo, default_branch = git_repo
21
+ monkeypatch.chdir(git_repo)
22
+ monkeypatch.setenv("API_KEY", "test_key")
23
+ monkeypatch.setenv("DB_PASSWORD", "test_password")
24
+
25
+ # Create service directories with .env.example files
26
+ service_a = git_repo / "service-a"
27
+ service_a.mkdir()
28
+ (service_a / ".env.example").write_text("""# Service A Configuration
29
+ API_KEY={{ API_KEY }}
30
+ API_PORT={{ auto_port() }}
31
+ """)
32
+
33
+ service_b = git_repo / "service-b"
34
+ service_b.mkdir()
35
+ (service_b / ".env.example").write_text("""# Service B Configuration
36
+ DB_PASSWORD={{ DB_PASSWORD }}
37
+ DB_PORT={{ auto_port() }}
38
+ """)
39
+
40
+ # Add files to git
41
+ subprocess.run(["git", "add", "."], cwd=git_repo, check=True)
42
+ subprocess.run(["git", "commit", "-m", "Add services"], cwd=git_repo, check=True)
43
+
44
+ # Create worktree
45
+ result = runner.invoke(app, ["create", "feature-multi"])
46
+ assert result.exit_code == 0
47
+ assert "Generating .env files from 3 template(s)" in result.stdout
48
+
49
+ # Check that all .env files were created with correct structure
50
+ worktree_path = git_repo / ".sprout" / "feature-multi"
51
+ assert (worktree_path / ".env").exists()
52
+ assert (worktree_path / "service-a" / ".env").exists()
53
+ assert (worktree_path / "service-b" / ".env").exists()
54
+
55
+ # Check content
56
+ root_env = (worktree_path / ".env").read_text()
57
+ assert "API_KEY=test_key" in root_env
58
+
59
+ service_a_env = (worktree_path / "service-a" / ".env").read_text()
60
+ assert "API_KEY=test_key" in service_a_env
61
+ assert "API_PORT=" in service_a_env
62
+
63
+ service_b_env = (worktree_path / "service-b" / ".env").read_text()
64
+ assert "DB_PASSWORD=test_password" in service_b_env
65
+ assert "DB_PORT=" in service_b_env
66
+
67
+ def test_port_uniqueness_across_services(self, git_repo, monkeypatch): # noqa: F811
68
+ """Test that auto_port() generates unique ports across all services."""
69
+ git_repo, default_branch = git_repo
70
+ monkeypatch.chdir(git_repo)
71
+ monkeypatch.setenv("API_KEY", "test_key") # Set env var for root .env.example
72
+
73
+ # Create multiple services with port requirements
74
+ for i in range(3):
75
+ service_dir = git_repo / f"service-{i}"
76
+ service_dir.mkdir()
77
+ (service_dir / ".env.example").write_text(f"""# Service {i}
78
+ PORT1={{{{ auto_port() }}}}
79
+ PORT2={{{{ auto_port() }}}}
80
+ """)
81
+
82
+ # Add files to git
83
+ subprocess.run(["git", "add", "."], cwd=git_repo, check=True)
84
+ subprocess.run(["git", "commit", "-m", "Add services"], cwd=git_repo, check=True)
85
+
86
+ # Create worktree
87
+ result = runner.invoke(app, ["create", "test-ports"])
88
+ assert result.exit_code == 0
89
+
90
+ # Collect all ports
91
+ all_ports = set()
92
+ worktree_path = git_repo / ".sprout" / "test-ports"
93
+
94
+ for i in range(3):
95
+ env_content = (worktree_path / f"service-{i}" / ".env").read_text()
96
+ lines = env_content.strip().split("\n")
97
+ for line in lines:
98
+ if "=" in line and not line.startswith("#"):
99
+ port = int(line.split("=")[1])
100
+ assert port not in all_ports, f"Port {port} is duplicated"
101
+ all_ports.add(port)
102
+
103
+ # Should have 6 unique ports (2 per service × 3 services)
104
+ assert len(all_ports) == 6
105
+
106
+ def test_global_port_uniqueness_across_worktrees(self, git_repo, monkeypatch): # noqa: F811
107
+ """Test that ports are unique across different worktrees."""
108
+ git_repo, default_branch = git_repo
109
+ monkeypatch.chdir(git_repo)
110
+ monkeypatch.setenv("API_KEY", "test_key") # Set env var for root .env.example
111
+
112
+ # Create service with ports
113
+ service_dir = git_repo / "service"
114
+ service_dir.mkdir()
115
+ (service_dir / ".env.example").write_text("""
116
+ PORT1={{ auto_port() }}
117
+ PORT2={{ auto_port() }}
118
+ """)
119
+
120
+ # Add files to git
121
+ subprocess.run(["git", "add", "."], cwd=git_repo, check=True)
122
+ subprocess.run(["git", "commit", "-m", "Add service"], cwd=git_repo, check=True)
123
+
124
+ # Create first worktree
125
+ result = runner.invoke(app, ["create", "branch1"])
126
+ assert result.exit_code == 0
127
+
128
+ # Get ports from first worktree
129
+ env1 = (git_repo / ".sprout" / "branch1" / "service" / ".env").read_text()
130
+ ports1 = set()
131
+ for line in env1.strip().split("\n"):
132
+ if "=" in line and not line.startswith("#"):
133
+ ports1.add(int(line.split("=")[1]))
134
+
135
+ # Create second worktree
136
+ result = runner.invoke(app, ["create", "branch2"])
137
+ assert result.exit_code == 0
138
+
139
+ # Get ports from second worktree
140
+ env2 = (git_repo / ".sprout" / "branch2" / "service" / ".env").read_text()
141
+ ports2 = set()
142
+ for line in env2.strip().split("\n"):
143
+ if "=" in line and not line.startswith("#"):
144
+ ports2.add(int(line.split("=")[1]))
145
+
146
+ # Ensure no overlap
147
+ assert len(ports1.intersection(ports2)) == 0, "Ports should not overlap between worktrees"
148
+
149
+ def test_nested_directory_structure(self, git_repo, monkeypatch): # noqa: F811
150
+ """Test handling of nested directory structures."""
151
+ git_repo, default_branch = git_repo
152
+ monkeypatch.chdir(git_repo)
153
+ monkeypatch.setenv("API_KEY", "test_key") # Set env var for root .env.example
154
+
155
+ # Create nested structure
156
+ nested_path = git_repo / "services" / "backend" / "api"
157
+ nested_path.mkdir(parents=True)
158
+ (nested_path / ".env.example").write_text("NESTED_PORT={{ auto_port() }}")
159
+
160
+ # Add files to git
161
+ subprocess.run(["git", "add", "."], cwd=git_repo, check=True)
162
+ subprocess.run(["git", "commit", "-m", "Add nested service"], cwd=git_repo, check=True)
163
+
164
+ # Create worktree
165
+ result = runner.invoke(app, ["create", "nested-test"])
166
+ assert result.exit_code == 0
167
+
168
+ # Check nested .env was created
169
+ worktree_path = git_repo / ".sprout" / "nested-test"
170
+ nested_env_path = worktree_path / "services" / "backend" / "api" / ".env"
171
+ assert nested_env_path.exists()
172
+ assert "NESTED_PORT=" in nested_env_path.read_text()
173
+
174
+ def test_get_used_ports_recursive(self, tmp_path, monkeypatch):
175
+ """Test that get_used_ports now searches recursively."""
176
+ monkeypatch.setattr("sprout.utils.get_sprout_dir", lambda: tmp_path)
177
+
178
+ # Create nested structure with .env files
179
+ (tmp_path / "branch1").mkdir()
180
+ (tmp_path / "branch1" / ".env").write_text("PORT1=8080")
181
+
182
+ (tmp_path / "branch1" / "service-a").mkdir()
183
+ (tmp_path / "branch1" / "service-a" / ".env").write_text("PORT2=8081")
184
+
185
+ (tmp_path / "branch2" / "nested" / "deep").mkdir(parents=True)
186
+ (tmp_path / "branch2" / "nested" / "deep" / ".env").write_text("PORT3=8082")
187
+
188
+ # Test recursive port collection
189
+ ports = get_used_ports()
190
+ assert ports == {8080, 8081, 8082}
@@ -1,114 +0,0 @@
1
- """Implementation of the create command."""
2
-
3
- from pathlib import Path
4
- from typing import Never
5
-
6
- import typer
7
- from rich.console import Console
8
-
9
- from sprout.exceptions import SproutError
10
- from sprout.types import BranchName
11
- from sprout.utils import (
12
- branch_exists,
13
- ensure_sprout_dir,
14
- get_git_root,
15
- is_git_repository,
16
- parse_env_template,
17
- run_command,
18
- worktree_exists,
19
- )
20
-
21
- console = Console()
22
-
23
-
24
- def create_worktree(branch_name: BranchName, path_only: bool = False) -> Never:
25
- """Create a new worktree with development environment."""
26
- # Check prerequisites
27
- if not is_git_repository():
28
- if not path_only:
29
- console.print("[red]Error: Not in a git repository[/red]")
30
- console.print("Please run this command from the root of a git repository.")
31
- else:
32
- typer.echo("Error: Not in a git repository", err=True)
33
- raise typer.Exit(1)
34
-
35
- git_root = get_git_root()
36
- env_example = git_root / ".env.example"
37
-
38
- if not env_example.exists():
39
- if not path_only:
40
- console.print("[red]Error: .env.example file not found[/red]")
41
- console.print(f"Expected at: {env_example}")
42
- else:
43
- typer.echo(f"Error: .env.example file not found at {env_example}", err=True)
44
- raise typer.Exit(1)
45
-
46
- # Check if worktree already exists
47
- if worktree_exists(branch_name):
48
- if not path_only:
49
- console.print(f"[red]Error: Worktree for branch '{branch_name}' already exists[/red]")
50
- else:
51
- typer.echo(f"Error: Worktree for branch '{branch_name}' already exists", err=True)
52
- raise typer.Exit(1)
53
-
54
- # Ensure .sprout directory exists
55
- sprout_dir = ensure_sprout_dir()
56
- worktree_path = sprout_dir / branch_name
57
-
58
- # Create the worktree
59
- if not path_only:
60
- console.print(f"Creating worktree for branch [cyan]{branch_name}[/cyan]...")
61
-
62
- # Check if branch exists, create if it doesn't
63
- if not branch_exists(branch_name):
64
- if not path_only:
65
- console.print(f"Branch '{branch_name}' doesn't exist. Creating new branch...")
66
- # Create branch with -b flag
67
- cmd = ["git", "worktree", "add", "-b", branch_name, str(worktree_path)]
68
- else:
69
- cmd = ["git", "worktree", "add", str(worktree_path), branch_name]
70
-
71
- try:
72
- run_command(cmd)
73
- except SproutError as e:
74
- if not path_only:
75
- console.print(f"[red]Error creating worktree: {e}[/red]")
76
- else:
77
- typer.echo(f"Error creating worktree: {e}", err=True)
78
- raise typer.Exit(1) from e
79
-
80
- # Generate .env file
81
- if not path_only:
82
- console.print("Generating .env file...")
83
- try:
84
- env_content = parse_env_template(env_example, silent=path_only)
85
- env_file = worktree_path / ".env"
86
- env_file.write_text(env_content)
87
- except SproutError as e:
88
- if not path_only:
89
- console.print(f"[red]Error generating .env file: {e}[/red]")
90
- else:
91
- typer.echo(f"Error generating .env file: {e}", err=True)
92
- # Clean up worktree on failure
93
- run_command(["git", "worktree", "remove", str(worktree_path)], check=False)
94
- raise typer.Exit(1) from e
95
- except KeyboardInterrupt:
96
- if not path_only:
97
- console.print("\n[yellow]Cancelled by user[/yellow]")
98
- else:
99
- typer.echo("Cancelled by user", err=True)
100
- # Clean up worktree on cancellation
101
- run_command(["git", "worktree", "remove", str(worktree_path)], check=False)
102
- raise typer.Exit(130) from None
103
-
104
- # Success message or path output
105
- if path_only:
106
- # Output only the path for shell command substitution
107
- print(str(worktree_path))
108
- else:
109
- console.print(f"\n[green]✅ Workspace '{branch_name}' created successfully![/green]\n")
110
- console.print("Navigate to your new environment with:")
111
- console.print(f" [cyan]cd {worktree_path.relative_to(Path.cwd())}[/cyan]")
112
-
113
- # Exit successfully
114
- raise typer.Exit(0)
File without changes
File without changes
File without changes
File without changes
File without changes