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.
- {sprout_cli-0.3.0 → sprout_cli-0.4.0}/PKG-INFO +62 -3
- {sprout_cli-0.3.0 → sprout_cli-0.4.0}/README.md +61 -2
- {sprout_cli-0.3.0 → sprout_cli-0.4.0}/src/sprout/__init__.py +1 -1
- {sprout_cli-0.3.0 → sprout_cli-0.4.0}/src/sprout/commands/create.py +49 -10
- {sprout_cli-0.3.0 → sprout_cli-0.4.0}/src/sprout/utils.py +29 -7
- {sprout_cli-0.3.0 → sprout_cli-0.4.0}/tests/test_commands.py +33 -9
- {sprout_cli-0.3.0 → sprout_cli-0.4.0}/tests/test_integration.py +9 -1
- sprout_cli-0.4.0/tests/test_multi_env.py +190 -0
- {sprout_cli-0.3.0 → sprout_cli-0.4.0}/.gitignore +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.4.0}/LICENSE +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.4.0}/pyproject.toml +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.4.0}/src/sprout/__main__.py +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.4.0}/src/sprout/cli.py +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.4.0}/src/sprout/commands/__init__.py +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.4.0}/src/sprout/commands/ls.py +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.4.0}/src/sprout/commands/path.py +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.4.0}/src/sprout/commands/rm.py +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.4.0}/src/sprout/exceptions.py +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.4.0}/src/sprout/types.py +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.4.0}/tests/__init__.py +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.4.0}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sprout-cli
|
|
3
|
-
Version: 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
|
|
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
|
|
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,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
|
-
|
|
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
|
|
41
|
-
console.print(f"Expected at: {
|
|
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
|
|
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
|
|
90
|
+
# Generate .env files
|
|
81
91
|
if not path_only:
|
|
82
|
-
console.print("Generating .env
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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.
|
|
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(
|
|
129
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
+
mock_git_root = Path("/project")
|
|
133
|
+
mocker.patch("sprout.commands.create.get_git_root", return_value=mock_git_root)
|
|
115
134
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|