# retoor <retoor@molodetz.nl>
import asyncio
import logging
import shutil
from devplacepy.config import FORK_STAGING_DIR
from devplacepy import project_files
from devplacepy.content import create_content_item
from devplacepy.database import get_table, record_fork, delete_fork_relations
from devplacepy.services.jobs.base import JobService
from devplacepy.utils import XP_PROJECT
logger = logging.getLogger(__name__)
class ForkService(JobService):
kind = "fork"
title = "Fork"
description = (
"Copies a project into a brand-new project owned by the forking user off the request "
"path: it creates the destination project, duplicates the whole virtual filesystem, and "
"records the source-to-fork relation. The resulting project is permanent; only the job "
"tracking row is swept on retention."
)
def __init__(self):
super().__init__(name="fork", interval_seconds=2)
async def process(self, job: dict) -> dict:
payload = job["payload"]
source_uid = payload.get("source_project_uid", "")
forked_by_uid = payload.get("forked_by_uid", "")
title = (payload.get("title") or "").strip()
source = get_table("projects").find_one(uid=source_uid)
if not source:
raise ValueError(f"source project not found: {source_uid}")
user = get_table("users").find_one(uid=forked_by_uid)
if not user:
raise ValueError(f"forking user not found: {forked_by_uid}")
if not title:
raise ValueError("a destination title is required")
new_uid, new_slug = create_content_item(
"projects",
"project",
user,
{
"title": title,
"description": source.get("description") or "",
"release_date": source.get("release_date") or None,
"demo_date": source.get("demo_date") or None,
"project_type": source.get("project_type") or "software",
"platforms": source.get("platforms") or "",
"status": source.get("status") or "In Development",
"is_private": 1 if source.get("is_private") else 0,
"read_only": 0,
},
title,
XP_PROJECT,
"First Project",
source.get("description") or "",
None,
)
from devplacepy.services.audit import record as audit
try:
item_count = await asyncio.to_thread(
self._copy_files, source_uid, new_uid, user, job["uid"]
)
record_fork(source_uid, new_uid, forked_by_uid)
except Exception:
self._rollback(new_uid)
audit.record_system(
"project.fork.failed",
actor_kind="user",
actor_uid=forked_by_uid,
actor_username=user.get("username"),
result="failure",
target_type="project",
target_uid=source_uid,
target_label=source.get("title"),
summary=f"fork of project {source.get('title')} by {user.get('username')} failed",
links=[audit.source("project", source_uid, source.get("title")), audit.job(job["uid"])],
)
raise
audit.record_system(
"project.fork.complete",
actor_kind="user",
actor_uid=forked_by_uid,
actor_username=user.get("username"),
target_type="project",
target_uid=new_uid,
target_label=title,
metadata={"item_count": item_count},
summary=f"fork of project {source.get('title')} by {user.get('username')} completed as {title}",
links=[
audit.source("project", source_uid, source.get("title")),
audit.destination("project", new_uid, title),
audit.job(job["uid"]),
],
)
return {
"project_uid": new_uid,
"project_url": f"/projects/{new_slug}",
"source_project_uid": source_uid,
"item_count": item_count,
}
def _copy_files(
self, source_uid: str, new_uid: str, user: dict, job_uid: str
) -> int:
staging = FORK_STAGING_DIR / job_uid
try:
project_files.export_to_dir(source_uid, "", staging)
return project_files.import_from_dir(
new_uid, staging, user, skip_names=set()
)
finally:
shutil.rmtree(staging, ignore_errors=True)
def _rollback(self, new_uid: str) -> None:
try:
project_files.delete_all_project_files(new_uid)
delete_fork_relations(new_uid)
get_table("projects").delete(uid=new_uid)
except Exception:
logger.exception("fork rollback failed for %s", new_uid)
def cleanup(self, job: dict) -> None:
shutil.rmtree(FORK_STAGING_DIR / job["uid"], ignore_errors=True)