You need to sign in before continuing.
project_scaffold.py 4.33 KB
"""c_project_create (03-C §3.0): register/scaffold a target project.

U-002 scope: the directory-scaffolding + DB-persistence half of c_project_create --
no HTTP endpoint yet (that's U-007); this is the plain function U-007 will wire up.

Target-project directory convention is architecture doc §13.2, NOT §7.3 (see
development-plan.md's 2026-07-31 correction: §7.3 describes graphlemon's own
platform repo, not a target project). c_project_create only ever creates
`.graphlemon/` inside the target folder; a target project's own `src/` and
everything else in it is left untouched.
"""

import os
import subprocess
import uuid

from src import agent_profile

_GIT_IDENTITY = [
    "-c", "user.name=graphlemon",
    "-c", "user.email=graphlemon@localhost",
]


class PathNotFoundError(Exception):
    """PATH_NOT_FOUND -- 03-C §3.0: target_folder_path does not exist."""


class PathNotWritableError(Exception):
    """PATH_NOT_WRITABLE -- 03-C §3.0: no write permission on target_folder_path."""


class GitInitDeclinedError(Exception):
    """GIT_INIT_DECLINED -- 03-C §3.0: not a git repo and caller didn't ask to init one.

    02-V §2.1: the UI must get explicit user confirmation before setting
    init_git_if_missing=True; this function never silently runs `git init`.
    """


def _run_git(args, cwd):
    subprocess.run(["git"] + args, cwd=cwd, check=True, capture_output=True, text=True)


def create_project(conn, target_folder_path, name=None, init_git_if_missing=False):
    """Implements c_project_create (03-C §3.0).

    Returns {"project_id", "has_git_repo", "graphlemon_subdir_initialized"}.
    Idempotent: a second call with the same target_folder_path returns the
    existing row untouched (01-M 1.0 幂等约束) -- no re-validation, no repeat
    side effects, matching REQ-11's failure-case wording.
    """
    existing = conn.execute(
        "SELECT project_id, has_git_repo, graphlemon_subdir_initialized "
        "FROM project WHERE target_folder_path = ?",
        (target_folder_path,),
    ).fetchone()
    if existing is not None:
        return {
            "project_id": existing[0],
            "has_git_repo": bool(existing[1]),
            "graphlemon_subdir_initialized": bool(existing[2]),
        }

    if not os.path.isdir(target_folder_path):
        raise PathNotFoundError(target_folder_path)
    if not os.access(target_folder_path, os.W_OK):
        raise PathNotWritableError(target_folder_path)

    has_git_repo = os.path.isdir(os.path.join(target_folder_path, ".git"))
    if not has_git_repo:
        if not init_git_if_missing:
            raise GitInitDeclinedError(target_folder_path)
        _run_git(["init"], cwd=target_folder_path)
        has_git_repo = True

    graphlemon_subdir = os.path.join(target_folder_path, ".graphlemon")
    os.makedirs(graphlemon_subdir, exist_ok=True)
    # git doesn't track empty directories; this is only so the init commit has
    # something under .graphlemon/ to record. Real content (runs/<task_id>/...)
    # arrives once tasks actually execute (01-M 1.5 artifact.file_path).
    open(os.path.join(graphlemon_subdir, ".gitkeep"), "w").close()

    # Without this, the first task ever triggered on this project fails
    # immediately: `claude --agent planner` has nothing to find in this cwd
    # (org_node_trigger.py's own docstring already says the caller is
    # responsible for deploying this first, but until now nothing did --
    # self-target usage never noticed because graphlemon's own .claude/agents/
    # is already committed as part of its own repo).
    agent_profile.deploy_agent_definitions(target_folder_path)

    _run_git(["add", ".graphlemon", ".claude"], cwd=target_folder_path)
    _run_git(
        _GIT_IDENTITY + ["commit", "-m", "graphlemon: initialize .graphlemon/ workspace + deploy agent definitions"],
        cwd=target_folder_path,
    )

    project_id = str(uuid.uuid4())
    project_name = name or os.path.basename(os.path.normpath(target_folder_path))
    conn.execute(
        "INSERT INTO project "
        "(project_id, name, target_folder_path, has_git_repo, graphlemon_subdir_initialized, status) "
        "VALUES (?, ?, ?, ?, 1, 'active')",
        (project_id, project_name, target_folder_path, int(has_git_repo)),
    )
    conn.commit()

    return {
        "project_id": project_id,
        "has_git_repo": has_git_repo,
        "graphlemon_subdir_initialized": True,
    }