Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
35 changes: 35 additions & 0 deletions .serena/memories/code_style_conventions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Code Style and Conventions

## Python Style
- Python 3.11+ with type hints
- Use ruff for linting and formatting
- Follow PEP 8 conventions
- Use type annotations for function parameters and return types

## Naming Conventions
- Functions: snake_case (e.g., `parse_env_template`)
- Classes: PascalCase (e.g., `SproutError`)
- Constants: UPPER_CASE
- Type aliases: PascalCase (e.g., `BranchName`, `PortNumber`)

## Documentation
- Use docstrings for functions with Args/Returns sections
- Comments should be in English
- No emoji in code (only in CLI output if requested)

## Error Handling
- Use custom `SproutError` exception for application errors
- Use `typer.Exit()` for CLI exit codes
- Provide helpful error messages to users

## Testing
- Use pytest for testing
- Mock external dependencies (subprocess, file system)
- Use `@pytest.mark.parametrize` for multiple test scenarios
- Test files go in `tests/` directory

## CLI Design
- Use typer for CLI framework
- Use rich for beautiful console output
- Support both interactive and silent modes
- Exit codes: 0 (success), 1 (error), 130 (user cancellation)
30 changes: 30 additions & 0 deletions .serena/memories/project_overview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Sprout Project Overview

Sprout is a CLI tool to automate git worktree and Docker Compose development workflows.

## Purpose
- Create isolated development environments using git worktrees
- Automatic `.env` file generation from `.env.example` templates
- Smart port allocation to avoid conflicts
- Centralized worktree management in `.sprout/` directory

## Tech Stack
- Language: Python 3.11+
- Build System: Makefile with uv package manager
- Testing: pytest
- Code Quality: ruff (linting and formatting)
- CLI Framework: typer
- UI: rich (for beautiful CLI output)

## Key Features
1. Creates git worktrees with automatic branch creation
2. Processes `.env.example` templates to generate `.env` files
3. Supports variable placeholders: `{{ VARIABLE_NAME }}`
4. Supports automatic port assignment: `{{ auto_port() }}`
5. Works with monorepos (multiple `.env.example` files in subdirectories)
6. Works without `.env.example` files (just creates worktrees)

## Entry Points
- Main CLI: `src/sprout/cli.py`
- Commands: `src/sprout/commands/` (create, ls, rm, path)
- Template parsing: `src/sprout/utils.py::parse_env_template()`
28 changes: 28 additions & 0 deletions .serena/memories/suggested_commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Suggested Commands for Sprout Development

## Testing and Quality
- `make test` - Run all unit tests
- `make test-cov` - Run tests with coverage report
- `make lint` - Run ruff linter
- `make format` - Format code with ruff
- `make typecheck` - Run type checking

## Development Setup
- `make setup` - Install development dependencies (or `uv sync --dev`)

## Building and Running
- `sprout create <branch-name>` - Create a new worktree
- `sprout ls` - List all worktrees
- `sprout rm <branch-name>` - Remove a worktree
- `sprout path <branch-name>` - Get worktree path

## Git Commands
- `git worktree list` - List git worktrees
- `git worktree add` - Add a worktree
- `git worktree remove` - Remove a worktree

## Important: After Task Completion
Always run:
1. `make lint` - Check for linting errors
2. `make format` - Format the code
3. `make test` - Ensure all tests pass
68 changes: 68 additions & 0 deletions .serena/project.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
# * For C, use cpp
# * For JavaScript, use typescript
# Special requirements:
# * csharp: Requires the presence of a .sln file in the project folder.
language: python

# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []

# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false


# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []

# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""

project_name: "sprout"
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- Support for `{{ branch() }}` placeholder in `.env.example` templates - replaced with the current branch/subtree name

### Changed

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ lint:
# Format code using ruff
format:
@echo "Formatting code..."
uv run ruff check --fix
uv run ruff format

# Run tests
test:
Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ API_PORT={{ auto_port() }}
DB_HOST=localhost
DB_PORT={{ auto_port() }}

# Branch-specific Configuration
SERVICE_NAME=myapp-{{ branch() }}
DEPLOYMENT_ENV={{ branch() }}

# Example: Docker Compose variables (preserved as-is)
# sprout will NOT process ${...} syntax - it's passed through unchanged
# DB_NAME=${DB_NAME}
Expand Down Expand Up @@ -152,7 +156,7 @@ Show the version of sprout.

## Template Syntax

sprout supports two types of placeholders in `.env.example`:
sprout supports three types of placeholders in `.env.example`:

1. **Variable Placeholders**: `{{ VARIABLE_NAME }}`
- **First**: Checks if the variable exists in your environment (e.g., `export API_KEY=xxx`)
Expand All @@ -165,7 +169,12 @@ sprout supports two types of placeholders in `.env.example`:
- Checks system port availability
- Ensures global uniqueness even in monorepo setups

3. **Docker Compose Syntax (Preserved)**: `${VARIABLE}`
3. **Branch Name**: `{{ branch() }}`
- Replaced with the current branch/subtree name
- Useful for branch-specific configurations
- Example: `SERVICE_NAME=myapp-{{ branch() }}` becomes `SERVICE_NAME=myapp-feature-auth`

4. **Docker Compose Syntax (Preserved)**: `${VARIABLE}`
- NOT processed by sprout - passed through as-is
- Useful for Docker Compose variable substitution
- Example: `${DB_NAME:-default}` remains unchanged in generated `.env`
Expand Down
3 changes: 2 additions & 1 deletion sample/monorepo/backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Backend service
JWT_SECRET={{ JWT_SECRET }}
API_PORT={{ auto_port() }}
API_PORT={{ auto_port() }}
DOCKER_NETWORK={{ branch() }}-sprout-nw
3 changes: 2 additions & 1 deletion sample/monorepo/frontend/.env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Frontend service
REACT_APP_API_KEY={{ REACT_APP_API_KEY }}
FRONTEND_PORT={{ auto_port() }}
FRONTEND_PORT={{ auto_port() }}
DOCKER_NETWORK={{ branch() }}-sprout-nw
5 changes: 4 additions & 1 deletion src/sprout/commands/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,10 @@ def create_worktree(branch_name: BranchName, path_only: bool = False) -> Never:

# Parse template with combined used ports
env_content = parse_env_template(
env_example, silent=path_only, used_ports=all_used_ports | session_ports
env_example,
silent=path_only,
used_ports=all_used_ports | session_ports,
branch_name=branch_name,
)

# Extract ports from generated content and add to session_ports
Expand Down
13 changes: 11 additions & 2 deletions src/sprout/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,18 @@ def find_available_port() -> PortNumber:


def parse_env_template(
template_path: Path, silent: bool = False, used_ports: PortSet | None = None
template_path: Path,
silent: bool = False,
used_ports: PortSet | None = None,
branch_name: str | None = None,
) -> str:
"""Parse .env.example template and process placeholders.

Args:
template_path: Path to the .env.example template file
silent: If True, use stderr for prompts to keep stdout clean
used_ports: Set of ports already in use (in addition to system-wide used ports)
branch_name: Branch name to use for {{ branch() }} placeholders
"""
if not template_path.exists():
raise SproutError(f".env.example file not found at {template_path}")
Expand Down Expand Up @@ -161,6 +165,10 @@ def replace_auto_port(match: re.Match[str]) -> str:

line = re.sub(r"{{\s*auto_port\(\)\s*}}", replace_auto_port, line)

# Process {{ branch() }} placeholders
if branch_name:
line = re.sub(r"{{\s*branch\(\)\s*}}", branch_name, line)

# Process {{ VARIABLE }} placeholders
def replace_variable(match: re.Match[str]) -> str:
var_name = match.group(1).strip()
Expand All @@ -187,7 +195,8 @@ def replace_variable(match: re.Match[str]) -> str:
value = console.input(prompt)
return value

line = re.sub(r"{{\s*([^}]+)\s*}}", replace_variable, line)
# Only match variables that don't look like function calls (no parentheses)
line = re.sub(r"{{\s*([^}()]+)\s*}}", replace_variable, line)

lines.append(line)

Expand Down
41 changes: 41 additions & 0 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,47 @@ def test_create_existing_branch(self, git_repo, monkeypatch):
worktree_path = git_repo / ".sprout" / "existing-branch"
assert worktree_path.exists()

def test_branch_placeholder(self, git_repo, monkeypatch):
"""Test that {{ branch() }} placeholder is replaced with branch name."""
git_repo, default_branch = git_repo
monkeypatch.chdir(git_repo)
monkeypatch.setenv("API_KEY", "test_key")

# Create .env.example with branch() placeholder
env_example = git_repo / ".env.example"
env_example.write_text(
"# Branch-specific configuration\n"
"BRANCH_NAME={{ branch() }}\n"
"SERVICE_NAME=myapp-{{ branch() }}\n"
"API_KEY={{ API_KEY }}\n"
"PORT={{ auto_port() }}\n"
"COMPOSE_VAR=${BRANCH_NAME}\n"
)

# Add to git
subprocess.run(["git", "add", ".env.example"], cwd=git_repo, check=True)
subprocess.run(
["git", "commit", "-m", "Add .env.example with branch placeholder"],
cwd=git_repo,
check=True,
)

# Create worktree
branch_name = "feature-auth"
result = runner.invoke(app, ["create", branch_name])
assert result.exit_code == 0

# Verify .env was created with correct branch name substitution
env_file = git_repo / ".sprout" / branch_name / ".env"
env_content = env_file.read_text()

# Check branch name substitutions
assert f"BRANCH_NAME={branch_name}" in env_content
assert f"SERVICE_NAME=myapp-{branch_name}" in env_content
assert "API_KEY=test_key" in env_content
assert "PORT=" in env_content # Should have a port number
assert "COMPOSE_VAR=${BRANCH_NAME}" in env_content # Docker syntax preserved

def test_error_cases(self, git_repo, monkeypatch, tmp_path):
"""Test various error conditions."""
git_repo, default_branch = git_repo
Expand Down
41 changes: 41 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,47 @@ def test_parse_env_template_preserves_docker_syntax(self, tmp_path):
result = parse_env_template(template)
assert result == "COMPOSE_VAR=${COMPOSE_VAR:-default}"

def test_parse_env_template_branch_placeholder(self, tmp_path):
"""Test parsing {{ branch() }} placeholders."""
template = tmp_path / ".env.example"
template.write_text("BRANCH_NAME={{ branch() }}\nFEATURE={{ branch() }}_feature")

result = parse_env_template(template, branch_name="feature-auth")
assert result == "BRANCH_NAME=feature-auth\nFEATURE=feature-auth_feature"

def test_parse_env_template_branch_placeholder_none(self, tmp_path):
"""Test {{ branch() }} placeholder when branch_name is None."""
template = tmp_path / ".env.example"
template.write_text("BRANCH={{ branch() }}")

# When branch_name is None, placeholder should remain unchanged
result = parse_env_template(template, branch_name=None)
assert result == "BRANCH={{ branch() }}"

def test_parse_env_template_mixed_placeholders(self, tmp_path, mocker):
"""Test parsing mixed placeholders in one template."""
mocker.patch("sprout.utils.find_available_port", return_value=8080)
mocker.patch.dict(os.environ, {"API_KEY": "secret"})
mocker.patch("sprout.utils.console.input", return_value="password123")

template = tmp_path / ".env.example"
template.write_text(
"API_KEY={{ API_KEY }}\n"
"PORT={{ auto_port() }}\n"
"BRANCH={{ branch() }}\n"
"DB_PASS={{ DB_PASS }}\n"
"COMPOSE_VAR=${COMPOSE_VAR}"
)

result = parse_env_template(template, branch_name="feature-xyz")
assert result == (
"API_KEY=secret\n"
"PORT=8080\n"
"BRANCH=feature-xyz\n"
"DB_PASS=password123\n"
"COMPOSE_VAR=${COMPOSE_VAR}"
)

def test_parse_env_template_file_not_found(self, tmp_path):
"""Test error when template file doesn't exist."""
template = tmp_path / "nonexistent.env"
Expand Down