diff --git a/.serena/cache/python/document_symbols_cache_v23-06-25.pkl b/.serena/cache/python/document_symbols_cache_v23-06-25.pkl new file mode 100644 index 0000000..a7751ad Binary files /dev/null and b/.serena/cache/python/document_symbols_cache_v23-06-25.pkl differ diff --git a/.serena/memories/code_style_conventions.md b/.serena/memories/code_style_conventions.md new file mode 100644 index 0000000..44d0023 --- /dev/null +++ b/.serena/memories/code_style_conventions.md @@ -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) \ No newline at end of file diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md new file mode 100644 index 0000000..c13fb03 --- /dev/null +++ b/.serena/memories/project_overview.md @@ -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()` \ No newline at end of file diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md new file mode 100644 index 0000000..fbb14ac --- /dev/null +++ b/.serena/memories/suggested_commands.md @@ -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 ` - Create a new worktree +- `sprout ls` - List all worktrees +- `sprout rm ` - Remove a worktree +- `sprout path ` - 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 \ No newline at end of file diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..95708b4 --- /dev/null +++ b/.serena/project.yml @@ -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" diff --git a/CHANGELOG.md b/CHANGELOG.md index b9d8ae2..eb0e2fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Makefile b/Makefile index 01d5c8b..24ccf0f 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/README.md b/README.md index 3b96a49..ac8f6ec 100644 --- a/README.md +++ b/README.md @@ -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} @@ -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`) @@ -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` diff --git a/sample/monorepo/backend/.env.example b/sample/monorepo/backend/.env.example index 5bcce62..f68a64c 100644 --- a/sample/monorepo/backend/.env.example +++ b/sample/monorepo/backend/.env.example @@ -1,3 +1,4 @@ # Backend service JWT_SECRET={{ JWT_SECRET }} -API_PORT={{ auto_port() }} \ No newline at end of file +API_PORT={{ auto_port() }} +DOCKER_NETWORK={{ branch() }}-sprout-nw \ No newline at end of file diff --git a/sample/monorepo/frontend/.env.example b/sample/monorepo/frontend/.env.example index c03781c..03116be 100644 --- a/sample/monorepo/frontend/.env.example +++ b/sample/monorepo/frontend/.env.example @@ -1,3 +1,4 @@ # Frontend service REACT_APP_API_KEY={{ REACT_APP_API_KEY }} -FRONTEND_PORT={{ auto_port() }} \ No newline at end of file +FRONTEND_PORT={{ auto_port() }} +DOCKER_NETWORK={{ branch() }}-sprout-nw \ No newline at end of file diff --git a/src/sprout/commands/create.py b/src/sprout/commands/create.py index 5a01a91..9250987 100644 --- a/src/sprout/commands/create.py +++ b/src/sprout/commands/create.py @@ -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 diff --git a/src/sprout/utils.py b/src/sprout/utils.py index 156328d..bd01dc5 100644 --- a/src/sprout/utils.py +++ b/src/sprout/utils.py @@ -126,7 +126,10 @@ 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. @@ -134,6 +137,7 @@ def parse_env_template( 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}") @@ -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() @@ -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) diff --git a/tests/test_integration.py b/tests/test_integration.py index e5e5d13..d566215 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -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 diff --git a/tests/test_utils.py b/tests/test_utils.py index 9e393e5..f413d15 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -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"