sprout-cli 0.3.0__tar.gz → 0.4.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.4.0
4
4
  Summary: CLI tool to automate git worktree and Docker Compose development workflows
5
5
  Author: SecDevLab Inc.
6
6
  License: MIT
@@ -56,7 +56,7 @@ pip install -e ".[dev]"
56
56
 
57
57
  ## Quick Start
58
58
 
59
- 1. Create a `.env.example` template in your project root:
59
+ 1. Create a `.env.example` template in your project root (and optionally in subdirectories):
60
60
  ```env
61
61
  # API Configuration
62
62
  API_KEY={{ API_KEY }}
@@ -71,6 +71,16 @@ DB_PORT={{ auto_port() }}
71
71
  # DB_NAME=${DB_NAME}
72
72
  ```
73
73
 
74
+ For monorepo or multi-service projects, you can create `.env.example` files in subdirectories:
75
+ ```
76
+ repo/
77
+ .env.example # Root configuration
78
+ service-a/
79
+ .env.example # Service A specific config
80
+ service-b/
81
+ .env.example # Service B specific config
82
+ ```
83
+
74
84
  2. Create and navigate to a new development environment in one command:
75
85
  ```bash
76
86
  cd $(sprout create feature-branch --path)
@@ -173,8 +183,9 @@ sprout supports two types of placeholders in `.env.example`:
173
183
 
174
184
  2. **Auto Port Assignment**: `{{ auto_port() }}`
175
185
  - Automatically assigns available ports
176
- - Avoids conflicts with other sprout environments
186
+ - Avoids conflicts across ALL services in ALL sprout environments
177
187
  - Checks system port availability
188
+ - Ensures global uniqueness even in monorepo setups
178
189
 
179
190
  3. **Docker Compose Syntax (Preserved)**: `${VARIABLE}`
180
191
  - NOT processed by sprout - passed through as-is
@@ -196,6 +207,54 @@ sprout create another-branch
196
207
  # → Enter a value for 'DATABASE_URL': [user input required]
197
208
  ```
198
209
 
210
+ ## Monorepo Tutorial
211
+
212
+ Try out the monorepo functionality with the included sample:
213
+
214
+ 1. **Navigate to the sample monorepo**:
215
+ ```bash
216
+ cd sample/monorepo
217
+ ```
218
+
219
+ 2. **Set required environment variables**:
220
+ ```bash
221
+ export API_KEY="your-api-key"
222
+ export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/myapp"
223
+ export REACT_APP_API_KEY="your-frontend-api-key"
224
+ export JWT_SECRET="your-jwt-secret"
225
+ export SMTP_USER="your-smtp-username"
226
+ export SMTP_PASS="your-smtp-password"
227
+ ```
228
+
229
+ 3. **Create a development environment**:
230
+ ```bash
231
+ sprout create monorepo-feature
232
+ ```
233
+
234
+ 4. **Navigate to the created environment**:
235
+ ```bash
236
+ cd .sprout/monorepo-feature
237
+ ```
238
+
239
+ 5. **Verify all services have unique ports**:
240
+ ```bash
241
+ find . -name "*.env" -exec echo "=== {} ===" \; -exec cat {} \;
242
+ ```
243
+
244
+ 6. **Start all services**:
245
+ ```bash
246
+ cd sample/monorepo
247
+ docker-compose up -d
248
+ ```
249
+
250
+ The sample includes:
251
+ - **Root service**: Database and Redis with shared configuration
252
+ - **Frontend**: React app with API integration
253
+ - **Backend**: REST API with authentication
254
+ - **Shared**: Utilities with message queue and monitoring
255
+
256
+ Each service gets unique, conflict-free ports automatically!
257
+
199
258
  ## Documentation
200
259
 
201
260
  - [Architecture Overview](docs/sprout-cli/overview.md) - Design philosophy, architecture, and implementation details
@@ -28,7 +28,7 @@ pip install -e ".[dev]"
28
28
 
29
29
  ## Quick Start
30
30
 
31
- 1. Create a `.env.example` template in your project root:
31
+ 1. Create a `.env.example` template in your project root (and optionally in subdirectories):
32
32
  ```env
33
33
  # API Configuration
34
34
  API_KEY={{ API_KEY }}
@@ -43,6 +43,16 @@ DB_PORT={{ auto_port() }}
43
43
  # DB_NAME=${DB_NAME}
44
44
  ```
45
45
 
46
+ For monorepo or multi-service projects, you can create `.env.example` files in subdirectories:
47
+ ```
48
+ repo/
49
+ .env.example # Root configuration
50
+ service-a/
51
+ .env.example # Service A specific config
52
+ service-b/
53
+ .env.example # Service B specific config
54
+ ```
55
+
46
56
  2. Create and navigate to a new development environment in one command:
47
57
  ```bash
48
58
  cd $(sprout create feature-branch --path)
@@ -145,8 +155,9 @@ sprout supports two types of placeholders in `.env.example`:
145
155
 
146
156
  2. **Auto Port Assignment**: `{{ auto_port() }}`
147
157
  - Automatically assigns available ports
148
- - Avoids conflicts with other sprout environments
158
+ - Avoids conflicts across ALL services in ALL sprout environments
149
159
  - Checks system port availability
160
+ - Ensures global uniqueness even in monorepo setups
150
161
 
151
162
  3. **Docker Compose Syntax (Preserved)**: `${VARIABLE}`
152
163
  - NOT processed by sprout - passed through as-is
@@ -168,6 +179,54 @@ sprout create another-branch
168
179
  # → Enter a value for 'DATABASE_URL': [user input required]
169
180
  ```
170
181
 
182
+ ## Monorepo Tutorial
183
+
184
+ Try out the monorepo functionality with the included sample:
185
+
186
+ 1. **Navigate to the sample monorepo**:
187
+ ```bash
188
+ cd sample/monorepo
189
+ ```
190
+
191
+ 2. **Set required environment variables**:
192
+ ```bash
193
+ export API_KEY="your-api-key"
194
+ export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/myapp"
195
+ export REACT_APP_API_KEY="your-frontend-api-key"
196
+ export JWT_SECRET="your-jwt-secret"
197
+ export SMTP_USER="your-smtp-username"
198
+ export SMTP_PASS="your-smtp-password"
199
+ ```
200
+
201
+ 3. **Create a development environment**:
202
+ ```bash
203
+ sprout create monorepo-feature
204
+ ```
205
+
206
+ 4. **Navigate to the created environment**:
207
+ ```bash
208
+ cd .sprout/monorepo-feature
209
+ ```
210
+
211
+ 5. **Verify all services have unique ports**:
212
+ ```bash
213
+ find . -name "*.env" -exec echo "=== {} ===" \; -exec cat {} \;
214
+ ```
215
+
216
+ 6. **Start all services**:
217
+ ```bash
218
+ cd sample/monorepo
219
+ docker-compose up -d
220
+ ```
221
+
222
+ The sample includes:
223
+ - **Root service**: Database and Redis with shared configuration
224
+ - **Frontend**: React app with API integration
225
+ - **Backend**: REST API with authentication
226
+ - **Shared**: Utilities with message queue and monitoring
227
+
228
+ Each service gets unique, conflict-free ports automatically!
229
+
171
230
  ## Documentation
172
231
 
173
232
  - [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.4.0"
@@ -1,5 +1,6 @@
1
1
  """Implementation of the create command."""
2
2
 
3
+ import re
3
4
  from pathlib import Path
4
5
  from typing import Never
5
6
 
@@ -12,6 +13,7 @@ from sprout.utils import (
12
13
  branch_exists,
13
14
  ensure_sprout_dir,
14
15
  get_git_root,
16
+ get_used_ports,
15
17
  is_git_repository,
16
18
  parse_env_template,
17
19
  run_command,
@@ -33,14 +35,22 @@ def create_worktree(branch_name: BranchName, path_only: bool = False) -> Never:
33
35
  raise typer.Exit(1)
34
36
 
35
37
  git_root = get_git_root()
36
- env_example = git_root / ".env.example"
37
38
 
38
- if not env_example.exists():
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:
39
49
  if not path_only:
40
- console.print("[red]Error: .env.example file not found[/red]")
41
- console.print(f"Expected at: {env_example}")
50
+ console.print("[red]Error: No .env.example files found[/red]")
51
+ console.print(f"Expected at least one .env.example file in: {git_root}")
42
52
  else:
43
- typer.echo(f"Error: .env.example file not found at {env_example}", err=True)
53
+ typer.echo(f"Error: No .env.example files found in {git_root}", err=True)
44
54
  raise typer.Exit(1)
45
55
 
46
56
  # Check if worktree already exists
@@ -77,13 +87,42 @@ def create_worktree(branch_name: BranchName, path_only: bool = False) -> Never:
77
87
  typer.echo(f"Error creating worktree: {e}", err=True)
78
88
  raise typer.Exit(1) from e
79
89
 
80
- # Generate .env file
90
+ # Generate .env files
81
91
  if not path_only:
82
- console.print("Generating .env file...")
92
+ console.print(f"Generating .env files from {len(env_examples)} template(s)...")
93
+
94
+ # Get all currently used ports to avoid conflicts
95
+ all_used_ports = get_used_ports()
96
+ session_ports: set[int] = set()
97
+
83
98
  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)
99
+ for env_example in env_examples:
100
+ # Calculate relative path from git root
101
+ relative_dir = env_example.parent.relative_to(git_root)
102
+
103
+ # Create target directory in worktree if needed
104
+ if relative_dir != Path("."):
105
+ target_dir = worktree_path / relative_dir
106
+ target_dir.mkdir(parents=True, exist_ok=True)
107
+ env_file = target_dir / ".env"
108
+ else:
109
+ env_file = worktree_path / ".env"
110
+
111
+ # Parse template with combined used ports
112
+ env_content = parse_env_template(
113
+ env_example, silent=path_only, used_ports=all_used_ports | session_ports
114
+ )
115
+
116
+ # Extract ports from generated content and add to session_ports
117
+ port_matches = re.findall(r"=(\d{4,5})\b", env_content)
118
+ for port_str in port_matches:
119
+ port = int(port_str)
120
+ if 1024 <= port <= 65535:
121
+ session_ports.add(port)
122
+
123
+ # Write the .env file
124
+ env_file.write_text(env_content)
125
+
87
126
  except SproutError as e:
88
127
  if not path_only:
89
128
  console.print(f"[red]Error generating .env file: {e}[/red]")
@@ -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"])
@@ -111,26 +129,32 @@ class TestCreateCommand:
111
129
  def test_create_no_env_example(self, mocker):
112
130
  """Test error 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 = Path("/project")
133
+ mocker.patch("sprout.commands.create.get_git_root", return_value=mock_git_root)
115
134
 
116
- mock_env_example = Mock()
117
- mock_env_example.exists.return_value = False
118
- mocker.patch("pathlib.Path.__truediv__", return_value=mock_env_example)
135
+ # Mock git ls-files to return empty list
136
+ mock_run = mocker.patch("sprout.commands.create.run_command")
137
+ mock_run.return_value = Mock(stdout="", returncode=0)
119
138
 
120
139
  result = runner.invoke(app, ["create", "feature-branch"])
121
140
 
122
141
  assert result.exit_code == 1
123
- assert ".env.example file not found" in result.stdout
142
+ assert "No .env.example files found" in result.stdout
124
143
 
125
144
  def test_create_worktree_exists(self, mocker):
126
145
  """Test error when worktree already exists."""
127
146
  mocker.patch("sprout.commands.create.is_git_repository", return_value=True)
128
- mocker.patch("sprout.commands.create.get_git_root", return_value=Path("/project"))
147
+ mock_git_root = Path("/project")
148
+ mocker.patch("sprout.commands.create.get_git_root", return_value=mock_git_root)
129
149
  mocker.patch("sprout.commands.create.worktree_exists", return_value=True)
130
150
 
131
- mock_env_example = Mock()
132
- mock_env_example.exists.return_value = True
133
- mocker.patch("pathlib.Path.__truediv__", return_value=mock_env_example)
151
+ # Mock git ls-files to return .env.example
152
+ mock_run = mocker.patch("sprout.commands.create.run_command")
153
+ mock_run.return_value = Mock(stdout=".env.example\n", returncode=0)
154
+
155
+ # Mock Path.exists to return True for .env.example
156
+ mock_exists = mocker.patch("pathlib.Path.exists")
157
+ mock_exists.return_value = True
134
158
 
135
159
  result = runner.invoke(app, ["create", "feature-branch"])
136
160
 
@@ -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")
@@ -219,7 +227,7 @@ class TestIntegrationWorkflow:
219
227
  (git_repo / ".env.example").unlink()
220
228
  result = runner.invoke(app, ["create", "another-branch"])
221
229
  assert result.exit_code == 1
222
- assert ".env.example file not found" in result.stdout
230
+ assert "No .env.example files found" in result.stdout
223
231
 
224
232
  # Test outside git repo using a separate temp directory
225
233
  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}
File without changes
File without changes
File without changes
File without changes
File without changes