sprout-cli 0.7.0__tar.gz → 0.9.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,8 +1,8 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: sprout-cli
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Summary: CLI tool to automate git worktree and Docker Compose development workflows
5
- Author: SecDevLab Inc.
5
+ Author: AgenticSec
6
6
  License: MIT
7
7
  License-File: LICENSE
8
8
  Classifier: Development Status :: 3 - Alpha
@@ -47,7 +47,7 @@ pip install sprout-cli
47
47
  For development:
48
48
  ```bash
49
49
  # Clone the repository
50
- git clone https://github.com/SecDev-Lab/sprout.git
50
+ git clone https://github.com/AgenticSec/sprout.git
51
51
  cd sprout
52
52
 
53
53
  # Install in development mode
@@ -196,6 +196,7 @@ sprout supports three types of placeholders in `.env.example`:
196
196
  - Avoids conflicts across ALL services in ALL sprout environments
197
197
  - Checks system port availability
198
198
  - Ensures global uniqueness even in monorepo setups
199
+ - Collects used ports from the `.env` files of every worktree, skipping dependency and cache directories (`node_modules`, `.venv`, `.git`, ...)
199
200
 
200
201
  3. **Branch Name**: `{{ branch() }}`
201
202
  - Replaced with the current branch/subtree name
@@ -19,7 +19,7 @@ pip install sprout-cli
19
19
  For development:
20
20
  ```bash
21
21
  # Clone the repository
22
- git clone https://github.com/SecDev-Lab/sprout.git
22
+ git clone https://github.com/AgenticSec/sprout.git
23
23
  cd sprout
24
24
 
25
25
  # Install in development mode
@@ -168,6 +168,7 @@ sprout supports three types of placeholders in `.env.example`:
168
168
  - Avoids conflicts across ALL services in ALL sprout environments
169
169
  - Checks system port availability
170
170
  - Ensures global uniqueness even in monorepo setups
171
+ - Collects used ports from the `.env` files of every worktree, skipping dependency and cache directories (`node_modules`, `.venv`, `.git`, ...)
171
172
 
172
173
  3. **Branch Name**: `{{ branch() }}`
173
174
  - Replaced with the current branch/subtree name
@@ -10,7 +10,7 @@ readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
  license = {text = "MIT"}
12
12
  authors = [
13
- {name = "SecDevLab Inc."},
13
+ {name = "AgenticSec"},
14
14
  ]
15
15
  classifiers = [
16
16
  "Development Status :: 3 - Alpha",
@@ -1,3 +1,3 @@
1
1
  """sprout - CLI tool to automate git worktree and Docker Compose development workflows."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.9.0"
@@ -5,6 +5,7 @@ import random
5
5
  import re
6
6
  import socket
7
7
  import subprocess
8
+ from collections.abc import Iterator
8
9
  from datetime import datetime
9
10
  from pathlib import Path
10
11
  from typing import TypeAlias
@@ -19,6 +20,27 @@ from sprout.types import BranchName, WorktreeInfo
19
20
  PortNumber: TypeAlias = int
20
21
  PortSet: TypeAlias = set[PortNumber]
21
22
 
23
+ # Directory names skipped when scanning worktrees for .env files. These hold
24
+ # dependencies, caches and VCS internals, never a .env that sprout generated,
25
+ # but they can each contain tens of thousands of files. Descending into them
26
+ # turns a port scan into a multi-second walk on a real workspace.
27
+ SCAN_EXCLUDED_DIRS: frozenset[str] = frozenset(
28
+ {
29
+ ".direnv",
30
+ ".git",
31
+ ".mypy_cache",
32
+ ".next",
33
+ ".pytest_cache",
34
+ ".ruff_cache",
35
+ ".terraform",
36
+ ".tox",
37
+ ".venv",
38
+ "__pycache__",
39
+ "node_modules",
40
+ "venv",
41
+ }
42
+ )
43
+
22
44
  console = Console()
23
45
 
24
46
 
@@ -75,6 +97,45 @@ def ensure_sprout_dir() -> Path:
75
97
  return sprout_dir
76
98
 
77
99
 
100
+ def _is_worktree_root(path: Path) -> bool:
101
+ """Check whether a directory is a git worktree root.
102
+
103
+ Args:
104
+ path: Directory to inspect.
105
+ """
106
+ # git worktree add always leaves a .git entry: a file in a linked worktree,
107
+ # a directory in a main checkout.
108
+ return (path / ".git").exists()
109
+
110
+
111
+ def iter_env_files(root: Path) -> Iterator[Path]:
112
+ """Yield .env files under root, skipping dependency and cache directories.
113
+
114
+ An excluded name is still walked when the directory is a worktree root:
115
+ a branch named "venv" or "fix/venv" puts a real worktree behind an excluded
116
+ name, and skipping it would hand its ports out to the next worktree.
117
+
118
+ Args:
119
+ root: Directory to walk.
120
+
121
+ Yields:
122
+ Paths of the .env files found under root.
123
+ """
124
+ for dir_path, dir_names, file_names in os.walk(root):
125
+ # Prune in place so os.walk does not descend into excluded directories
126
+ dir_names[:] = [
127
+ name
128
+ for name in dir_names
129
+ if name not in SCAN_EXCLUDED_DIRS or _is_worktree_root(Path(dir_path) / name)
130
+ ]
131
+ for file_name in file_names:
132
+ if file_name.endswith(".env"):
133
+ env_file = Path(dir_path) / file_name
134
+ # Only regular files: a fifo named .env would block read_text()
135
+ if env_file.is_file():
136
+ yield env_file
137
+
138
+
78
139
  def get_used_ports() -> PortSet:
79
140
  """Get all ports currently used by sprout worktrees."""
80
141
  used_ports: PortSet = set()
@@ -84,18 +145,17 @@ def get_used_ports() -> PortSet:
84
145
  return used_ports
85
146
 
86
147
  # Scan all .env files recursively in .sprout/
87
- for env_file in sprout_dir.rglob("*.env"):
88
- if env_file.is_file():
89
- try:
90
- content = env_file.read_text()
91
- # Find all port assignments (e.g., PORT=8080)
92
- port_matches = re.findall(r"=(\d{4,5})\b", content)
93
- for port_str in port_matches:
94
- port = int(port_str)
95
- if 1024 <= port <= 65535:
96
- used_ports.add(port)
97
- except (OSError, ValueError):
98
- continue
148
+ for env_file in iter_env_files(sprout_dir):
149
+ try:
150
+ content = env_file.read_text()
151
+ # Find all port assignments (e.g., PORT=8080)
152
+ port_matches = re.findall(r"=(\d{4,5})\b", content)
153
+ for port_str in port_matches:
154
+ port = int(port_str)
155
+ if 1024 <= port <= 65535:
156
+ used_ports.add(port)
157
+ except (OSError, ValueError):
158
+ continue
99
159
 
100
160
  return used_ports
101
161
 
@@ -110,9 +170,18 @@ def is_port_available(port: PortNumber) -> bool:
110
170
  return False
111
171
 
112
172
 
113
- def find_available_port() -> PortNumber:
114
- """Find an available port that's not used by sprout or system."""
115
- used_ports = get_used_ports()
173
+ def find_available_port(used_ports: PortSet | None = None) -> PortNumber:
174
+ """Find an available port that's not used by sprout or system.
175
+
176
+ Args:
177
+ used_ports: Ports to treat as taken, replacing the workspace scan rather
178
+ than adding to it. Scanning every worktree is the expensive part of
179
+ this call, so callers that allocate several ports in a row should
180
+ scan once and pass the result instead of paying for a fresh walk per
181
+ port. An empty set means nothing is taken; pass None to scan.
182
+ """
183
+ if used_ports is None:
184
+ used_ports = get_used_ports()
116
185
  max_attempts = 1000
117
186
 
118
187
  for _ in range(max_attempts):
@@ -136,7 +205,11 @@ def parse_env_template(
136
205
  Args:
137
206
  template_path: Path to the .env.example template file
138
207
  silent: If True, use stderr for prompts to keep stdout clean
139
- used_ports: Set of ports already in use (in addition to system-wide used ports)
208
+ used_ports: Ports already in use across the workspace. The set replaces
209
+ the workspace scan rather than adding to it, so passing a partial
210
+ set silently gives up collision detection against the other
211
+ worktrees. An empty set means nothing is taken; omit it to have this
212
+ function scan once, and only if the template asks for a port.
140
213
  branch_name: Branch name to use for {{ branch() }} placeholders
141
214
  """
142
215
  if not template_path.exists():
@@ -148,11 +221,12 @@ def parse_env_template(
148
221
  raise SproutError(f"Failed to read .env.example: {e}") from e
149
222
 
150
223
  lines: list[str] = []
151
- # Track used ports within this file to avoid duplicates
152
- file_ports: PortSet = set()
153
- # Include any additional used ports passed in
154
- if used_ports:
155
- file_ports.update(used_ports)
224
+ # Ports that {{ auto_port() }} must avoid: the ones already taken across the
225
+ # workspace plus the ones handed out earlier in this file. Left as None
226
+ # until the first port is requested, so a template without auto_port() -
227
+ # and every caller that already knows the used ports - never walks the
228
+ # worktrees. Once filled, it is reused for the rest of the file.
229
+ file_ports: PortSet | None = set(used_ports) if used_ports is not None else None
156
230
 
157
231
  for line in content.splitlines():
158
232
  # Process {{ auto_port() | default }} placeholders
@@ -162,10 +236,13 @@ def parse_env_template(
162
236
  if default_value is not None:
163
237
  default_value = default_value.strip()
164
238
 
165
- # Generate available port
166
- port = find_available_port()
167
- while port in file_ports:
168
- port = find_available_port()
239
+ # Generate available port. Scan the worktrees on the first
240
+ # placeholder only; from then on file_ports carries both those
241
+ # ports and the ones assigned earlier in this file.
242
+ nonlocal file_ports
243
+ if file_ports is None:
244
+ file_ports = get_used_ports()
245
+ port = find_available_port(file_ports)
169
246
  file_ports.add(port)
170
247
  return str(port)
171
248
 
@@ -4,6 +4,7 @@ import subprocess
4
4
 
5
5
  from typer.testing import CliRunner
6
6
 
7
+ import sprout.utils
7
8
  from sprout.cli import app
8
9
  from sprout.utils import get_used_ports
9
10
 
@@ -146,6 +147,28 @@ PORT2={{ auto_port() }}
146
147
  # Ensure no overlap
147
148
  assert len(ports1.intersection(ports2)) == 0, "Ports should not overlap between worktrees"
148
149
 
150
+ def test_create_scans_the_workspace_once(self, git_repo, monkeypatch, mocker): # noqa: F811
151
+ """Test one create walks the worktrees once, not once per port."""
152
+ git_repo, _default_branch = git_repo
153
+ monkeypatch.chdir(git_repo)
154
+ monkeypatch.setenv("API_KEY", "test_key") # Set env var for root .env.example
155
+
156
+ service = git_repo / "service"
157
+ service.mkdir()
158
+ (service / ".env.example").write_text(
159
+ "P1={{ auto_port() }}\nP2={{ auto_port() }}\nP3={{ auto_port() }}\n"
160
+ )
161
+ subprocess.run(["git", "add", "."], cwd=git_repo, check=True)
162
+ subprocess.run(["git", "commit", "-m", "Add service"], cwd=git_repo, check=True)
163
+
164
+ # Every scan funnels through iter_env_files, whichever module asked for it
165
+ spy = mocker.spy(sprout.utils, "iter_env_files")
166
+
167
+ result = runner.invoke(app, ["create", "scan-once"])
168
+
169
+ assert result.exit_code == 0
170
+ assert spy.call_count == 1
171
+
149
172
  def test_nested_directory_structure(self, git_repo, monkeypatch): # noqa: F811
150
173
  """Test handling of nested directory structures."""
151
174
  git_repo, default_branch = git_repo
@@ -10,12 +10,14 @@ import pytest
10
10
 
11
11
  from sprout.exceptions import SproutError
12
12
  from sprout.utils import (
13
+ SCAN_EXCLUDED_DIRS,
13
14
  branch_exists,
14
15
  find_available_port,
15
16
  get_git_root,
16
17
  get_used_ports,
17
18
  is_git_repository,
18
19
  is_port_available,
20
+ iter_env_files,
19
21
  parse_env_template,
20
22
  run_command,
21
23
  worktree_exists,
@@ -100,6 +102,36 @@ class TestPortUtils:
100
102
  ports = get_used_ports()
101
103
  assert ports == set()
102
104
 
105
+ def test_get_used_ports_skips_dependency_directories(self, tmp_path, mocker):
106
+ """Test get_used_ports ignores .env files under dependency directories."""
107
+ mocker.patch("sprout.utils.get_sprout_dir", return_value=tmp_path)
108
+
109
+ (tmp_path / "branch1").mkdir()
110
+ (tmp_path / "branch1" / ".env").write_text("WEB_PORT=8080")
111
+
112
+ # Dependency and cache trees can ship their own .env files, and walking
113
+ # them is what made port allocation slow on large worktrees.
114
+ vendored = tmp_path / "branch1" / "node_modules" / "some-package"
115
+ vendored.mkdir(parents=True)
116
+ (vendored / ".env").write_text("VENDOR_PORT=9999")
117
+
118
+ virtualenv = tmp_path / "branch1" / ".venv" / "lib"
119
+ virtualenv.mkdir(parents=True)
120
+ (virtualenv / ".env").write_text("VENV_PORT=9998")
121
+
122
+ assert get_used_ports() == {8080}
123
+
124
+ def test_get_used_ports_ignores_unreadable_files(self, tmp_path, mocker):
125
+ """Test a .env that cannot be read is skipped, not fatal."""
126
+ mocker.patch("sprout.utils.get_sprout_dir", return_value=tmp_path)
127
+
128
+ (tmp_path / "branch1").mkdir()
129
+ (tmp_path / "branch1" / ".env").write_text("WEB_PORT=8080")
130
+ # Dangling symlink: reported by the walk, unreadable when opened
131
+ (tmp_path / "branch1" / "broken.env").symlink_to(tmp_path / "gone" / ".env")
132
+
133
+ assert get_used_ports() == {8080}
134
+
103
135
  def test_is_port_available_true(self):
104
136
  """Test is_port_available returns True for free port."""
105
137
  # Find a likely free port
@@ -137,6 +169,93 @@ class TestPortUtils:
137
169
  with pytest.raises(SproutError, match="Could not find an available port"):
138
170
  find_available_port()
139
171
 
172
+ def test_find_available_port_accepts_precomputed_ports(self, mocker):
173
+ """Test find_available_port skips the workspace scan when given ports."""
174
+ scan = mocker.patch("sprout.utils.get_used_ports", return_value=set())
175
+ mocker.patch("sprout.utils.is_port_available", return_value=True)
176
+ mocker.patch("random.randint", side_effect=[8080, 8081])
177
+
178
+ port = find_available_port({8080})
179
+
180
+ assert port == 8081
181
+ scan.assert_not_called()
182
+
183
+ def test_find_available_port_empty_set_is_not_a_rescan(self, mocker):
184
+ """Test an empty set means 'nothing taken', not 'go and scan'."""
185
+ scan = mocker.patch("sprout.utils.get_used_ports", return_value={8080})
186
+ mocker.patch("sprout.utils.is_port_available", return_value=True)
187
+ mocker.patch("random.randint", side_effect=[8080])
188
+
189
+ assert find_available_port(set()) == 8080
190
+ scan.assert_not_called()
191
+
192
+
193
+ class TestIterEnvFiles:
194
+ """Test the .env discovery walk."""
195
+
196
+ # Spelled out rather than derived from SCAN_EXCLUDED_DIRS: deriving it would
197
+ # make the test shrink along with the constant instead of failing.
198
+ EXCLUDED_DIRS = [
199
+ ".direnv",
200
+ ".git",
201
+ ".mypy_cache",
202
+ ".next",
203
+ ".pytest_cache",
204
+ ".ruff_cache",
205
+ ".terraform",
206
+ ".tox",
207
+ ".venv",
208
+ "__pycache__",
209
+ "node_modules",
210
+ "venv",
211
+ ]
212
+
213
+ def test_excluded_dirs_constant_is_in_sync(self):
214
+ """Test the spelled-out list still matches the implementation."""
215
+ assert set(self.EXCLUDED_DIRS) == SCAN_EXCLUDED_DIRS
216
+
217
+ @pytest.mark.parametrize("excluded", EXCLUDED_DIRS)
218
+ def test_iter_env_files_skips_excluded_directory(self, tmp_path, excluded):
219
+ """Test each excluded directory is left unwalked."""
220
+ (tmp_path / "worktree").mkdir()
221
+ (tmp_path / "worktree" / ".env").write_text("PORT=8080")
222
+
223
+ buried = tmp_path / "worktree" / excluded / "nested"
224
+ buried.mkdir(parents=True)
225
+ (buried / ".env").write_text("PORT=9999")
226
+
227
+ assert list(iter_env_files(tmp_path)) == [tmp_path / "worktree" / ".env"]
228
+
229
+ @pytest.mark.parametrize("excluded", EXCLUDED_DIRS)
230
+ def test_iter_env_files_walks_worktree_named_after_excluded_dir(self, tmp_path, excluded):
231
+ """Test a worktree is walked even when its name is on the exclude list."""
232
+ # A branch named e.g. "venv" or "fix/venv" puts a real worktree behind an
233
+ # excluded name; its ports must still be collected.
234
+ worktree = tmp_path / excluded
235
+ worktree.mkdir()
236
+ (worktree / ".git").write_text("gitdir: /somewhere/.git/worktrees/venv\n")
237
+ (worktree / ".env").write_text("PORT=8080")
238
+
239
+ assert list(iter_env_files(tmp_path)) == [worktree / ".env"]
240
+
241
+ def test_iter_env_files_matches_suffixed_names(self, tmp_path):
242
+ """Test names ending in .env are matched, as rglob('*.env') did."""
243
+ (tmp_path / ".env").write_text("A=1")
244
+ (tmp_path / "local.env").write_text("B=2")
245
+ (tmp_path / "env").write_text("C=3")
246
+ (tmp_path / "notes.txt").write_text("D=4")
247
+
248
+ assert {path.name for path in iter_env_files(tmp_path)} == {".env", "local.env"}
249
+
250
+ @pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="requires mkfifo")
251
+ def test_iter_env_files_skips_non_regular_files(self, tmp_path):
252
+ """Test a fifo named .env is skipped instead of blocking a read."""
253
+ os.mkfifo(tmp_path / ".env")
254
+ (tmp_path / "real").mkdir()
255
+ (tmp_path / "real" / ".env").write_text("PORT=8080")
256
+
257
+ assert list(iter_env_files(tmp_path)) == [tmp_path / "real" / ".env"]
258
+
140
259
 
141
260
  class TestEnvTemplateParser:
142
261
  """Test .env template parsing."""
@@ -151,6 +270,70 @@ class TestEnvTemplateParser:
151
270
  result = parse_env_template(template)
152
271
  assert result == "WEB_PORT=8080\nAPI_PORT=3000"
153
272
 
273
+ def test_parse_env_template_scans_workspace_once(self, tmp_path, mocker):
274
+ """Test the workspace is scanned once, not once per auto_port()."""
275
+ scan = mocker.patch("sprout.utils.get_used_ports", return_value=set())
276
+ mocker.patch("sprout.utils.is_port_available", return_value=True)
277
+ mocker.patch("random.randint", side_effect=[8080, 8081, 8082])
278
+
279
+ template = tmp_path / ".env.example"
280
+ template.write_text(
281
+ "A_PORT={{ auto_port() }}\nB_PORT={{ auto_port() }}\nC_PORT={{ auto_port() }}"
282
+ )
283
+
284
+ result = parse_env_template(template)
285
+
286
+ assert result == "A_PORT=8080\nB_PORT=8081\nC_PORT=8082"
287
+ assert scan.call_count == 1
288
+
289
+ def test_parse_env_template_reuses_given_used_ports(self, tmp_path, mocker):
290
+ """Test a caller-supplied port set replaces the workspace scan."""
291
+ scan = mocker.patch("sprout.utils.get_used_ports", return_value=set())
292
+ mocker.patch("sprout.utils.is_port_available", return_value=True)
293
+ mocker.patch("random.randint", side_effect=[8080, 8081])
294
+
295
+ template = tmp_path / ".env.example"
296
+ template.write_text("WEB_PORT={{ auto_port() }}")
297
+
298
+ result = parse_env_template(template, used_ports={8080})
299
+
300
+ assert result == "WEB_PORT=8081"
301
+ scan.assert_not_called()
302
+
303
+ def test_parse_env_template_empty_used_ports_is_not_a_rescan(self, tmp_path, mocker):
304
+ """Test an empty port set is taken at face value, not as 'unscanned'."""
305
+ scan = mocker.patch("sprout.utils.get_used_ports", return_value={8080})
306
+ mocker.patch("sprout.utils.is_port_available", return_value=True)
307
+ mocker.patch("random.randint", side_effect=[8080])
308
+
309
+ template = tmp_path / ".env.example"
310
+ template.write_text("WEB_PORT={{ auto_port() }}")
311
+
312
+ assert parse_env_template(template, used_ports=set()) == "WEB_PORT=8080"
313
+ scan.assert_not_called()
314
+
315
+ def test_parse_env_template_without_auto_port_does_not_scan(self, tmp_path, mocker):
316
+ """Test a template with no auto_port() never touches the workspace."""
317
+ scan = mocker.patch("sprout.utils.get_used_ports", return_value=set())
318
+
319
+ template = tmp_path / ".env.example"
320
+ template.write_text("NAME=static\nURL=${HOST}/path")
321
+
322
+ assert parse_env_template(template) == "NAME=static\nURL=${HOST}/path"
323
+ scan.assert_not_called()
324
+
325
+ def test_parse_env_template_never_repeats_a_port_within_a_file(self, tmp_path, mocker):
326
+ """Test a port assigned earlier in the file is excluded from later draws."""
327
+ mocker.patch("sprout.utils.get_used_ports", return_value=set())
328
+ mocker.patch("sprout.utils.is_port_available", return_value=True)
329
+ # The same port is drawn twice; the second placeholder must reject it
330
+ mocker.patch("random.randint", side_effect=[8080, 8080, 8081])
331
+
332
+ template = tmp_path / ".env.example"
333
+ template.write_text("A={{ auto_port() }}\nB={{ auto_port() }}")
334
+
335
+ assert parse_env_template(template) == "A=8080\nB=8081"
336
+
154
337
  def test_parse_env_template_variable_from_env(self, tmp_path, mocker):
155
338
  """Test parsing {{ VARIABLE }} from environment."""
156
339
  mocker.patch.dict(os.environ, {"API_KEY": "secret123"})
File without changes
File without changes
File without changes
File without changes