refactor: store workspaces as {workspace_id}/{repo_name} for natural git clone layout

Working copies were stored as /data/working-copies/{repo_id}/{workspace_name}/,
so git clone was forced into a user-named directory. That meant the container
mount basename was the workspace name (e.g. main) instead of the repo name.

- Generate the workspace UUID before cloning and clone into
  /data/working-copies/{workspace_id}/ so git creates {repo_name}/ naturally
- Set workspace.path to /data/working-copies/{workspace_id}/{repo_name}/
- Update _migrate_clone_into_workspace() to use the same layout
- _get_repository_mount_name() now prefers workspace.path basename and only
  falls back to remote URL / repo.name for legacy repo-only instances
- Update unit tests to assert workspace path basename is used for mounts

Quality gates:
- pytest tests/unit: 219 passed
- ruff: clean on changed files
- mypy: clean on changed files
This commit is contained in:
Developer
2026-06-15 09:40:59 +00:00
parent 6e33e8e4e9
commit b26ed7c3e4
27 changed files with 176 additions and 87 deletions
+25 -11
View File
@@ -20,6 +20,7 @@ from src.models import (
SSHKey,
ToolInstance,
ToolType,
Workspace,
)
from src.schemas.tool import CreateInstanceRequest, StartInstanceRequest
from src.services.git.clone import check_dirty_state, clone_repository
@@ -79,13 +80,21 @@ logger = logging.getLogger(__name__)
_event_bus = InstanceEventBus()
def _get_repository_mount_name(repo: GitRepository) -> str:
"""Return the directory name a standard git clone would create.
def _get_repository_mount_name(
repo: GitRepository,
workspace: "Workspace | None" = None,
) -> str:
"""Return the directory name the repository should appear under in the container.
Prefers the repository name parsed from the remote URL so the container
mount matches what users expect from ``git clone``. Falls back to the
user-provided repository name when no remote URL is available.
When a workspace exists, the on-disk layout is
``/data/working-copies/{workspace_id}/{repo_name}/``, so the repo-named
directory is already available as the basename of ``workspace.path``. For
legacy repo-only instances we fall back to parsing the remote URL like a
standard ``git clone`` would, then to the user-provided repository name.
"""
if workspace is not None and workspace.path:
return os.path.basename(os.path.normpath(workspace.path))
if repo.remote_url:
base_url = extract_base_repo_url(repo.remote_url) or repo.remote_url
name = base_url.rstrip("/").split("/")[-1]
@@ -907,10 +916,14 @@ async def prepare_manifest_instance(
# Use the repository name for the workspace/repo mount target, not the
# directory name of a workspace/clone path (which may be "main" or similar).
# Prefer the name parsed from the remote URL so it matches a standard clone.
# Prefer the actual on-disk workspace directory name when a workspace is
# mounted, otherwise fall back to parsing the remote URL like git clone.
repo = await session.get(GitRepository, instance.repository_id)
workspace: Workspace | None = None
if instance.workspace_id:
workspace = await session.get(Workspace, instance.workspace_id)
repo_name = (
_get_repository_mount_name(repo)
_get_repository_mount_name(repo, workspace)
if repo
else os.path.basename(os.path.normpath(repo_path))
)
@@ -1075,7 +1088,7 @@ async def create_tool_instance(
)
home_dir = tool_type.home_directory or "/home/user"
mount_name = _get_repository_mount_name(repo)
mount_name = _get_repository_mount_name(repo, workspace)
workspace_target = f"{home_dir}/{mount_name}"
compose_content = f"""version: "3.8"\nservices:
@@ -1112,14 +1125,15 @@ async def create_tool_instance(
# Manifest templates use WORKSPACE_PATH; REPO_PATH is retained as a
# deprecated alias for backward compatibility with older templates.
mount_name = _get_repository_mount_name(repo, workspace)
variables = {
"IMAGE_TAG": image_tag,
"INSTANCE_NAME": instance_name.lower(),
"INSTANCE_DIR": instance_dir,
"WORKSPACE_PATH": repo_path,
"REPO_PATH": repo_path,
"REPO_NAME": _get_repository_mount_name(repo),
"WORKSPACE_NAME": _get_repository_mount_name(repo),
"REPO_NAME": mount_name,
"WORKSPACE_NAME": mount_name,
"SSH_PATH": "",
"TOOL_PORT": tool_port,
"EXTRA_ENV": {},
@@ -1140,7 +1154,7 @@ async def create_tool_instance(
"TOOL_PORT": tool_port,
"USER_ID": str(user_id),
"PROJECT_ID": str(project_id),
"WORKSPACE_NAME": _get_repository_mount_name(repo),
"WORKSPACE_NAME": _get_repository_mount_name(repo, workspace),
"HOME_DIRECTORY": tool_type.home_directory or "/home/user",
}
compose_content = render_compose_template(tool_type.compose_template, variables)