Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
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")
|
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'")
|
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')")
|
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(
|
def _modify_compose_file(
|
||||||
@@ -256,6 +258,25 @@ async def create_instance(
|
|||||||
else:
|
else:
|
||||||
repo_path = repo.path
|
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
|
# Handle based on definition type
|
||||||
if tool_type.definition_type == "dockerfile":
|
if tool_type.definition_type == "dockerfile":
|
||||||
# Build image from Dockerfile
|
# Build image from Dockerfile
|
||||||
@@ -318,7 +339,7 @@ services:
|
|||||||
compose_path=compose_path,
|
compose_path=compose_path,
|
||||||
port=tool_port,
|
port=tool_port,
|
||||||
clone_mode=data.clone_mode,
|
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)
|
session.add(instance)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -64,6 +64,27 @@ export async function updateRepositorySshKey(
|
|||||||
return response.data;
|
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<BranchesResponse> {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/branches`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CommitHistoryEntry {
|
export interface CommitHistoryEntry {
|
||||||
hash: string;
|
hash: string;
|
||||||
short_hash: string;
|
short_hash: string;
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ export async function createInstance(
|
|||||||
toolTypeId: string,
|
toolTypeId: string,
|
||||||
displayName?: string,
|
displayName?: string,
|
||||||
cloneMode?: string,
|
cloneMode?: string,
|
||||||
branch?: string
|
branch?: string,
|
||||||
|
newBranch?: string
|
||||||
): Promise<ToolInstance> {
|
): Promise<ToolInstance> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||||
@@ -56,6 +57,7 @@ export async function createInstance(
|
|||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
clone_mode: cloneMode || "mount",
|
clone_mode: cloneMode || "mount",
|
||||||
branch: branch || undefined,
|
branch: branch || undefined,
|
||||||
|
new_branch: newBranch || undefined,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
|
|||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import { createInstance, startInstance, type ToolInstance } from "../api/sessions";
|
import { createInstance, startInstance, type ToolInstance } from "../api/sessions";
|
||||||
import type { Project } from "../types";
|
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 type { ToolType } from "../api/tool_types";
|
||||||
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||||
|
|
||||||
@@ -47,6 +47,12 @@ export const CreateSessionForm = ({
|
|||||||
const [branch, setBranch] = useState("main");
|
const [branch, setBranch] = useState("main");
|
||||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||||
|
|
||||||
|
const [branches, setBranches] = useState<Branch[]>([]);
|
||||||
|
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 [status, setStatus] = useState<"idle" | "creating" | "error">("idle");
|
||||||
const [progress, setProgress] = useState("");
|
const [progress, setProgress] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -65,6 +71,31 @@ export const CreateSessionForm = ({
|
|||||||
void loadKeys();
|
void loadKeys();
|
||||||
}, [showCloneMode]);
|
}, [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
|
// Filter repositories by selected project
|
||||||
const availableRepos = selectedProject
|
const availableRepos = selectedProject
|
||||||
? repositories.filter((r) => r.project_id === selectedProject)
|
? repositories.filter((r) => r.project_id === selectedProject)
|
||||||
@@ -100,7 +131,14 @@ export const CreateSessionForm = ({
|
|||||||
selectedToolType,
|
selectedToolType,
|
||||||
displayName || undefined,
|
displayName || undefined,
|
||||||
showCloneMode ? cloneMode : 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...");
|
setProgress("Starting container...");
|
||||||
@@ -113,6 +151,10 @@ export const CreateSessionForm = ({
|
|||||||
setDisplayName("");
|
setDisplayName("");
|
||||||
setCloneMode("mount");
|
setCloneMode("mount");
|
||||||
setBranch("main");
|
setBranch("main");
|
||||||
|
setIsCreatingNewBranch(false);
|
||||||
|
setNewBranchName("");
|
||||||
|
setBaseBranch("");
|
||||||
|
setBranches([]);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
|
|
||||||
onSuccess?.(instance);
|
onSuccess?.(instance);
|
||||||
@@ -247,16 +289,89 @@ export const CreateSessionForm = ({
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
{cloneMode === "clone" && (
|
{cloneMode === "clone" && (
|
||||||
<label className="form-field">
|
<>
|
||||||
Branch
|
<label className="form-field">
|
||||||
<input
|
Branch
|
||||||
type="text"
|
{isLoadingBranches ? (
|
||||||
value={branch}
|
<span className="muted">Loading branches...</span>
|
||||||
onChange={(e) => setBranch(e.target.value)}
|
) : (
|
||||||
placeholder="main"
|
<select
|
||||||
disabled={isSubmitting}
|
value={isCreatingNewBranch ? "__new__" : branch}
|
||||||
/>
|
onChange={(e) => {
|
||||||
</label>
|
const value = e.target.value;
|
||||||
|
if (value === "__new__") {
|
||||||
|
setIsCreatingNewBranch(true);
|
||||||
|
setNewBranchName("");
|
||||||
|
} else {
|
||||||
|
setIsCreatingNewBranch(false);
|
||||||
|
setBranch(value);
|
||||||
|
setBaseBranch(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<option key={b.name} value={b.name}>
|
||||||
|
{b.name} {b.is_default ? "(default)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="__new__">Create new branch...</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{isCreatingNewBranch && (
|
||||||
|
<>
|
||||||
|
<label className="form-field">
|
||||||
|
New Branch Name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newBranchName}
|
||||||
|
onChange={(e) => setNewBranchName(e.target.value)}
|
||||||
|
placeholder="feature/my-new-branch"
|
||||||
|
required
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Base Branch
|
||||||
|
<select
|
||||||
|
value={baseBranch}
|
||||||
|
onChange={(e) => setBaseBranch(e.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<option key={b.name} value={b.name}>
|
||||||
|
{b.name} {b.is_default ? "(default)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedRepo && (
|
||||||
|
<div className="form-field ssh-key-info">
|
||||||
|
{(() => {
|
||||||
|
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 (
|
||||||
|
<span className="success-text">
|
||||||
|
SSH key: {key?.name || "Assigned"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="warning-text">
|
||||||
|
No SSH key assigned to this repository. Clone mode requires an SSH key.
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
type Session,
|
type Session,
|
||||||
deleteInstance,
|
deleteInstance,
|
||||||
stopInstance,
|
stopInstance,
|
||||||
startInstance,
|
|
||||||
checkInstanceHealth,
|
checkInstanceHealth,
|
||||||
recreateInstanceTunnel,
|
recreateInstanceTunnel,
|
||||||
} from "../api/sessions";
|
} from "../api/sessions";
|
||||||
|
|||||||
@@ -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<BranchesResponse> {
|
||||||
|
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 `<select>`:
|
||||||
|
- Options populated from `branches` state
|
||||||
|
- Default branch marked visually: `"main (default)"`
|
||||||
|
- Last option: `"Create new branch..."` (disabled separator style or as a real option)
|
||||||
|
- When selected, set `isCreatingNewBranch = true`
|
||||||
|
|
||||||
|
#### 5. New Branch Form
|
||||||
|
|
||||||
|
When `isCreatingNewBranch` is true, show:
|
||||||
|
- **New branch name** input (required, validated for valid git branch name)
|
||||||
|
- **Base branch** dropdown (populated from `branches`, defaulting to `default_branch`)
|
||||||
|
|
||||||
|
#### 6. Form Submission
|
||||||
|
|
||||||
|
Update `handleCreate` to handle new branch creation:
|
||||||
|
- If `isCreatingNewBranch` is true, pass `newBranchName` and `baseBranch` to the API
|
||||||
|
- The `branch` parameter sent to the API should be:
|
||||||
|
- `newBranchName` if creating a new branch
|
||||||
|
- The selected existing branch otherwise
|
||||||
|
|
||||||
|
### Backend Changes
|
||||||
|
|
||||||
|
#### 1. Update `CreateInstanceRequest`
|
||||||
|
|
||||||
|
In `apps/api/src/api/tool_instances.py`, extend the request model:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CreateInstanceRequest(BaseModel):
|
||||||
|
model_config = {"extra": "ignore"}
|
||||||
|
|
||||||
|
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
|
||||||
|
display_name: str | None = Field(default=None, description="Optional display name")
|
||||||
|
clone_mode: str = Field(default="mount", description="'mount' or 'clone'")
|
||||||
|
branch: str | None = Field(default="main", description="Branch to clone")
|
||||||
|
new_branch: str | None = Field(default=None, description="Create a new branch from 'branch' after clone")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Update Clone Logic
|
||||||
|
|
||||||
|
In `create_instance`, after cloning:
|
||||||
|
- If `data.new_branch` is provided:
|
||||||
|
1. Clone the `data.branch` (base branch) as usual
|
||||||
|
2. Run `git -C <clone_path> checkout -b <new_branch>` to create the local branch
|
||||||
|
3. Store `new_branch` in the `branch` field of the ToolInstance record
|
||||||
|
|
||||||
|
#### 3. Update `clone_repository` Service
|
||||||
|
|
||||||
|
No changes needed — it already clones a specific branch. The new branch creation happens after clone.
|
||||||
|
|
||||||
|
#### 4. Database Schema
|
||||||
|
|
||||||
|
No changes needed — the existing `branch` field on `ToolInstance` can store the new branch name.
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User selects repo + "Clone fresh copy"
|
||||||
|
→ Frontend fetches branches from GET /branches
|
||||||
|
→ User selects "Create new branch..."
|
||||||
|
→ User fills: newBranchName="feature-x", baseBranch="dev"
|
||||||
|
→ Frontend sends: { branch: "dev", new_branch: "feature-x", ... }
|
||||||
|
→ Backend clones "dev" branch
|
||||||
|
→ Backend runs: git checkout -b feature-x
|
||||||
|
→ Instance record stores branch="feature-x"
|
||||||
|
→ Container starts with the new branch checked out
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- **Branch fetch fails**: Show error, fallback to free-text input
|
||||||
|
- **Invalid branch name**: Frontend validation (regex for valid git branch names)
|
||||||
|
- **New branch creation fails**: Backend returns 400 with git error message
|
||||||
|
- **Branch already exists locally**: Backend handles gracefully (git checkout -b will fail if branch exists)
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
1. **Frontend**: Test branch dropdown loads correctly, "Create new branch" toggle works, form submission sends correct payload
|
||||||
|
2. **Backend**: Test instance creation with `new_branch` parameter, verify git command runs correctly
|
||||||
|
3. **Integration**: End-to-end test creating a session with a new branch
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
- `apps/web/src/api/git_repositories.ts` — Add `listRepositoryBranches` function
|
||||||
|
- `apps/web/src/pages/sessions.tsx` — Replace branch input with dropdown + new branch form
|
||||||
|
- `apps/api/src/api/tool_instances.py` — Extend `CreateInstanceRequest` and clone logic
|
||||||
|
- `apps/api/src/services/clone.py` — Add `create_local_branch` helper (optional)
|
||||||
|
|
||||||
|
## Trade-offs
|
||||||
|
|
||||||
|
- **Local branch only**: The new branch is created in the cloned workspace only, not pushed to the remote. This is intentional — it's a disposable work branch.
|
||||||
|
- **No branch deletion**: When the instance is deleted, the branch is lost with the clone. This matches the "disposable" mental model.
|
||||||
Reference in New Issue
Block a user