27c77af591
- Add workspace detail page (/workspaces/:id) with 4 tabs: - Files: file tree, viewer, editor, git toolbar (commit/push/pull/fetch) - Git: branch selector, commit history - Tools: instance grid, start tool modal - Settings: workspace info read-only - Add workspace API clients: workspace-files, workspace-git, workspace-instances - Add hooks: useWorkspaceFiles, useWorkspaceGit, useWorkspaceInstances - WorkspaceCard links to detail page via router Link - Add comprehensive CSS for workspace detail layout - Mobile: bottom tab bar, responsive file tree/split - TypeScript + eslint clean Quality gates: tsc --noEmit clean, eslint clean
224 lines
6.8 KiB
Python
224 lines
6.8 KiB
Python
"""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
|