feat: workspace backend foundation (PR-1)

- Add workspaces table migration (2026_06_01_add_workspaces)
- Create Workspace model with repo_id, user_id, branch, path, status
- Add workspace_id nullable FK to ToolInstance
- Create GitService for clone/fetch/pull/branch_exists_remotely
- Create WorkspaceManager for create/delete/sync lifecycle
- Create workspace CRUD API with 409 handling for duplicates and instances
- Wire workspace routes into FastAPI app
- 17 tests passing (8 unit + 9 integration), 1 skipped

Quality gates: ruff clean
This commit is contained in:
2026-05-31 23:02:45 +02:00
parent d2b6bba15c
commit d567225bf7
14 changed files with 2354 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
"""Git operations for workspace management."""
import asyncio
import logging
import subprocess
logger = logging.getLogger(__name__)
class GitService:
"""Low-level git operations for creating and syncing workspaces."""
@staticmethod
async def clone(remote_url: str, branch: str, path: str) -> None:
"""Clone a repository to the given path.
Args:
remote_url: The git remote URL.
branch: The branch to clone.
path: The destination path for the clone.
Raises:
RuntimeError: If the clone fails.
"""
cmd = [
"git",
"clone",
"--branch",
branch,
"--single-branch",
remote_url,
path,
]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
error_msg = stderr.decode().strip() if stderr else "unknown error"
logger.error("Git clone failed: %s", error_msg)
raise RuntimeError(f"Git clone failed: {error_msg}")
logger.debug("Cloned %s (branch: %s) to %s", remote_url, branch, path)
@staticmethod
async def fetch(path: str) -> None:
"""Fetch from origin.
Args:
path: The path to the local git repository.
Raises:
RuntimeError: If fetch fails.
"""
proc = await asyncio.create_subprocess_exec(
"git",
"-C",
path,
"fetch",
"origin",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
error_msg = stderr.decode().strip() if stderr else "unknown error"
logger.error("Git fetch failed: %s", error_msg)
raise RuntimeError(f"Git fetch failed: {error_msg}")
logger.debug("Fetched origin for %s", path)
@staticmethod
async def pull(path: str, branch: str) -> None:
"""Pull latest changes from origin.
Args:
path: The path to the local git repository.
branch: The branch to pull.
Raises:
RuntimeError: If pull fails.
"""
proc = await asyncio.create_subprocess_exec(
"git",
"-C",
path,
"pull",
"origin",
branch,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
error_msg = stderr.decode().strip() if stderr else "unknown error"
logger.error("Git pull failed: %s", error_msg)
raise RuntimeError(f"Git pull failed: {error_msg}")
logger.debug("Pulled origin/%s for %s", branch, path)
@staticmethod
def branch_exists_remotely(path: str, branch: str) -> bool:
"""Check if a branch exists on the remote.
Args:
path: The path to the local git repository.
branch: The branch name to check.
Returns:
True if the branch exists on origin, False otherwise.
"""
result = subprocess.run(
["git", "-C", path, "ls-remote", "--heads", "origin", branch],
capture_output=True,
text=True,
)
exists = result.returncode == 0 and result.stdout.strip() != ""
logger.debug("Branch %s exists on remote: %s", branch, exists)
return exists