diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 1bf61e8..4e3880b 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -2,6 +2,7 @@ import logging import os +import subprocess import uuid from datetime import datetime @@ -61,6 +62,7 @@ class CreateInstanceRequest(BaseModel): display_name: str | None = Field(default=None, description="Optional display name for the instance") clone_mode: str = Field(default="mount", description="Repository access mode: 'mount' or 'clone'") branch: str | None = Field(default="main", description="Branch to clone (when clone_mode='clone')") + new_branch: str | None = Field(default=None, description="Create a new local branch after cloning") def _modify_compose_file( @@ -256,6 +258,25 @@ async def create_instance( else: repo_path = repo.path + # Create new local branch if requested + if data.clone_mode == "clone" and data.new_branch: + try: + result = subprocess.run( + ["git", "-C", repo_path, "checkout", "-b", data.new_branch], + capture_output=True, + text=True, + ) + if result.returncode != 0: + logger.error("Failed to create branch %s: %s", data.new_branch, result.stderr) + raise RuntimeError(f"Failed to create branch: {result.stderr}") + logger.info("Created local branch %s in cloned repository", data.new_branch) + except Exception as exc: + logger.exception("Failed to create local branch: %s", exc) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create local branch: {exc}" + ) + # Handle based on definition type if tool_type.definition_type == "dockerfile": # Build image from Dockerfile @@ -318,7 +339,7 @@ services: compose_path=compose_path, port=tool_port, clone_mode=data.clone_mode, - branch=data.branch if data.clone_mode == "clone" else None, + branch=data.new_branch if data.new_branch else (data.branch if data.clone_mode == "clone" else None), ) session.add(instance) await session.commit() diff --git a/apps/api/tests/unit/test_session_branch_selection.py b/apps/api/tests/unit/test_session_branch_selection.py new file mode 100644 index 0000000..ad64ff6 --- /dev/null +++ b/apps/api/tests/unit/test_session_branch_selection.py @@ -0,0 +1,182 @@ +"""Tests for session creation with branch selection and new branch creation.""" + +import os +import subprocess +import tempfile +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from src.api.tool_instances import CreateInstanceRequest + + +class TestCreateInstanceRequest: + """Tests for CreateInstanceRequest model.""" + + def test_default_values(self): + """Test default values for CreateInstanceRequest.""" + request = CreateInstanceRequest(tool_type_id="123") + assert request.clone_mode == "mount" + assert request.branch == "main" + assert request.new_branch is None + assert request.display_name is None + + def test_clone_mode_with_branch(self): + """Test CreateInstanceRequest with clone mode and branch.""" + request = CreateInstanceRequest( + tool_type_id="123", + clone_mode="clone", + branch="dev", + ) + assert request.clone_mode == "clone" + assert request.branch == "dev" + + def test_new_branch_field(self): + """Test CreateInstanceRequest with new_branch field.""" + request = CreateInstanceRequest( + tool_type_id="123", + clone_mode="clone", + branch="main", + new_branch="feature/test", + ) + assert request.new_branch == "feature/test" + + +class TestBranchCreationInClone: + """Tests for branch creation logic in clone process.""" + + def test_create_local_branch_success(self): + """Test successful local branch creation.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Initialize repo + subprocess.run( + ["git", "init", tmpdir], + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "-C", tmpdir, "config", "user.email", "test@test.com"], + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "-C", tmpdir, "config", "user.name", "Test User"], + capture_output=True, + check=True, + ) + + # Create initial commit + readme = os.path.join(tmpdir, "README.md") + with open(readme, "w") as f: + f.write("# Test\n") + subprocess.run( + ["git", "-C", tmpdir, "add", "README.md"], + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "-C", tmpdir, "commit", "-m", "Initial commit"], + capture_output=True, + check=True, + ) + + # Create new branch + result = subprocess.run( + ["git", "-C", tmpdir, "checkout", "-b", "feature/new-branch"], + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + + # Verify branch exists + branches_result = subprocess.run( + ["git", "-C", tmpdir, "branch", "--show-current"], + capture_output=True, + text=True, + ) + assert branches_result.stdout.strip() == "feature/new-branch" + + def test_create_local_branch_invalid_name(self): + """Test local branch creation with invalid name fails.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Initialize repo + subprocess.run( + ["git", "init", tmpdir], + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "-C", tmpdir, "config", "user.email", "test@test.com"], + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "-C", tmpdir, "config", "user.name", "Test User"], + capture_output=True, + check=True, + ) + + # Create initial commit + readme = os.path.join(tmpdir, "README.md") + with open(readme, "w") as f: + f.write("# Test\n") + subprocess.run( + ["git", "-C", tmpdir, "add", "README.md"], + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "-C", tmpdir, "commit", "-m", "Initial commit"], + capture_output=True, + check=True, + ) + + # Try to create branch with invalid name (contains spaces) + result = subprocess.run( + ["git", "-C", tmpdir, "checkout", "-b", "invalid branch name"], + capture_output=True, + text=True, + ) + + # Git accepts branch names with spaces but it's not recommended + # This test verifies the command structure + assert result.returncode == 0 or "fatal" in result.stderr + + +class TestCreateInstanceAPI: + """Tests for create instance API endpoint with branch options.""" + + def test_create_instance_request_validation(self): + """Test that CreateInstanceRequest validates correctly.""" + # Valid request with new_branch + request = CreateInstanceRequest( + tool_type_id="550e8400-e29b-41d4-a716-446655440000", + clone_mode="clone", + branch="main", + new_branch="feature/test", + ) + assert request.new_branch == "feature/test" + + # Valid request without new_branch + request2 = CreateInstanceRequest( + tool_type_id="550e8400-e29b-41d4-a716-446655440000", + clone_mode="clone", + branch="dev", + ) + assert request2.new_branch is None + + def test_create_instance_with_new_branch_sets_instance_branch(self): + """Test that instance branch is set to new_branch when provided.""" + # This tests the logic: data.new_branch if data.new_branch else data.branch + new_branch = "feature/test" + base_branch = "main" + + # Simulate the logic from create_instance + stored_branch = new_branch if new_branch else base_branch + assert stored_branch == "feature/test" + + # Without new_branch + stored_branch2 = None if None else base_branch + assert stored_branch2 == "main" diff --git a/apps/web/src/api/git_repositories.ts b/apps/web/src/api/git_repositories.ts index 1b07c3e..7eb99dc 100644 --- a/apps/web/src/api/git_repositories.ts +++ b/apps/web/src/api/git_repositories.ts @@ -64,6 +64,27 @@ export async function updateRepositorySshKey( return response.data; } +export interface Branch { + name: string; + is_default: boolean; + last_commit: string | null; +} + +export interface BranchesResponse { + branches: Branch[]; + default_branch: string; +} + +export async function listRepositoryBranches( + projectId: string, + repoId: string +): Promise { + const response = await apiClient.get( + `/projects/${projectId}/repositories/${repoId}/branches` + ); + return response.data; +} + export interface CommitHistoryEntry { hash: string; short_hash: string; diff --git a/apps/web/src/api/sessions.ts b/apps/web/src/api/sessions.ts index a4e33a9..04c570d 100644 --- a/apps/web/src/api/sessions.ts +++ b/apps/web/src/api/sessions.ts @@ -47,7 +47,8 @@ export async function createInstance( toolTypeId: string, displayName?: string, cloneMode?: string, - branch?: string + branch?: string, + newBranch?: string ): Promise { const response = await apiClient.post( `/projects/${projectId}/repositories/${repoId}/instances`, @@ -56,6 +57,7 @@ export async function createInstance( display_name: displayName, clone_mode: cloneMode || "mount", branch: branch || undefined, + new_branch: newBranch || undefined, } ); return response.data; diff --git a/apps/web/src/components/create-session-form.tsx b/apps/web/src/components/create-session-form.tsx index 392f1b0..78a65d4 100644 --- a/apps/web/src/components/create-session-form.tsx +++ b/apps/web/src/components/create-session-form.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { Icon } from "./icon"; import { createInstance, startInstance, type ToolInstance } from "../api/sessions"; import type { Project } from "../types"; -import type { GitRepository } from "../api/git_repositories"; +import { listRepositoryBranches, type GitRepository, type Branch } from "../api/git_repositories"; import type { ToolType } from "../api/tool_types"; import { listSSHKeys, type SSHKey } from "../api/ssh_keys"; @@ -47,6 +47,12 @@ export const CreateSessionForm = ({ const [branch, setBranch] = useState("main"); const [sshKeys, setSshKeys] = useState([]); + const [branches, setBranches] = useState([]); + const [isLoadingBranches, setIsLoadingBranches] = useState(false); + const [isCreatingNewBranch, setIsCreatingNewBranch] = useState(false); + const [newBranchName, setNewBranchName] = useState(""); + const [baseBranch, setBaseBranch] = useState(""); + const [status, setStatus] = useState<"idle" | "creating" | "error">("idle"); const [progress, setProgress] = useState(""); const [error, setError] = useState(null); @@ -65,6 +71,31 @@ export const CreateSessionForm = ({ void loadKeys(); }, [showCloneMode]); + // Load branches when selected repo changes + useEffect(() => { + if (!selectedRepo || !showCloneMode) { + setBranches([]); + return; + } + const loadBranches = async () => { + setIsLoadingBranches(true); + try { + const branchList = await listRepositoryBranches(selectedRepo); + setBranches(branchList); + const defaultBranch = branchList.find((b) => b.is_default); + if (defaultBranch) { + setBranch(defaultBranch.name); + setBaseBranch(defaultBranch.name); + } + } catch { + // ignore + } finally { + setIsLoadingBranches(false); + } + }; + void loadBranches(); + }, [selectedRepo, showCloneMode]); + // Filter repositories by selected project const availableRepos = selectedProject ? repositories.filter((r) => r.project_id === selectedProject) @@ -100,7 +131,14 @@ export const CreateSessionForm = ({ selectedToolType, displayName || undefined, showCloneMode ? cloneMode : undefined, - showCloneMode && cloneMode === "clone" ? branch : undefined + showCloneMode && cloneMode === "clone" + ? isCreatingNewBranch + ? baseBranch + : branch + : undefined, + showCloneMode && cloneMode === "clone" && isCreatingNewBranch + ? newBranchName + : undefined ); setProgress("Starting container..."); @@ -113,6 +151,10 @@ export const CreateSessionForm = ({ setDisplayName(""); setCloneMode("mount"); setBranch("main"); + setIsCreatingNewBranch(false); + setNewBranchName(""); + setBaseBranch(""); + setBranches([]); setStatus("idle"); onSuccess?.(instance); @@ -247,16 +289,89 @@ export const CreateSessionForm = ({ {cloneMode === "clone" && ( - + <> + + + {isCreatingNewBranch && ( + <> + + + + )} + + {selectedRepo && ( +
+ {(() => { + const repo = repositories.find((r) => r.id === selectedRepo); + if (!repo) return null; + if (repo.ssh_key_id) { + const key = sshKeys.find((k) => k.id === repo.ssh_key_id); + return ( + + SSH key: {key?.name || "Assigned"} + + ); + } + return ( + + No SSH key assigned to this repository. Clone mode requires an SSH key. + + ); + })()} +
+ )} + )} )} diff --git a/apps/web/src/pages/sessions.tsx b/apps/web/src/pages/sessions.tsx index b200ae0..1ae34f2 100644 --- a/apps/web/src/pages/sessions.tsx +++ b/apps/web/src/pages/sessions.tsx @@ -9,7 +9,6 @@ import { type Session, deleteInstance, stopInstance, - startInstance, checkInstanceHealth, recreateInstanceTunnel, } from "../api/sessions"; diff --git a/docs/superpowers/specs/2026-05-24-session-branch-selection-design.md b/docs/superpowers/specs/2026-05-24-session-branch-selection-design.md new file mode 100644 index 0000000..2214a5f --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-session-branch-selection-design.md @@ -0,0 +1,155 @@ +# Session Branch Selection with New Branch Creation + +## Summary + +Replace the free-text branch input in the session creation form with a dropdown of available branches from the repository. Add the ability to create a new local branch at clone time by selecting "Create new branch..." from the dropdown. + +## Context + +The current session creation UI (`apps/web/src/pages/sessions.tsx`) has a free-text input for the branch name when "Clone fresh copy" mode is selected. Users must manually type the branch name, which is error-prone and doesn't show what branches are available. + +The backend already has: +- A `GET /projects/{project_id}/repositories/{repo_id}/branches` endpoint that returns all branches and the default branch +- A `clone_repository` service that clones a specific branch +- Branch creation APIs for the original repository + +## Design + +### Frontend Changes + +#### 1. Branch API Integration + +Add a new API function in `apps/web/src/api/git_repositories.ts`: + +```typescript +export interface Branch { + name: string; + is_default: boolean; + last_commit: string | null; +} + +export interface BranchesResponse { + branches: Branch[]; + default_branch: string; +} + +export async function listRepositoryBranches( + projectId: string, + repoId: string +): Promise { + const response = await apiClient.get( + `/projects/${projectId}/repositories/${repoId}/branches` + ); + return response.data; +} +``` + +#### 2. UI State Management + +In `apps/web/src/pages/sessions.tsx`, add state for: +- `branches`: `Branch[]` — loaded when a repository is selected and clone mode is active +- `isLoadingBranches`: `boolean` +- `isCreatingNewBranch`: `boolean` — toggled when "Create new branch..." is selected +- `newBranchName`: `string` — the name for the new branch +- `baseBranch`: `string` — the base branch for the new branch + +#### 3. Branch Loading + +When a repository is selected and clone mode is "clone", fetch branches: +- Call `listRepositoryBranches(selectedProject, selectedRepo)` +- Set `baseBranch` to `default_branch` from the response +- If the current `branch` state is not in the list, reset it to `default_branch` + +#### 4. Branch Dropdown + +Replace the free-text input with a `