perf: scan the workspace once per create instead of once per port - #11
Merged
Merged
Conversation
`sprout create` spent 97 of its 125 seconds generating .env files on a
workspace holding ~490k files under .sprout/. Two causes:
- find_available_port() called get_used_ports() on every call, so the
entire workspace was walked once per {{ auto_port() }} placeholder:
9 walks at ~11s each for a template with 8 auto-assigned ports.
- get_used_ports() descended into node_modules, .venv, .git and friends,
which hold most of those files and never a sprout-generated .env.
find_available_port() now accepts a pre-computed port set, and
parse_env_template() builds that set once per file, so the workspace is
scanned once per create instead of once per port. The walk prunes
dependency, cache and VCS directories, which also stops ports from being
reserved out of .env files that sprout did not write.
Measured on the same workspace: the .env generation step went from 97s
to 2s, and a single scan from 10.0s to 0.66s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZFDWL7uPZTBDWvLDsTdJD
Review of the previous commit found three defects in it:
- The prune matched by directory name at any depth, so a worktree whose
own name is on the exclude list (a branch called "venv", or "fix/venv")
had its .env skipped and its ports handed out to the next worktree.
is_port_available() does not catch that: it only sees a conflict while
the other stack is running. An excluded name is now still walked when
the directory holds a .git entry, which every worktree root does.
- parse_env_template() scanned the workspace even for templates with no
{{ auto_port() }}, dragging git rev-parse and a full walk into every
call. That also broke test isolation: 15 unit tests started depending
on being run inside a git repository. The scan now happens on the first
placeholder that needs a port, and not at all otherwise.
- Dropping the is_file() guard let a fifo named .env reach read_text()
and block indefinitely. The guard is back, applied only to names that
already end in .env, so it costs one stat per candidate.
The tests grew from 70 to 103. The new ones pin what mutation testing
showed was unprotected: the set() vs None distinction, the prune itself,
walking a worktree root that carries an excluded name, suffixed names
such as local.env, in-file port uniqueness, and one workspace scan per
create. CHANGELOG now credits the speedup to both changes rather than to
the rescan removal alone, and the usage doc that describes the same
scanning behaviour as the README was brought in line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZFDWL7uPZTBDWvLDsTdJD
laysakura
approved these changes
Sep 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
sprout createwalked the entire workspace once per{{ auto_port() }}placeholder. It now walks it once per create, and the walk itself skips dependency and cache directories.sprout/, 8 auto-assigned ports), the.envgeneration step went from 97s to 2s; a single walk went from 10.0s to 0.66s.envfiles, the old and new walks return identical file sets and identical port setsProblem
Two independent causes, both measured with per-phase timestamps:
find_available_port()calledget_used_ports()on every call, so.sprout/was walked once per placeholder — 9 walks for a template with 8 ports, at ~11s each.node_modules,.venv,.gitand friends, which held 77% of the files under.sprout/.Changes
find_available_port(used_ports=None)accepts a pre-computed port set.parse_env_template()builds that set once per file and reuses it, soget_used_ports()runs once per create instead of once per port.iter_env_files()replacesPath.rglob("*.env")withos.walk(), pruningSCAN_EXCLUDED_DIRSin place.{{ auto_port() }}is reached, so a template without one never walks the worktrees — and never shells out togit rev-parse.Correctness notes
Three ways this can go wrong, and how each is handled:
sprout create venv(orfix/venv) puts a real worktree behind an excluded name. Pruning by name alone made its.envinvisible and handed its ports out again.is_port_available()is not a safety net here: it only sees a conflict while the other stack is running, so the collision surfaces later as a failure to bind. An excluded name is still walked when the directory holds a.gitentry, which every worktree root does.used_portsreplaces the scan rather than adding to it. An empty set means "nothing is taken"; passNoneto have the function scan. Both docstrings spell this out, because a partial set silently gives up collision detection against the other worktrees.is_file()guard let a fifo named.envblockread_text()indefinitely. The guard is back, applied only to names that already end in.env, so it costs onestatper candidate.Test Plan
make all-checksgreen: ruff, mypy, 103 tests (up from 70)local.env-style names, directories named*.env, dangps, symlinked worktrees and permission errorsfile_ports.add(), droppedis_file()— each kills at least one testsprout createon the original workspace:.envgeneration 97s -> 2s