Skip to content

Commit 356455a

Browse files
authored
Merge pull request #7 from SecDev-Lab/no-env-example
feat: Enable sprout to work without .env.example files
2 parents 110b2c5 + 52d08f2 commit 356455a

File tree

7 files changed

+144
-77
lines changed

7 files changed

+144
-77
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- Support for repositories without `.env.example` files - `sprout create` now works in any git repository
1112

1213
### Changed
14+
- `sprout create` behavior when no `.env.example` files exist: shows warning instead of error and continues creating worktree
1315

1416
### Deprecated
1517

@@ -24,11 +26,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2426
### Added
2527
- Support for multiple `.env.example` files throughout the repository, enabling monorepo workflows
2628
- Recursive scanning of `.env` files for port allocation to ensure global uniqueness across all services
29+
- Support for repositories without `.env.example` files - `sprout create` now works in any git repository
2730

2831
### Changed
2932
- Port allocation now ensures uniqueness across all services in all worktrees, preventing Docker host port conflicts
3033
- `sprout create` now processes all `.env.example` files found in the repository while maintaining directory structure
3134
- Only git-tracked `.env.example` files are now processed, preventing unwanted processing of files in `.sprout/` worktrees
35+
- `sprout create` behavior when no `.env.example` files exist: shows warning instead of error and continues creating worktree
3236

3337
### Deprecated
3438

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ pip install -e ".[dev]"
2828

2929
## Quick Start
3030

31-
1. Create a `.env.example` template in your project root (and optionally in subdirectories):
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:
3234
```env
3335
# API Configuration
3436
API_KEY={{ API_KEY }}
@@ -58,9 +60,13 @@ repo/
5860
cd $(sprout create feature-branch --path)
5961
```
6062

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+
6167
This single command:
6268
- Creates a new git worktree for `feature-branch`
63-
- Generates a `.env` file from your template
69+
- Generates `.env` files from your templates (if `.env.example` files exist)
6470
- Outputs the path to the new environment
6571
- Changes to that directory when wrapped in `cd $(...)`
6672

docs/sprout-cli/overview.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88

99
### 1. Automated Development Environment Setup
1010
- Create git worktrees
11-
- Automatically generate `.env` files from `.env.example` templates
11+
- Automatically generate `.env` files from `.env.example` templates (when templates exist)
1212
- Automatic port number assignment (collision avoidance)
1313
- Interactive environment variable configuration
14+
- Works in any git repository, with or without `.env.example` files
1415

1516
### 2. Unified Management
1617
- Centralize all worktrees in `.sprout/` directory
@@ -42,9 +43,9 @@
4243
├── .git/
4344
├── .sprout/ # sprout management directory
4445
│ └── <branch-name>/ # each worktree
45-
│ ├── .env # auto-generated environment config
46+
│ ├── .env # auto-generated environment config (if .env.example exists)
4647
│ └── ... # source code
47-
├── .env.example # template
48+
├── .env.example # template (optional)
4849
└── compose.yaml # Docker Compose config
4950
```
5051

docs/sprout-cli/usage.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@ sprout create feature-branch
2222

2323
This command performs:
2424
1. Creates worktree in `.sprout/feature-branch`
25-
2. Generates `.env` from `.env.example` template
26-
3. Prompts for required environment variables
27-
4. Automatically assigns port numbers
25+
2. Generates `.env` from `.env.example` template (if template exists)
26+
3. Prompts for required environment variables (if `.env.example` exists)
27+
4. Automatically assigns port numbers (if `.env.example` exists)
28+
29+
**Note**: If no `.env.example` files are found, sprout will show a warning but continue creating the worktree without `.env` generation.
2830

2931
### 2. List Development Environments
3032

@@ -177,8 +179,14 @@ REDIS_PORT={{ auto_port() }} # Might assign 3003
177179
- Execute from Git repository root directory
178180
- Ensure `.git` directory exists
179181

180-
### ".env.example file not found" Error
181-
- Create `.env.example` in project root
182+
### Working Without .env.example Files
183+
As of recent versions, sprout works perfectly fine without `.env.example` files:
184+
- If no `.env.example` files exist, sprout will show a warning but continue
185+
- The worktree will be created successfully without `.env` generation
186+
- This is useful for projects that don't need environment variable templating
187+
188+
If you want to add `.env` generation later:
189+
- Create `.env.example` in project root or subdirectories
182190
- Use the template syntax described above
183191

184192
### "Could not find an available port" Error

src/sprout/commands/create.py

Lines changed: 65 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,9 @@ def create_worktree(branch_name: BranchName, path_only: bool = False) -> Never:
4747

4848
if not env_examples:
4949
if not path_only:
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}")
52-
else:
53-
typer.echo(f"Error: No .env.example files found in {git_root}", err=True)
54-
raise typer.Exit(1)
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
5553

5654
# Check if worktree already exists
5755
if worktree_exists(branch_name):
@@ -87,67 +85,77 @@ def create_worktree(branch_name: BranchName, path_only: bool = False) -> Never:
8785
typer.echo(f"Error creating worktree: {e}", err=True)
8886
raise typer.Exit(1) from e
8987

90-
# Generate .env files
91-
if not path_only:
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-
98-
try:
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-
126-
except SproutError as e:
127-
if not path_only:
128-
console.print(f"[red]Error generating .env file: {e}[/red]")
129-
else:
130-
typer.echo(f"Error generating .env file: {e}", err=True)
131-
# Clean up worktree on failure
132-
run_command(["git", "worktree", "remove", str(worktree_path)], check=False)
133-
raise typer.Exit(1) from e
134-
except KeyboardInterrupt:
88+
# Generate .env files only if .env.example files exist
89+
if env_examples:
13590
if not path_only:
136-
console.print("\n[yellow]Cancelled by user[/yellow]")
137-
else:
138-
typer.echo("Cancelled by user", err=True)
139-
# Clean up worktree on cancellation
140-
run_command(["git", "worktree", "remove", str(worktree_path)], check=False)
141-
raise typer.Exit(130) from None
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
142141

143142
# Success message or path output
144143
if path_only:
145144
# Output only the path for shell command substitution
146145
print(str(worktree_path))
147146
else:
148147
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)")
149152
console.print("Navigate to your new environment with:")
150-
console.print(f" [cyan]cd {worktree_path.relative_to(Path.cwd())}[/cyan]")
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]")
151159

152160
# Exit successfully
153161
raise typer.Exit(0)

tests/test_commands.py

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,20 +126,37 @@ def test_create_with_path_flag_error(self, mocker):
126126
# stdout should be empty
127127
assert result.stdout == ""
128128

129-
def test_create_no_env_example(self, mocker):
130-
"""Test error when .env.example doesn't exist."""
129+
def test_create_no_env_example(self, mocker, tmp_path):
130+
"""Test success when .env.example doesn't exist."""
131131
mocker.patch("sprout.commands.create.is_git_repository", return_value=True)
132-
mock_git_root = Path("/project")
132+
mock_git_root = tmp_path / "project"
133+
mock_git_root.mkdir()
133134
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)
134140

135-
# Mock git ls-files to return empty list
141+
# Mock git ls-files to return empty list (no .env.example files)
136142
mock_run = mocker.patch("sprout.commands.create.run_command")
137143
mock_run.return_value = Mock(stdout="", returncode=0)
138144

139-
result = runner.invoke(app, ["create", "feature-branch"])
145+
# Change to project directory to make relative path calculation work
146+
import os
140147

141-
assert result.exit_code == 1
142-
assert "No .env.example files found" in result.stdout
148+
old_cwd = os.getcwd()
149+
os.chdir(str(mock_git_root))
150+
151+
try:
152+
result = runner.invoke(app, ["create", "feature-branch"])
153+
154+
assert result.exit_code == 0
155+
assert "Warning: No .env.example files found" in result.stdout
156+
assert "Workspace 'feature-branch' created successfully!" in result.stdout
157+
assert "No .env files generated (no .env.example templates found)" in result.stdout
158+
finally:
159+
os.chdir(old_cwd)
143160

144161
def test_create_worktree_exists(self, mocker):
145162
"""Test error when worktree already exists."""
@@ -161,6 +178,28 @@ def test_create_worktree_exists(self, mocker):
161178
assert result.exit_code == 1
162179
assert "Worktree for branch 'feature-branch' already exists" in result.stdout
163180

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+
164203

165204
class TestLsCommand:
166205
"""Test sprout ls command."""

tests/test_integration.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,11 +223,12 @@ def test_error_cases(self, git_repo, monkeypatch, tmp_path):
223223
result = runner.invoke(app, ["path", "nonexistent"])
224224
assert result.exit_code == 1
225225

226-
# Remove .env.example and try to create
226+
# Remove .env.example and try to create (should succeed now)
227227
(git_repo / ".env.example").unlink()
228228
result = runner.invoke(app, ["create", "another-branch"])
229-
assert result.exit_code == 1
230-
assert "No .env.example files found" in result.stdout
229+
assert result.exit_code == 0
230+
assert "Warning: No .env.example files found" in result.stdout
231+
assert "Workspace 'another-branch' created successfully!" in result.stdout
231232

232233
# Test outside git repo using a separate temp directory
233234
import tempfile

0 commit comments

Comments
 (0)