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.
- {sprout_cli-0.3.0 → sprout_cli-0.5.0}/PKG-INFO +70 -5
- {sprout_cli-0.3.0 → sprout_cli-0.5.0}/README.md +69 -4
- {sprout_cli-0.3.0 → sprout_cli-0.5.0}/src/sprout/__init__.py +1 -1
- sprout_cli-0.5.0/src/sprout/commands/create.py +161 -0
- {sprout_cli-0.3.0 → sprout_cli-0.5.0}/src/sprout/utils.py +29 -7
- {sprout_cli-0.3.0 → sprout_cli-0.5.0}/tests/test_commands.py +76 -13
- {sprout_cli-0.3.0 → sprout_cli-0.5.0}/tests/test_integration.py +12 -3
- sprout_cli-0.5.0/tests/test_multi_env.py +190 -0
- sprout_cli-0.3.0/src/sprout/commands/create.py +0 -114
- {sprout_cli-0.3.0 → sprout_cli-0.5.0}/.gitignore +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.5.0}/LICENSE +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.5.0}/pyproject.toml +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.5.0}/src/sprout/__main__.py +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.5.0}/src/sprout/cli.py +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.5.0}/src/sprout/commands/__init__.py +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.5.0}/src/sprout/commands/ls.py +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.5.0}/src/sprout/commands/path.py +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.5.0}/src/sprout/commands/rm.py +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.5.0}/src/sprout/exceptions.py +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.5.0}/src/sprout/types.py +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.5.0}/tests/__init__.py +0 -0
- {sprout_cli-0.3.0 → sprout_cli-0.5.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.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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
@@ -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.
|
|
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"])
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
145
|
+
# Change to project directory to make relative path calculation work
|
|
146
|
+
import os
|
|
121
147
|
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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 ==
|
|
222
|
-
assert ".env.example
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|