sprout-cli 0.5.0__tar.gz → 0.7.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.5.0
3
+ Version: 0.7.0
4
4
  Summary: CLI tool to automate git worktree and Docker Compose development workflows
5
5
  Author: SecDevLab Inc.
6
6
  License: MIT
@@ -68,6 +68,10 @@ API_PORT={{ auto_port() }}
68
68
  DB_HOST=localhost
69
69
  DB_PORT={{ auto_port() }}
70
70
 
71
+ # Branch-specific Configuration
72
+ SERVICE_NAME=myapp-{{ branch() }}
73
+ DEPLOYMENT_ENV={{ branch() }}
74
+
71
75
  # Example: Docker Compose variables (preserved as-is)
72
76
  # sprout will NOT process ${...} syntax - it's passed through unchanged
73
77
  # DB_NAME=${DB_NAME}
@@ -180,7 +184,7 @@ Show the version of sprout.
180
184
 
181
185
  ## Template Syntax
182
186
 
183
- sprout supports two types of placeholders in `.env.example`:
187
+ sprout supports three types of placeholders in `.env.example`:
184
188
 
185
189
  1. **Variable Placeholders**: `{{ VARIABLE_NAME }}`
186
190
  - **First**: Checks if the variable exists in your environment (e.g., `export API_KEY=xxx`)
@@ -193,7 +197,12 @@ sprout supports two types of placeholders in `.env.example`:
193
197
  - Checks system port availability
194
198
  - Ensures global uniqueness even in monorepo setups
195
199
 
196
- 3. **Docker Compose Syntax (Preserved)**: `${VARIABLE}`
200
+ 3. **Branch Name**: `{{ branch() }}`
201
+ - Replaced with the current branch/subtree name
202
+ - Useful for branch-specific configurations
203
+ - Example: `SERVICE_NAME=myapp-{{ branch() }}` becomes `SERVICE_NAME=myapp-feature-auth`
204
+
205
+ 4. **Docker Compose Syntax (Preserved)**: `${VARIABLE}`
197
206
  - NOT processed by sprout - passed through as-is
198
207
  - Useful for Docker Compose variable substitution
199
208
  - Example: `${DB_NAME:-default}` remains unchanged in generated `.env`
@@ -40,6 +40,10 @@ API_PORT={{ auto_port() }}
40
40
  DB_HOST=localhost
41
41
  DB_PORT={{ auto_port() }}
42
42
 
43
+ # Branch-specific Configuration
44
+ SERVICE_NAME=myapp-{{ branch() }}
45
+ DEPLOYMENT_ENV={{ branch() }}
46
+
43
47
  # Example: Docker Compose variables (preserved as-is)
44
48
  # sprout will NOT process ${...} syntax - it's passed through unchanged
45
49
  # DB_NAME=${DB_NAME}
@@ -152,7 +156,7 @@ Show the version of sprout.
152
156
 
153
157
  ## Template Syntax
154
158
 
155
- sprout supports two types of placeholders in `.env.example`:
159
+ sprout supports three types of placeholders in `.env.example`:
156
160
 
157
161
  1. **Variable Placeholders**: `{{ VARIABLE_NAME }}`
158
162
  - **First**: Checks if the variable exists in your environment (e.g., `export API_KEY=xxx`)
@@ -165,7 +169,12 @@ sprout supports two types of placeholders in `.env.example`:
165
169
  - Checks system port availability
166
170
  - Ensures global uniqueness even in monorepo setups
167
171
 
168
- 3. **Docker Compose Syntax (Preserved)**: `${VARIABLE}`
172
+ 3. **Branch Name**: `{{ branch() }}`
173
+ - Replaced with the current branch/subtree name
174
+ - Useful for branch-specific configurations
175
+ - Example: `SERVICE_NAME=myapp-{{ branch() }}` becomes `SERVICE_NAME=myapp-feature-auth`
176
+
177
+ 4. **Docker Compose Syntax (Preserved)**: `${VARIABLE}`
169
178
  - NOT processed by sprout - passed through as-is
170
179
  - Useful for Docker Compose variable substitution
171
180
  - Example: `${DB_NAME:-default}` remains unchanged in generated `.env`
@@ -1,3 +1,3 @@
1
1
  """sprout - CLI tool to automate git worktree and Docker Compose development workflows."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.7.0"
@@ -109,7 +109,10 @@ def create_worktree(branch_name: BranchName, path_only: bool = False) -> Never:
109
109
 
110
110
  # Parse template with combined used ports
111
111
  env_content = parse_env_template(
112
- env_example, silent=path_only, used_ports=all_used_ports | session_ports
112
+ env_example,
113
+ silent=path_only,
114
+ used_ports=all_used_ports | session_ports,
115
+ branch_name=branch_name,
113
116
  )
114
117
 
115
118
  # Extract ports from generated content and add to session_ports
@@ -126,7 +126,10 @@ def find_available_port() -> PortNumber:
126
126
 
127
127
 
128
128
  def parse_env_template(
129
- template_path: Path, silent: bool = False, used_ports: PortSet | None = None
129
+ template_path: Path,
130
+ silent: bool = False,
131
+ used_ports: PortSet | None = None,
132
+ branch_name: str | None = None,
130
133
  ) -> str:
131
134
  """Parse .env.example template and process placeholders.
132
135
 
@@ -134,6 +137,7 @@ def parse_env_template(
134
137
  template_path: Path to the .env.example template file
135
138
  silent: If True, use stderr for prompts to keep stdout clean
136
139
  used_ports: Set of ports already in use (in addition to system-wide used ports)
140
+ branch_name: Branch name to use for {{ branch() }} placeholders
137
141
  """
138
142
  if not template_path.exists():
139
143
  raise SproutError(f".env.example file not found at {template_path}")
@@ -151,22 +155,56 @@ def parse_env_template(
151
155
  file_ports.update(used_ports)
152
156
 
153
157
  for line in content.splitlines():
154
- # Process {{ auto_port() }} placeholders
158
+ # Process {{ auto_port() | default }} placeholders
155
159
  def replace_auto_port(match: re.Match[str]) -> str:
160
+ # Extract default value if present (group 1)
161
+ default_value = match.group(1)
162
+ if default_value is not None:
163
+ default_value = default_value.strip()
164
+
165
+ # Generate available port
156
166
  port = find_available_port()
157
167
  while port in file_ports:
158
168
  port = find_available_port()
159
169
  file_ports.add(port)
160
170
  return str(port)
161
171
 
162
- line = re.sub(r"{{\s*auto_port\(\)\s*}}", replace_auto_port, line)
172
+ line = re.sub(r"{{\s*auto_port\(\)(?:\s*\|\s*([^}]*))?\s*}}", replace_auto_port, line)
173
+
174
+ # Process {{ branch() | default }} placeholders
175
+ def replace_branch(match: re.Match[str]) -> str:
176
+ # Extract default value if present (group 1)
177
+ default_value = match.group(1)
178
+ if default_value is not None:
179
+ default_value = default_value.strip()
180
+
181
+ # Use branch_name if provided, otherwise use default
182
+ if branch_name:
183
+ return branch_name
184
+ elif default_value is not None:
185
+ return default_value
186
+ else:
187
+ # No branch name and no default - keep placeholder unchanged
188
+ return match.group(0)
163
189
 
164
- # Process {{ VARIABLE }} placeholders
190
+ line = re.sub(r"{{\s*branch\(\)(?:\s*\|\s*([^}]*))?\s*}}", replace_branch, line)
191
+
192
+ # Process {{ VARIABLE | default }} placeholders
165
193
  def replace_variable(match: re.Match[str]) -> str:
166
194
  var_name = match.group(1).strip()
195
+ # Extract default value if present (group 2)
196
+ default_value = match.group(2)
197
+ if default_value is not None:
198
+ default_value = default_value.strip()
199
+
167
200
  # Check environment variable first
168
201
  value = os.environ.get(var_name)
169
202
  if value is None:
203
+ # If default value is provided, use it
204
+ if default_value is not None:
205
+ return default_value
206
+
207
+ # No default value - prompt user for value
170
208
  # Create a relative path for display
171
209
  try:
172
210
  display_path = template_path.relative_to(Path.cwd())
@@ -187,7 +225,9 @@ def parse_env_template(
187
225
  value = console.input(prompt)
188
226
  return value
189
227
 
190
- line = re.sub(r"{{\s*([^}]+)\s*}}", replace_variable, line)
228
+ # Only match variables that don't look like function calls (no parentheses)
229
+ # Now also captures optional | default_value
230
+ line = re.sub(r"{{\s*([^}()]+?)(?:\s*\|\s*([^}]*))?\s*}}", replace_variable, line)
191
231
 
192
232
  lines.append(line)
193
233
 
@@ -198,6 +198,102 @@ class TestIntegrationWorkflow:
198
198
  worktree_path = git_repo / ".sprout" / "existing-branch"
199
199
  assert worktree_path.exists()
200
200
 
201
+ def test_branch_placeholder(self, git_repo, monkeypatch):
202
+ """Test that {{ branch() }} placeholder is replaced with branch name."""
203
+ git_repo, default_branch = git_repo
204
+ monkeypatch.chdir(git_repo)
205
+ monkeypatch.setenv("API_KEY", "test_key")
206
+
207
+ # Create .env.example with branch() placeholder
208
+ env_example = git_repo / ".env.example"
209
+ env_example.write_text(
210
+ "# Branch-specific configuration\n"
211
+ "BRANCH_NAME={{ branch() }}\n"
212
+ "SERVICE_NAME=myapp-{{ branch() }}\n"
213
+ "API_KEY={{ API_KEY }}\n"
214
+ "PORT={{ auto_port() }}\n"
215
+ "COMPOSE_VAR=${BRANCH_NAME}\n"
216
+ )
217
+
218
+ # Add to git
219
+ subprocess.run(["git", "add", ".env.example"], cwd=git_repo, check=True)
220
+ subprocess.run(
221
+ ["git", "commit", "-m", "Add .env.example with branch placeholder"],
222
+ cwd=git_repo,
223
+ check=True,
224
+ )
225
+
226
+ # Create worktree
227
+ branch_name = "feature-auth"
228
+ result = runner.invoke(app, ["create", branch_name])
229
+ assert result.exit_code == 0
230
+
231
+ # Verify .env was created with correct branch name substitution
232
+ env_file = git_repo / ".sprout" / branch_name / ".env"
233
+ env_content = env_file.read_text()
234
+
235
+ # Check branch name substitutions
236
+ assert f"BRANCH_NAME={branch_name}" in env_content
237
+ assert f"SERVICE_NAME=myapp-{branch_name}" in env_content
238
+ assert "API_KEY=test_key" in env_content
239
+ assert "PORT=" in env_content # Should have a port number
240
+ assert "COMPOSE_VAR=${BRANCH_NAME}" in env_content # Docker syntax preserved
241
+
242
+ def test_default_values(self, git_repo, monkeypatch):
243
+ """Test that default values work correctly in .env.example templates."""
244
+ git_repo, default_branch = git_repo
245
+ monkeypatch.chdir(git_repo)
246
+
247
+ # Set only some environment variables
248
+ monkeypatch.setenv("SET_VAR", "from_environment")
249
+ # UNSET_VAR is not set - should use default
250
+
251
+ # Create .env.example with default values
252
+ env_example = git_repo / ".env.example"
253
+ env_example.write_text(
254
+ "# Test default values\n"
255
+ "SET_VAR={{ SET_VAR | default_value }}\n"
256
+ "UNSET_VAR={{ UNSET_VAR | localhost }}\n"
257
+ "EMPTY_VAR={{ EMPTY_VAR | }}\n"
258
+ "PORT={{ auto_port() | 8080 }}\n"
259
+ "BRANCH={{ branch() | develop }}\n"
260
+ "NO_DEFAULT={{ NO_DEFAULT }}\n" # Will be prompted
261
+ )
262
+
263
+ # Add to git
264
+ subprocess.run(["git", "add", ".env.example"], cwd=git_repo, check=True)
265
+ subprocess.run(
266
+ ["git", "commit", "-m", "Add .env.example with defaults"],
267
+ cwd=git_repo,
268
+ check=True,
269
+ )
270
+
271
+ # Set NO_DEFAULT to avoid prompt
272
+ monkeypatch.setenv("NO_DEFAULT", "prompted_value")
273
+
274
+ # Create worktree
275
+ branch_name = "feature-test"
276
+ result = runner.invoke(app, ["create", branch_name])
277
+ assert result.exit_code == 0
278
+
279
+ # Verify .env was created with correct values
280
+ env_file = git_repo / ".sprout" / branch_name / ".env"
281
+ env_content = env_file.read_text()
282
+
283
+ # Check that environment variable overrides default
284
+ assert "SET_VAR=from_environment" in env_content
285
+ # Check that default is used when variable is not set
286
+ assert "UNSET_VAR=localhost" in env_content
287
+ # Check empty string default
288
+ assert "EMPTY_VAR=" in env_content or "EMPTY_VAR=\n" in env_content
289
+ # Check that auto_port generates a port (default not used)
290
+ assert "PORT=" in env_content
291
+ assert "PORT=8080" not in env_content # Should be a generated port
292
+ # Check that branch name is used (not default)
293
+ assert f"BRANCH={branch_name}" in env_content
294
+ # Check prompted value
295
+ assert "NO_DEFAULT=prompted_value" in env_content
296
+
201
297
  def test_error_cases(self, git_repo, monkeypatch, tmp_path):
202
298
  """Test various error conditions."""
203
299
  git_repo, default_branch = git_repo
@@ -179,6 +179,140 @@ class TestEnvTemplateParser:
179
179
  result = parse_env_template(template)
180
180
  assert result == "COMPOSE_VAR=${COMPOSE_VAR:-default}"
181
181
 
182
+ def test_parse_env_template_branch_placeholder(self, tmp_path):
183
+ """Test parsing {{ branch() }} placeholders."""
184
+ template = tmp_path / ".env.example"
185
+ template.write_text("BRANCH_NAME={{ branch() }}\nFEATURE={{ branch() }}_feature")
186
+
187
+ result = parse_env_template(template, branch_name="feature-auth")
188
+ assert result == "BRANCH_NAME=feature-auth\nFEATURE=feature-auth_feature"
189
+
190
+ def test_parse_env_template_branch_placeholder_none(self, tmp_path):
191
+ """Test {{ branch() }} placeholder when branch_name is None."""
192
+ template = tmp_path / ".env.example"
193
+ template.write_text("BRANCH={{ branch() }}")
194
+
195
+ # When branch_name is None, placeholder should remain unchanged
196
+ result = parse_env_template(template, branch_name=None)
197
+ assert result == "BRANCH={{ branch() }}"
198
+
199
+ def test_parse_env_template_mixed_placeholders(self, tmp_path, mocker):
200
+ """Test parsing mixed placeholders in one template."""
201
+ mocker.patch("sprout.utils.find_available_port", return_value=8080)
202
+ mocker.patch.dict(os.environ, {"API_KEY": "secret"})
203
+ mocker.patch("sprout.utils.console.input", return_value="password123")
204
+
205
+ template = tmp_path / ".env.example"
206
+ template.write_text(
207
+ "API_KEY={{ API_KEY }}\n"
208
+ "PORT={{ auto_port() }}\n"
209
+ "BRANCH={{ branch() }}\n"
210
+ "DB_PASS={{ DB_PASS }}\n"
211
+ "COMPOSE_VAR=${COMPOSE_VAR}"
212
+ )
213
+
214
+ result = parse_env_template(template, branch_name="feature-xyz")
215
+ assert result == (
216
+ "API_KEY=secret\n"
217
+ "PORT=8080\n"
218
+ "BRANCH=feature-xyz\n"
219
+ "DB_PASS=password123\n"
220
+ "COMPOSE_VAR=${COMPOSE_VAR}"
221
+ )
222
+
223
+ def test_parse_env_template_variable_with_default(self, tmp_path, mocker):
224
+ """Test parsing {{ VARIABLE | default }} when variable is not set."""
225
+ # Ensure the variable is not in environment
226
+ mocker.patch.dict(os.environ, {}, clear=False)
227
+ if "TEST_VAR" in os.environ:
228
+ del os.environ["TEST_VAR"]
229
+
230
+ template = tmp_path / ".env.example"
231
+ template.write_text("TEST_VAR={{ TEST_VAR | localhost }}")
232
+
233
+ result = parse_env_template(template)
234
+ assert result == "TEST_VAR=localhost"
235
+
236
+ def test_parse_env_template_variable_with_default_env_set(self, tmp_path, mocker):
237
+ """Test that environment variable overrides default value."""
238
+ mocker.patch.dict(os.environ, {"TEST_VAR": "custom_value"})
239
+
240
+ template = tmp_path / ".env.example"
241
+ template.write_text("TEST_VAR={{ TEST_VAR | localhost }}")
242
+
243
+ result = parse_env_template(template)
244
+ assert result == "TEST_VAR=custom_value"
245
+
246
+ def test_parse_env_template_variable_with_empty_default(self, tmp_path, mocker):
247
+ """Test parsing {{ VARIABLE | }} with empty string as default."""
248
+ mocker.patch.dict(os.environ, {}, clear=False)
249
+ if "EMPTY_VAR" in os.environ:
250
+ del os.environ["EMPTY_VAR"]
251
+
252
+ template = tmp_path / ".env.example"
253
+ template.write_text("EMPTY_VAR={{ EMPTY_VAR | }}")
254
+
255
+ result = parse_env_template(template)
256
+ assert result == "EMPTY_VAR="
257
+
258
+ def test_parse_env_template_variable_whitespace_trimming(self, tmp_path, mocker):
259
+ """Test that whitespace is trimmed from default values."""
260
+ mocker.patch.dict(os.environ, {}, clear=False)
261
+ if "TEST_VAR" in os.environ:
262
+ del os.environ["TEST_VAR"]
263
+
264
+ template = tmp_path / ".env.example"
265
+ template.write_text("TEST_VAR={{ TEST_VAR | localhost }}")
266
+
267
+ result = parse_env_template(template)
268
+ assert result == "TEST_VAR=localhost"
269
+
270
+ def test_parse_env_template_branch_with_default(self, tmp_path):
271
+ """Test parsing {{ branch() | default }} when branch_name is None."""
272
+ template = tmp_path / ".env.example"
273
+ template.write_text("BRANCH={{ branch() | main }}")
274
+
275
+ result = parse_env_template(template, branch_name=None)
276
+ assert result == "BRANCH=main"
277
+
278
+ def test_parse_env_template_branch_with_default_branch_set(self, tmp_path):
279
+ """Test that branch_name overrides default value."""
280
+ template = tmp_path / ".env.example"
281
+ template.write_text("BRANCH={{ branch() | main }}")
282
+
283
+ result = parse_env_template(template, branch_name="feature-xyz")
284
+ assert result == "BRANCH=feature-xyz"
285
+
286
+ def test_parse_env_template_auto_port_with_default(self, tmp_path, mocker):
287
+ """Test {{ auto_port() | default }} - default is captured but not used."""
288
+ mocker.patch("sprout.utils.find_available_port", return_value=9000)
289
+
290
+ template = tmp_path / ".env.example"
291
+ template.write_text("PORT={{ auto_port() | 8080 }}")
292
+
293
+ # auto_port() always generates a port, so default is not used
294
+ result = parse_env_template(template)
295
+ assert result == "PORT=9000"
296
+
297
+ def test_parse_env_template_mixed_with_defaults(self, tmp_path, mocker):
298
+ """Test parsing mixed placeholders with default values."""
299
+ mocker.patch("sprout.utils.find_available_port", return_value=8080)
300
+ mocker.patch.dict(os.environ, {"SET_VAR": "from_env"}, clear=False)
301
+ if "UNSET_VAR" in os.environ:
302
+ del os.environ["UNSET_VAR"]
303
+
304
+ template = tmp_path / ".env.example"
305
+ template.write_text(
306
+ "SET_VAR={{ SET_VAR | default1 }}\n"
307
+ "UNSET_VAR={{ UNSET_VAR | default2 }}\n"
308
+ "PORT={{ auto_port() | 3000 }}\n"
309
+ "BRANCH={{ branch() | develop }}\n"
310
+ "EMPTY={{ EMPTY | }}"
311
+ )
312
+
313
+ result = parse_env_template(template, branch_name=None)
314
+ assert result == ("SET_VAR=from_env\nUNSET_VAR=default2\nPORT=8080\nBRANCH=develop\nEMPTY=")
315
+
182
316
  def test_parse_env_template_file_not_found(self, tmp_path):
183
317
  """Test error when template file doesn't exist."""
184
318
  template = tmp_path / "nonexistent.env"
File without changes
File without changes
File without changes
File without changes
File without changes