Commit 5a49060364a6948ec560010cd491438347838fa6
1 parent
1a31a2ad
U-002: target-project .graphlemon/ scaffold (c_project_create)
Implements c_project_create (03-C §3.0) as a plain function for U-007 to wire up later: idempotent by target_folder_path, creates .graphlemon/ + one init commit in an existing git repo, PATH_NOT_FOUND/PATH_NOT_WRITABLE/ GIT_INIT_DECLINED per contract, never silently `git init`s (02-V §2.1). Also corrects a real SDD-vs-plan conflict found while scoping this Unit: development-plan.md's U-002 line cited architecture doc §7.3's directory tree (.claude/agents/, skills/, top-level runs/) as what to create inside a target project. But §13.1 (a later, confirmed decision) states those are graphlemon's own platform-repo assets (U-003 scope), and 01-M's own graphlemon_subdir_initialized field already points at §13.2, whose target- project tree is only .git/ + .graphlemon/ + the target's pre-existing src/. §7.3 predates the "target project is an independent code space" pivot and was superseded by it. Corrected development-plan.md, unit_matrix.json, CLAUDE.md, and 05-实施约束与依赖.md accordingly (user confirmed, 2026-07-31). 23 stdlib unittest cases (src/test_schema.py + new src/test_project_scaffold.py), all passing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
6 changed files
with
250 additions
and
6 deletions
CLAUDE.md
| @@ -115,7 +115,9 @@ directly; anything worth reusing has already been copied and adapted into this r | @@ -115,7 +115,9 @@ directly; anything worth reusing has already been copied and adapted into this r | ||
| 115 | - `supervisor/isolation/` — staged utility library (isolate/diff/apply), not yet wired to anything | 115 | - `supervisor/isolation/` — staged utility library (isolate/diff/apply), not yet wired to anything |
| 116 | - `spikes/` — one-off validation spikes (currently just A-1) | 116 | - `spikes/` — one-off validation spikes (currently just A-1) |
| 117 | - `src/` (created by U-001) — graphlemon's own backend source (schema, then U-007+ HTTP/WebSocket server). | 117 | - `src/` (created by U-001) — graphlemon's own backend source (schema, then U-007+ HTTP/WebSocket server). |
| 118 | - **Not the same `src/` U-002 generates inside a target project's own repo** — those are two different | ||
| 119 | - physical repos that can coexist on disk during PH5 self-target. See 05-实施约束与依赖.md §5.7. | 118 | + **Not the same `src/` a target project already has in its own repo** — a target project's `src/` is |
| 119 | + its own pre-existing source tree; graphlemon does not create or restructure it (see 05-实施约束与依赖.md | ||
| 120 | + §5.7 and architecture doc §13.2 — U-002 only creates `.graphlemon/` inside a target project, corrected | ||
| 121 | + 2026-07-31 after finding development-plan.md's U-002 line had cited the wrong architecture section). | ||
| 120 | - Everything else (`.claude/agents/`, `skills/`, `runs/`, per-target-project scaffolding) gets created by | 122 | - Everything else (`.claude/agents/`, `skills/`, `runs/`, per-target-project scaffolding) gets created by |
| 121 | U-002/U-003 — it doesn't exist yet, and that's expected at this stage. | 123 | U-002/U-003 — it doesn't exist yet, and that's expected at this stage. |
docs/development-plan.md
| @@ -48,9 +48,10 @@ | @@ -48,9 +48,10 @@ | ||
| 48 | - 覆盖:01-M 全部实体 | 48 | - 覆盖:01-M 全部实体 |
| 49 | - 源码位置:graphlemon 仓库根目录 `src/`(见 05 节 §5.7"源码目录约定",与 U-002 在目标项目里生成的 `src/` 是两回事) | 49 | - 源码位置:graphlemon 仓库根目录 `src/`(见 05 节 §5.7"源码目录约定",与 U-002 在目标项目里生成的 `src/` 是两回事) |
| 50 | 50 | ||
| 51 | -- **U-002 [Enabler]** 目标项目 Git 仓库目录骨架初始化逻辑(架构文档 §7.3 目录树:`.claude/agents/`、`.claude/agent-memory/`、`.claude/workflows/`、`skills/`、`runs/`、`src/`)+ `.graphlemon/` 子目录(架构文档 §13.2) | 51 | +- **U-002 [Enabler]** 目标项目 Git 仓库目录骨架初始化逻辑:只生成 `.graphlemon/` 子目录(含 `runs/<task_id>/`,架构文档 §13.2——01-M `project.graphlemon_subdir_initialized` 字段本身指向的就是这节,不是 §7.3) |
| 52 | + - **2026-07-31 订正**:development-plan.md 这行原先误引用了架构文档 §7.3 的旧目录树(`.claude/agents/`、`.claude/agent-memory/`、`.claude/workflows/`、`skills/`、顶层 `runs/`)。但 §13.1("已确认"决策,晚于 §7.3)明确写着这些是 graphlemon **平台自己仓库**的资产("graphlemon 平台自己的仓库只保留平台资产:Org/Work Graph 引擎、Skill 库、Agent 人设...不含任何目标项目的业务代码"),属于 U-003 范围,不是目标项目、不是 U-002。§7.3 是"graphlemon 只服务自己开发自己"阶段(自举模式)的旧设计,已被 §13"目标项目是独立代码空间"这个决策取代。目标项目自己的 `src/` 不需要 graphlemon 创建或规定结构("目标项目自己原有的源码目录,graphlemon 不规定具体结构,尊重目标项目原有约定")。用户确认,2026-07-31。 | ||
| 52 | - 依赖:U-001(`project.graphlemon_subdir_initialized` 字段需要有实体支撑) | 53 | - 依赖:U-001(`project.graphlemon_subdir_initialized` 字段需要有实体支撑) |
| 53 | - - Done:给定一个空 git 仓库,能生成完整目录骨架并产生一次初始化 commit;非 git 仓库时按 02-V 2.1 节的确认态流程走(不静默 `git init`) | 54 | + - Done:给定一个空 git 仓库,能生成 `.graphlemon/` 子目录骨架并产生一次初始化 commit;非 git 仓库时按 02-V 2.1 节的确认态流程走(不静默 `git init`) |
| 54 | - 覆盖:01-M `project`;03-C `c_project_create` | 55 | - 覆盖:01-M `project`;03-C `c_project_create` |
| 55 | 56 | ||
| 56 | - **U-003 [Enabler]** graphlemon 平台自身的仓库/运行时目录结构(与目标项目仓库物理分离;本地非 Git 的原始日志目录,见架构文档 §13.2) | 57 | - **U-003 [Enabler]** graphlemon 平台自身的仓库/运行时目录结构(与目标项目仓库物理分离;本地非 Git 的原始日志目录,见架构文档 §13.2) |
docs/sdd/05-实施约束与依赖.md
| @@ -68,7 +68,7 @@ | @@ -68,7 +68,7 @@ | ||
| 68 | - 前端是本地 Web 应用(浏览器页面),后端用 `http.server`/`socketserver` 等 stdlib 原生能力提供 HTTP API;WebSocket 若 stdlib 支持不足,实施时(U-007)需要验证并可退化为 SSE/长轮询,但仍在 stdlib 范围内解决,不引入第三方包;前端不引入 npm/构建步骤,可以是原生 HTML/CSS/JS 或参考 graphloop `graph.html`/`project.html` 的单文件 no-build 页面模式 | 68 | - 前端是本地 Web 应用(浏览器页面),后端用 `http.server`/`socketserver` 等 stdlib 原生能力提供 HTTP API;WebSocket 若 stdlib 支持不足,实施时(U-007)需要验证并可退化为 SSE/长轮询,但仍在 stdlib 范围内解决,不引入第三方包;前端不引入 npm/构建步骤,可以是原生 HTML/CSS/JS 或参考 graphloop `graph.html`/`project.html` 的单文件 no-build 页面模式 |
| 69 | - **例外**:这条约束只针对 graphlemon 平台自身的实现语言,不代表 graphlemon 生成/审查的目标项目代码也必须是 Python——目标项目用什么语言由目标项目自己的技术栈决定 | 69 | - **例外**:这条约束只针对 graphlemon 平台自身的实现语言,不代表 graphlemon 生成/审查的目标项目代码也必须是 Python——目标项目用什么语言由目标项目自己的技术栈决定 |
| 70 | - **影响**:development-plan.md 的 U-007(本地后端骨架)、U-008(前端骨架)、U-036(Deploy Supervisor 骨架)的 Done 标准隐含"纯 stdlib"这条约束 | 70 | - **影响**:development-plan.md 的 U-007(本地后端骨架)、U-008(前端骨架)、U-036(Deploy Supervisor 骨架)的 Done 标准隐含"纯 stdlib"这条约束 |
| 71 | -- **源码目录约定(2026-07-30 补充)**:graphlemon 平台自身的后端源码(U-001 的 schema 实现、U-007 起的 HTTP/WebSocket 后端)落在 graphlemon 仓库根目录的 `src/`。这与 U-002 在**目标项目**仓库内生成的 `src/` 是两个完全不同的目录(分属两个物理仓库,见架构文档 §13.1"目标项目是独立代码空间"决策)——PH5 self-target 场景下二者会同时存在于同一台机器上,实现时不要混淆,必要时以绝对路径而非相对路径区分 | 71 | +- **源码目录约定(2026-07-30 补充,2026-07-31 订正)**:graphlemon 平台自身的后端源码(U-001 的 schema 实现、U-007 起的 HTTP/WebSocket 后端)落在 graphlemon 仓库根目录的 `src/`。这与**目标项目**仓库自己已有的 `src/` 是两个完全不同的目录,分属两个物理仓库(见架构文档 §13.1"目标项目是独立代码空间"决策)——PH5 self-target 场景下二者会同时存在于同一台机器上,实现时不要混淆,必要时以绝对路径而非相对路径区分。**订正**:目标项目的 `src/` 不是 U-002 生成的——U-002 只在目标项目里创建 `.graphlemon/` 子目录(架构文档 §13.2),目标项目自己的 `src/` 是其原有源码目录,graphlemon 不创建也不规定其结构;development-plan.md 此前 U-002 那行误引用了架构文档 §7.3 的旧目录树,已于 2026-07-31 订正(§7.3 是"graphlemon 只服务自己开发自己"阶段的旧设计,已被 §13 取代,§7.3 里的 `.claude/agents/`、`skills/`、顶层 `runs/` 实际是 graphlemon 平台自己仓库的资产,属于 U-003 范围) |
| 72 | 72 | ||
| 73 | ## 5.8 self-target 场景治理规则(`self-development-feasibility.md` 缺口 1/3,2026-07-30 回填) | 73 | ## 5.8 self-target 场景治理规则(`self-development-feasibility.md` 缺口 1/3,2026-07-30 回填) |
| 74 | 74 |
docs/sdd/unit_matrix.json
| @@ -12,7 +12,7 @@ | @@ -12,7 +12,7 @@ | ||
| 12 | }, | 12 | }, |
| 13 | "units": [ | 13 | "units": [ |
| 14 | {"id": "U-001", "phase": "PH0", "sub_phase": "PH0-S1", "name": "01-M 全部 15 实体物理 schema", "classification": "Enabler", "depends_on": [], "coverage": {"model": ["project", "org_graph_definition", "task", "work_graph_run", "work_node_execution", "artifact", "bug_record", "skill", "skill_iteration", "agent_profile", "budget_guardrail_config", "org_concurrency_config", "execution_event", "execution_log_file", "log_retention_config"], "view": [], "controller": [], "process": [], "requirement": []}, "done": "15 张表全部建表成功,关键约束(唯一/外键/枚举)有单测覆盖"}, | 14 | {"id": "U-001", "phase": "PH0", "sub_phase": "PH0-S1", "name": "01-M 全部 15 实体物理 schema", "classification": "Enabler", "depends_on": [], "coverage": {"model": ["project", "org_graph_definition", "task", "work_graph_run", "work_node_execution", "artifact", "bug_record", "skill", "skill_iteration", "agent_profile", "budget_guardrail_config", "org_concurrency_config", "execution_event", "execution_log_file", "log_retention_config"], "view": [], "controller": [], "process": [], "requirement": []}, "done": "15 张表全部建表成功,关键约束(唯一/外键/枚举)有单测覆盖"}, |
| 15 | - {"id": "U-002", "phase": "PH0", "sub_phase": "PH0-S1", "name": "目标项目 Git 仓库目录骨架初始化", "classification": "Enabler", "depends_on": ["U-001"], "coverage": {"model": ["project"], "view": [], "controller": ["c_project_create"], "process": [], "requirement": []}, "done": "给定空 git 仓库,能生成完整目录骨架并产生初始化 commit"}, | 15 | + {"id": "U-002", "phase": "PH0", "sub_phase": "PH0-S1", "name": "目标项目 Git 仓库目录骨架初始化(仅 .graphlemon/,见架构文档 §13.2;2026-07-31 订正,此前误引用 §7.3)", "classification": "Enabler", "depends_on": ["U-001"], "coverage": {"model": ["project"], "view": [], "controller": ["c_project_create"], "process": [], "requirement": []}, "done": "给定空 git 仓库,能生成 .graphlemon/ 子目录骨架并产生初始化 commit"}, |
| 16 | {"id": "U-003", "phase": "PH0", "sub_phase": "PH0-S1", "name": "graphlemon 平台自身仓库/运行时目录结构", "classification": "Enabler", "depends_on": [], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "平台目录与目标项目目录物理分离,互不干扰"}, | 16 | {"id": "U-003", "phase": "PH0", "sub_phase": "PH0-S1", "name": "graphlemon 平台自身仓库/运行时目录结构", "classification": "Enabler", "depends_on": [], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "平台目录与目标项目目录物理分离,互不干扰"}, |
| 17 | {"id": "U-004", "phase": "PH0", "sub_phase": "PH0-S2", "name": "Hermes profile 迁移到 .claude/agents/*.md", "classification": "Risk Gate", "depends_on": ["U-002"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": [], "assumption": ["A-2"]}, "done": "claude --agent planner -p 可正常启动且系统提示词正确;skills/memory/tools/model frontmatter 配置完整"}, | 17 | {"id": "U-004", "phase": "PH0", "sub_phase": "PH0-S2", "name": "Hermes profile 迁移到 .claude/agents/*.md", "classification": "Risk Gate", "depends_on": ["U-002"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": [], "assumption": ["A-2"]}, "done": "claude --agent planner -p 可正常启动且系统提示词正确;skills/memory/tools/model frontmatter 配置完整"}, |
| 18 | {"id": "U-004.5", "phase": "PH0", "sub_phase": "PH0-S2", "name": "RUN_REAL_AGENT_E2E 门控测试 harness(2026-07-30 新增)", "classification": "Enabler", "depends_on": ["U-004"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "有可复用的 skip_unless_env(\"RUN_REAL_AGENT_E2E\") 装饰器/基类;默认 unittest 跳过真实 claude 子进程调用;显式开启环境变量时至少一个真实往返用例可跑通"}, | 18 | {"id": "U-004.5", "phase": "PH0", "sub_phase": "PH0-S2", "name": "RUN_REAL_AGENT_E2E 门控测试 harness(2026-07-30 新增)", "classification": "Enabler", "depends_on": ["U-004"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "有可复用的 skip_unless_env(\"RUN_REAL_AGENT_E2E\") 装饰器/基类;默认 unittest 跳过真实 claude 子进程调用;显式开启环境变量时至少一个真实往返用例可跑通"}, |
src/project_scaffold.py
0 → 100644
| 1 | +"""c_project_create (03-C §3.0): register/scaffold a target project. | ||
| 2 | + | ||
| 3 | +U-002 scope: the directory-scaffolding + DB-persistence half of c_project_create -- | ||
| 4 | +no HTTP endpoint yet (that's U-007); this is the plain function U-007 will wire up. | ||
| 5 | + | ||
| 6 | +Target-project directory convention is architecture doc §13.2, NOT §7.3 (see | ||
| 7 | +development-plan.md's 2026-07-31 correction: §7.3 describes graphlemon's own | ||
| 8 | +platform repo, not a target project). c_project_create only ever creates | ||
| 9 | +`.graphlemon/` inside the target folder; a target project's own `src/` and | ||
| 10 | +everything else in it is left untouched. | ||
| 11 | +""" | ||
| 12 | + | ||
| 13 | +import os | ||
| 14 | +import subprocess | ||
| 15 | +import uuid | ||
| 16 | + | ||
| 17 | +_GIT_IDENTITY = [ | ||
| 18 | + "-c", "user.name=graphlemon", | ||
| 19 | + "-c", "user.email=graphlemon@localhost", | ||
| 20 | +] | ||
| 21 | + | ||
| 22 | + | ||
| 23 | +class PathNotFoundError(Exception): | ||
| 24 | + """PATH_NOT_FOUND -- 03-C §3.0: target_folder_path does not exist.""" | ||
| 25 | + | ||
| 26 | + | ||
| 27 | +class PathNotWritableError(Exception): | ||
| 28 | + """PATH_NOT_WRITABLE -- 03-C §3.0: no write permission on target_folder_path.""" | ||
| 29 | + | ||
| 30 | + | ||
| 31 | +class GitInitDeclinedError(Exception): | ||
| 32 | + """GIT_INIT_DECLINED -- 03-C §3.0: not a git repo and caller didn't ask to init one. | ||
| 33 | + | ||
| 34 | + 02-V §2.1: the UI must get explicit user confirmation before setting | ||
| 35 | + init_git_if_missing=True; this function never silently runs `git init`. | ||
| 36 | + """ | ||
| 37 | + | ||
| 38 | + | ||
| 39 | +def _run_git(args, cwd): | ||
| 40 | + subprocess.run(["git"] + args, cwd=cwd, check=True, capture_output=True, text=True) | ||
| 41 | + | ||
| 42 | + | ||
| 43 | +def create_project(conn, target_folder_path, name=None, init_git_if_missing=False): | ||
| 44 | + """Implements c_project_create (03-C §3.0). | ||
| 45 | + | ||
| 46 | + Returns {"project_id", "has_git_repo", "graphlemon_subdir_initialized"}. | ||
| 47 | + Idempotent: a second call with the same target_folder_path returns the | ||
| 48 | + existing row untouched (01-M 1.0 幂等约束) -- no re-validation, no repeat | ||
| 49 | + side effects, matching REQ-11's failure-case wording. | ||
| 50 | + """ | ||
| 51 | + existing = conn.execute( | ||
| 52 | + "SELECT project_id, has_git_repo, graphlemon_subdir_initialized " | ||
| 53 | + "FROM project WHERE target_folder_path = ?", | ||
| 54 | + (target_folder_path,), | ||
| 55 | + ).fetchone() | ||
| 56 | + if existing is not None: | ||
| 57 | + return { | ||
| 58 | + "project_id": existing[0], | ||
| 59 | + "has_git_repo": bool(existing[1]), | ||
| 60 | + "graphlemon_subdir_initialized": bool(existing[2]), | ||
| 61 | + } | ||
| 62 | + | ||
| 63 | + if not os.path.isdir(target_folder_path): | ||
| 64 | + raise PathNotFoundError(target_folder_path) | ||
| 65 | + if not os.access(target_folder_path, os.W_OK): | ||
| 66 | + raise PathNotWritableError(target_folder_path) | ||
| 67 | + | ||
| 68 | + has_git_repo = os.path.isdir(os.path.join(target_folder_path, ".git")) | ||
| 69 | + if not has_git_repo: | ||
| 70 | + if not init_git_if_missing: | ||
| 71 | + raise GitInitDeclinedError(target_folder_path) | ||
| 72 | + _run_git(["init"], cwd=target_folder_path) | ||
| 73 | + has_git_repo = True | ||
| 74 | + | ||
| 75 | + graphlemon_subdir = os.path.join(target_folder_path, ".graphlemon") | ||
| 76 | + os.makedirs(graphlemon_subdir, exist_ok=True) | ||
| 77 | + # git doesn't track empty directories; this is only so the init commit has | ||
| 78 | + # something under .graphlemon/ to record. Real content (runs/<task_id>/...) | ||
| 79 | + # arrives once tasks actually execute (01-M 1.5 artifact.file_path). | ||
| 80 | + open(os.path.join(graphlemon_subdir, ".gitkeep"), "w").close() | ||
| 81 | + | ||
| 82 | + _run_git(["add", ".graphlemon"], cwd=target_folder_path) | ||
| 83 | + _run_git( | ||
| 84 | + _GIT_IDENTITY + ["commit", "-m", "graphlemon: initialize .graphlemon/ workspace"], | ||
| 85 | + cwd=target_folder_path, | ||
| 86 | + ) | ||
| 87 | + | ||
| 88 | + project_id = str(uuid.uuid4()) | ||
| 89 | + project_name = name or os.path.basename(os.path.normpath(target_folder_path)) | ||
| 90 | + conn.execute( | ||
| 91 | + "INSERT INTO project " | ||
| 92 | + "(project_id, name, target_folder_path, has_git_repo, graphlemon_subdir_initialized, status) " | ||
| 93 | + "VALUES (?, ?, ?, ?, 1, 'active')", | ||
| 94 | + (project_id, project_name, target_folder_path, int(has_git_repo)), | ||
| 95 | + ) | ||
| 96 | + conn.commit() | ||
| 97 | + | ||
| 98 | + return { | ||
| 99 | + "project_id": project_id, | ||
| 100 | + "has_git_repo": has_git_repo, | ||
| 101 | + "graphlemon_subdir_initialized": True, | ||
| 102 | + } |
src/test_project_scaffold.py
0 → 100644
| 1 | +"""U-002 tests: c_project_create's directory-scaffolding + DB-persistence logic. | ||
| 2 | + | ||
| 3 | +Run: python3 -m unittest src.test_project_scaffold -v | ||
| 4 | +""" | ||
| 5 | + | ||
| 6 | +import os | ||
| 7 | +import shutil | ||
| 8 | +import stat | ||
| 9 | +import subprocess | ||
| 10 | +import tempfile | ||
| 11 | +import unittest | ||
| 12 | + | ||
| 13 | +from src import project_scaffold, schema | ||
| 14 | + | ||
| 15 | + | ||
| 16 | +def _init_git_repo(path): | ||
| 17 | + subprocess.run(["git", "init"], cwd=path, check=True, capture_output=True) | ||
| 18 | + | ||
| 19 | + | ||
| 20 | +class ProjectScaffoldTestBase(unittest.TestCase): | ||
| 21 | + def setUp(self): | ||
| 22 | + self.conn = schema.connect(":memory:") | ||
| 23 | + schema.create_schema(self.conn) | ||
| 24 | + self.tmpdir = tempfile.mkdtemp(prefix="graphlemon-u002-") | ||
| 25 | + | ||
| 26 | + def tearDown(self): | ||
| 27 | + self.conn.close() | ||
| 28 | + shutil.rmtree(self.tmpdir, ignore_errors=True) | ||
| 29 | + | ||
| 30 | + | ||
| 31 | +class HappyPathTest(ProjectScaffoldTestBase): | ||
| 32 | + def test_existing_git_repo_gets_graphlemon_subdir_and_init_commit(self): | ||
| 33 | + _init_git_repo(self.tmpdir) | ||
| 34 | + | ||
| 35 | + result = project_scaffold.create_project(self.conn, self.tmpdir) | ||
| 36 | + | ||
| 37 | + self.assertTrue(result["has_git_repo"]) | ||
| 38 | + self.assertTrue(result["graphlemon_subdir_initialized"]) | ||
| 39 | + self.assertTrue(os.path.isdir(os.path.join(self.tmpdir, ".graphlemon"))) | ||
| 40 | + | ||
| 41 | + log = subprocess.run( | ||
| 42 | + ["git", "log", "--oneline"], cwd=self.tmpdir, check=True, capture_output=True, text=True | ||
| 43 | + ) | ||
| 44 | + self.assertIn("graphlemon: initialize .graphlemon/ workspace", log.stdout) | ||
| 45 | + | ||
| 46 | + row = self.conn.execute( | ||
| 47 | + "SELECT project_id, target_folder_path, has_git_repo, graphlemon_subdir_initialized, status " | ||
| 48 | + "FROM project WHERE project_id = ?", | ||
| 49 | + (result["project_id"],), | ||
| 50 | + ).fetchone() | ||
| 51 | + self.assertIsNotNone(row) | ||
| 52 | + self.assertEqual(row[1], self.tmpdir) | ||
| 53 | + self.assertEqual(row[2], 1) | ||
| 54 | + self.assertEqual(row[3], 1) | ||
| 55 | + self.assertEqual(row[4], "active") | ||
| 56 | + | ||
| 57 | + def test_project_name_defaults_to_folder_name(self): | ||
| 58 | + _init_git_repo(self.tmpdir) | ||
| 59 | + result = project_scaffold.create_project(self.conn, self.tmpdir) | ||
| 60 | + row = self.conn.execute( | ||
| 61 | + "SELECT name FROM project WHERE project_id = ?", (result["project_id"],) | ||
| 62 | + ).fetchone() | ||
| 63 | + self.assertEqual(row[0], os.path.basename(self.tmpdir)) | ||
| 64 | + | ||
| 65 | + def test_explicit_name_used_when_given(self): | ||
| 66 | + _init_git_repo(self.tmpdir) | ||
| 67 | + result = project_scaffold.create_project(self.conn, self.tmpdir, name="Snake Game") | ||
| 68 | + row = self.conn.execute( | ||
| 69 | + "SELECT name FROM project WHERE project_id = ?", (result["project_id"],) | ||
| 70 | + ).fetchone() | ||
| 71 | + self.assertEqual(row[0], "Snake Game") | ||
| 72 | + | ||
| 73 | + | ||
| 74 | +class NonGitRepoConfirmationFlowTest(ProjectScaffoldTestBase): | ||
| 75 | + """02-V §2.1 / 03-C §3.0: never silently `git init`.""" | ||
| 76 | + | ||
| 77 | + def test_declines_without_confirmation_and_leaves_folder_untouched(self): | ||
| 78 | + with self.assertRaises(project_scaffold.GitInitDeclinedError): | ||
| 79 | + project_scaffold.create_project(self.conn, self.tmpdir, init_git_if_missing=False) | ||
| 80 | + | ||
| 81 | + self.assertFalse(os.path.isdir(os.path.join(self.tmpdir, ".git"))) | ||
| 82 | + self.assertFalse(os.path.isdir(os.path.join(self.tmpdir, ".graphlemon"))) | ||
| 83 | + count = self.conn.execute("SELECT COUNT(*) FROM project").fetchone()[0] | ||
| 84 | + self.assertEqual(count, 0) | ||
| 85 | + | ||
| 86 | + def test_initializes_git_when_explicitly_confirmed(self): | ||
| 87 | + result = project_scaffold.create_project(self.conn, self.tmpdir, init_git_if_missing=True) | ||
| 88 | + | ||
| 89 | + self.assertTrue(result["has_git_repo"]) | ||
| 90 | + self.assertTrue(os.path.isdir(os.path.join(self.tmpdir, ".git"))) | ||
| 91 | + self.assertTrue(os.path.isdir(os.path.join(self.tmpdir, ".graphlemon"))) | ||
| 92 | + | ||
| 93 | + | ||
| 94 | +class IdempotentCreationTest(ProjectScaffoldTestBase): | ||
| 95 | + def test_second_call_returns_existing_record_without_new_commit(self): | ||
| 96 | + _init_git_repo(self.tmpdir) | ||
| 97 | + first = project_scaffold.create_project(self.conn, self.tmpdir) | ||
| 98 | + second = project_scaffold.create_project(self.conn, self.tmpdir) | ||
| 99 | + | ||
| 100 | + self.assertEqual(first["project_id"], second["project_id"]) | ||
| 101 | + count = self.conn.execute("SELECT COUNT(*) FROM project").fetchone()[0] | ||
| 102 | + self.assertEqual(count, 1) | ||
| 103 | + | ||
| 104 | + log = subprocess.run( | ||
| 105 | + ["git", "log", "--oneline"], cwd=self.tmpdir, check=True, capture_output=True, text=True | ||
| 106 | + ) | ||
| 107 | + self.assertEqual(len(log.stdout.strip().splitlines()), 1) | ||
| 108 | + | ||
| 109 | + def test_idempotent_hit_skips_path_validation(self): | ||
| 110 | + # REQ-11 failure case: repeat calls on an already-registered path must | ||
| 111 | + # return the existing record even if the path itself is no longer | ||
| 112 | + # valid, not re-validate and error. | ||
| 113 | + _init_git_repo(self.tmpdir) | ||
| 114 | + first = project_scaffold.create_project(self.conn, self.tmpdir) | ||
| 115 | + shutil.rmtree(self.tmpdir) | ||
| 116 | + | ||
| 117 | + second = project_scaffold.create_project(self.conn, self.tmpdir) | ||
| 118 | + self.assertEqual(first["project_id"], second["project_id"]) | ||
| 119 | + | ||
| 120 | + | ||
| 121 | +class PathValidationErrorsTest(ProjectScaffoldTestBase): | ||
| 122 | + def test_nonexistent_path_raises_path_not_found(self): | ||
| 123 | + missing = os.path.join(self.tmpdir, "does-not-exist") | ||
| 124 | + with self.assertRaises(project_scaffold.PathNotFoundError): | ||
| 125 | + project_scaffold.create_project(self.conn, missing) | ||
| 126 | + | ||
| 127 | + def test_unwritable_path_raises_path_not_writable(self): | ||
| 128 | + os.chmod(self.tmpdir, stat.S_IRUSR | stat.S_IXUSR) | ||
| 129 | + try: | ||
| 130 | + if os.access(self.tmpdir, os.W_OK): | ||
| 131 | + self.skipTest("running as a user that bypasses permission bits (e.g. root)") | ||
| 132 | + with self.assertRaises(project_scaffold.PathNotWritableError): | ||
| 133 | + project_scaffold.create_project(self.conn, self.tmpdir) | ||
| 134 | + finally: | ||
| 135 | + os.chmod(self.tmpdir, stat.S_IRWXU) | ||
| 136 | + | ||
| 137 | + | ||
| 138 | +if __name__ == "__main__": | ||
| 139 | + unittest.main() |