feat: workspace-first UI refresh - PR-1 backend endpoints
- Add FileService for workspace-scoped file operations
- Add GitOperations service for workspace-scoped git commands
- Add workspace_files API: GET/POST /workspaces/{id}/files
- Add workspace_git API: status, branches, commit, push, pull, fetch, checkout, history
- Add workspace_instances API: list instances per workspace
- Add top-level POST /workspaces/ (accepts repo_id directly)
- Enrich GET /projects/ with nested repositories and workspaces
- Register all new routers in main.py
- 23 tests passing (17 existing + 6 new)
Quality gates: ruff clean
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
"""File operations scoped to a workspace directory."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.models.workspace import Workspace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileEntry:
|
||||
"""A single file or directory entry."""
|
||||
|
||||
name: str
|
||||
path: str
|
||||
type: str # "file" or "directory"
|
||||
size: int | None = None
|
||||
|
||||
|
||||
class FileService:
|
||||
"""Read and write files within a workspace directory."""
|
||||
|
||||
def list_directory(
|
||||
self,
|
||||
workspace: Workspace,
|
||||
relative_path: str = "",
|
||||
) -> list[FileEntry]:
|
||||
"""List entries in a workspace directory.
|
||||
|
||||
Args:
|
||||
workspace: The workspace to list files in.
|
||||
relative_path: Path relative to workspace root.
|
||||
|
||||
Returns:
|
||||
List of file entries sorted by name (directories first).
|
||||
"""
|
||||
abs_path = os.path.join(workspace.path, relative_path)
|
||||
abs_path = os.path.normpath(abs_path)
|
||||
|
||||
# Security: ensure we stay within workspace
|
||||
if not abs_path.startswith(os.path.normpath(workspace.path)):
|
||||
raise ValueError("Path escapes workspace directory")
|
||||
|
||||
if not os.path.exists(abs_path):
|
||||
return []
|
||||
|
||||
entries = []
|
||||
for item in sorted(os.listdir(abs_path)):
|
||||
full = os.path.join(abs_path, item)
|
||||
rel = os.path.join(relative_path, item) if relative_path else item
|
||||
is_dir = os.path.isdir(full)
|
||||
size = os.path.getsize(full) if os.path.isfile(full) else None
|
||||
entries.append(
|
||||
FileEntry(
|
||||
name=item,
|
||||
path=rel.replace("\\", "/"),
|
||||
type="directory" if is_dir else "file",
|
||||
size=size,
|
||||
)
|
||||
)
|
||||
|
||||
# Directories first, then files, both alphabetical
|
||||
entries.sort(key=lambda e: (0 if e.type == "directory" else 1, e.name.lower()))
|
||||
return entries
|
||||
|
||||
def read_file(self, workspace: Workspace, relative_path: str) -> str:
|
||||
"""Read a text file from the workspace.
|
||||
|
||||
Args:
|
||||
workspace: The workspace to read from.
|
||||
relative_path: Path relative to workspace root.
|
||||
|
||||
Returns:
|
||||
File contents as string.
|
||||
|
||||
Raises:
|
||||
ValueError: If path escapes workspace or file is binary.
|
||||
FileNotFoundError: If file does not exist.
|
||||
"""
|
||||
abs_path = self._resolve_path(workspace, relative_path)
|
||||
|
||||
if not os.path.isfile(abs_path):
|
||||
raise FileNotFoundError(f"Not a file: {relative_path}")
|
||||
|
||||
# Basic binary check — read first 8KB and look for null bytes
|
||||
with open(abs_path, "rb") as f:
|
||||
chunk = f.read(8192)
|
||||
if b"\x00" in chunk:
|
||||
raise ValueError("Binary files cannot be viewed")
|
||||
|
||||
with open(abs_path, encoding="utf-8", errors="replace") as f:
|
||||
return f.read()
|
||||
|
||||
def write_file(
|
||||
self,
|
||||
workspace: Workspace,
|
||||
relative_path: str,
|
||||
content: str,
|
||||
) -> None:
|
||||
"""Write a text file to the workspace.
|
||||
|
||||
Args:
|
||||
workspace: The workspace to write to.
|
||||
relative_path: Path relative to workspace root.
|
||||
content: File contents.
|
||||
|
||||
Raises:
|
||||
ValueError: If path escapes workspace.
|
||||
"""
|
||||
abs_path = self._resolve_path(workspace, relative_path)
|
||||
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||
|
||||
with open(abs_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
logger.info("Wrote file %s in workspace %s", relative_path, workspace.id)
|
||||
|
||||
def _resolve_path(self, workspace: Workspace, relative_path: str) -> str:
|
||||
"""Resolve a relative path to absolute, with security check."""
|
||||
abs_path = os.path.normpath(os.path.join(workspace.path, relative_path))
|
||||
workspace_root = os.path.normpath(workspace.path)
|
||||
|
||||
if not abs_path.startswith(workspace_root):
|
||||
raise ValueError("Path escapes workspace directory")
|
||||
|
||||
return abs_path
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Git commands scoped to a workspace directory."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.models.workspace import Workspace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitStatus:
|
||||
"""Parsed git status output."""
|
||||
|
||||
branch: str
|
||||
modified: list[str]
|
||||
added: list[str]
|
||||
deleted: list[str]
|
||||
untracked: list[str]
|
||||
ahead: int = 0
|
||||
behind: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Commit:
|
||||
"""A single git commit."""
|
||||
|
||||
hash: str
|
||||
message: str
|
||||
author: str
|
||||
date: str
|
||||
|
||||
|
||||
class GitOperations:
|
||||
"""Run git commands within a workspace directory."""
|
||||
|
||||
def __init__(self, workspace: Workspace) -> None:
|
||||
self.cwd = workspace.path
|
||||
self.branch = workspace.branch
|
||||
|
||||
async def _run(self, *cmd: str) -> tuple[int, str, str]:
|
||||
"""Run a git command and return (returncode, stdout, stderr)."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
return proc.returncode or 0, stdout.decode(), stderr.decode()
|
||||
|
||||
async def status(self) -> GitStatus:
|
||||
"""Get git status for the workspace."""
|
||||
returncode, stdout, _ = await self._run(
|
||||
"git", "-C", self.cwd, "status", "--porcelain", "-b"
|
||||
)
|
||||
|
||||
modified: list[str] = []
|
||||
added: list[str] = []
|
||||
deleted: list[str] = []
|
||||
untracked: list[str] = []
|
||||
branch = self.branch
|
||||
ahead = 0
|
||||
behind = 0
|
||||
|
||||
for line in stdout.splitlines():
|
||||
if line.startswith("##"):
|
||||
# Branch info line
|
||||
branch_info = line[3:].strip()
|
||||
if "..." in branch_info:
|
||||
branch = branch_info.split("...")[0]
|
||||
if "[ahead " in branch_info:
|
||||
ahead_str = branch_info.split("[ahead ")[1].split("]")[0]
|
||||
ahead = int(ahead_str.split(",")[0])
|
||||
if "[behind " in branch_info:
|
||||
behind_str = branch_info.split("[behind ")[1].split("]")[0]
|
||||
behind = int(behind_str.split(",")[0])
|
||||
else:
|
||||
branch = branch_info
|
||||
continue
|
||||
|
||||
if len(line) < 3:
|
||||
continue
|
||||
|
||||
status_code = line[:2]
|
||||
file_path = line[3:]
|
||||
|
||||
# XY format: X = index status, Y = working tree status
|
||||
if status_code == "??":
|
||||
untracked.append(file_path)
|
||||
elif status_code[1] == "D" or status_code[0] == "D":
|
||||
deleted.append(file_path)
|
||||
elif status_code[0] == "A" or status_code[1] == "A":
|
||||
added.append(file_path)
|
||||
else:
|
||||
modified.append(file_path)
|
||||
|
||||
return GitStatus(
|
||||
branch=branch,
|
||||
modified=modified,
|
||||
added=added,
|
||||
deleted=deleted,
|
||||
untracked=untracked,
|
||||
ahead=ahead,
|
||||
behind=behind,
|
||||
)
|
||||
|
||||
async def commit(self, message: str) -> None:
|
||||
"""Stage all changes and commit."""
|
||||
rc, _, err = await self._run("git", "-C", self.cwd, "add", "-A")
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git add failed: {err}")
|
||||
|
||||
rc, _, err = await self._run(
|
||||
"git", "-C", self.cwd, "commit", "-m", message
|
||||
)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git commit failed: {err}")
|
||||
|
||||
logger.info("Committed in workspace: %s", self.cwd)
|
||||
|
||||
async def push(self) -> None:
|
||||
"""Push current branch to origin."""
|
||||
rc, _, err = await self._run(
|
||||
"git", "-C", self.cwd, "push", "origin", self.branch
|
||||
)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git push failed: {err}")
|
||||
|
||||
logger.info("Pushed branch %s from workspace: %s", self.branch, self.cwd)
|
||||
|
||||
async def pull(self) -> None:
|
||||
"""Pull current branch from origin."""
|
||||
rc, _, err = await self._run(
|
||||
"git", "-C", self.cwd, "pull", "origin", self.branch
|
||||
)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git pull failed: {err}")
|
||||
|
||||
logger.info("Pulled branch %s in workspace: %s", self.branch, self.cwd)
|
||||
|
||||
async def fetch(self) -> None:
|
||||
"""Fetch from origin."""
|
||||
rc, _, err = await self._run("git", "-C", self.cwd, "fetch", "origin")
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git fetch failed: {err}")
|
||||
|
||||
logger.info("Fetched origin for workspace: %s", self.cwd)
|
||||
|
||||
async def checkout(self, branch: str) -> None:
|
||||
"""Checkout a branch."""
|
||||
rc, _, err = await self._run("git", "-C", self.cwd, "checkout", branch)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git checkout failed: {err}")
|
||||
|
||||
self.branch = branch
|
||||
logger.info("Checked out branch %s in workspace: %s", branch, self.cwd)
|
||||
|
||||
async def history(self, path: str | None = None, limit: int = 50) -> list[Commit]:
|
||||
"""Get commit history.
|
||||
|
||||
Args:
|
||||
path: Optional file path to filter history.
|
||||
limit: Maximum number of commits.
|
||||
|
||||
Returns:
|
||||
List of commits.
|
||||
"""
|
||||
cmd = [
|
||||
"git",
|
||||
"-C",
|
||||
self.cwd,
|
||||
"log",
|
||||
f"--max-count={limit}",
|
||||
"--pretty=format:%H|%s|%an|%ad",
|
||||
"--date=iso",
|
||||
]
|
||||
if path:
|
||||
cmd.extend(["--", path])
|
||||
|
||||
rc, stdout, err = await self._run(*cmd)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git log failed: {err}")
|
||||
|
||||
commits = []
|
||||
for line in stdout.strip().splitlines():
|
||||
parts = line.split("|", 3)
|
||||
if len(parts) >= 4:
|
||||
commits.append(
|
||||
Commit(
|
||||
hash=parts[0],
|
||||
message=parts[1],
|
||||
author=parts[2],
|
||||
date=parts[3],
|
||||
)
|
||||
)
|
||||
|
||||
return commits
|
||||
|
||||
async def branches(self) -> tuple[list[str], str]:
|
||||
"""List all branches and current branch.
|
||||
|
||||
Returns:
|
||||
Tuple of (all_branches, current_branch).
|
||||
"""
|
||||
rc, stdout, err = await self._run(
|
||||
"git", "-C", self.cwd, "branch", "-a", "--format=%(refname:short)"
|
||||
)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git branch failed: {err}")
|
||||
|
||||
branches = []
|
||||
current = self.branch
|
||||
for line in stdout.strip().splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("HEAD") or line.endswith("/HEAD"):
|
||||
continue
|
||||
if line.startswith("remotes/origin/"):
|
||||
branch_name = line.replace("remotes/origin/", "")
|
||||
if branch_name not in branches:
|
||||
branches.append(branch_name)
|
||||
elif line and line not in branches:
|
||||
branches.append(line)
|
||||
|
||||
return branches, current
|
||||
Reference in New Issue
Block a user