Kiln 项目 Git 重新导入恢复的后端实现:remove_conflicting_id 与共享注销机制详解

原创2026-10-15 15:38:511,708 阅读
文章标签:人工智能AI 应用AI 评测RAG微调提示工程开发工具桌面应用MLOps

Kiln 项目 Git 重新导入恢复的后端实现:remove_conflicting_id 与共享注销机制详解

导读:本文围绕 Kiln 开源仓库中 git_auth_recovery 项目 的 Phase 2(后端 Git 重新导入恢复) 展开,讲解如何通过提取共享的项目注销助手 remove_project_from_config / _deregister_project,并新增 remove_conflicting_id 标志,让"移除冲突项目并重新导入"的恢复流程在后端落地。读完本文,你将掌握 Kiln 三层架构下"仅注销配置"与"注销配置 + 运行时注册表"两种移除语义的区别,理解 POST /api/git_sync/save_config 的 409 冲突处理路径,并能对照源码与测试用例独立追踪这条恢复链路。


1. 问题背景:为什么需要"重新导入恢复"链路

根据 project_overview.md 的记载,Kiln 用户曾遇到一个不可恢复的错误状态:某个项目通过 Git 同步,当 Git 凭据(Credentials)过期后,任务选择器(task picker)依赖的 /tasks API 直接报错。原始错误被包装成了 {"detail": "Cannot sync with remote. Check your connection."}——一个连通性提示,掩盖了真实的认证失败原因;同时前端把该对象渲染成 Tasks failed to load: [Object object],用户既看不懂原因、也没有出路。

更致命的是恢复路径被堵死:当用户只有一个项目时,应用会重定向到 /setup/select_task,而该页面没有"移除项目 / 重新导入 / 重新认证"的任何入口,而重新导入又会因为"你已经有一个相同 ID 的项目"(DuplicateProjectError)被拒绝,形成死锁。

为此,functional_spec.md 与 implementation_plan.md 将整个恢复方案拆成四个阶段:

阶段 主题 说明
Phase 1 错误封装 + 认证错误分类(后端) 中间件错误体 {"detail": ...} → {"message": ...};新增 GitAuthError,在 GitSyncManager.fetch() 中对 401/403 等认证类 pygit2 错误做分类,并在 ERROR_MAP 中映射为 HTTP 401
Phase 2 Git 重新导入恢复(后端) 本文主题:共享注销助手 + remove_conflicting_id,打通 save_config 的"先注销冲突、再保存新克隆"路径
Phase 3 恢复 UI(前端) 任务选择器错误态的"Re-import project?"链接、GitSyncRequestError 类型化错误、导入向导末步的红色"Remove existing and re-sync"按钮
Phase 4 本地文件夹导入对等 POST /api/import_project 增加 remove_conflicting_id 查询参数,本地导入获得与 Git 路径一致的重试能力

本文严格聚焦 Phase 2,同时会引用 Phase 1 的 GitAuthError 分类与 Phase 3/4 的前端/对等实现作为上下文佐证(详见 phase_1.md、phase_3.md、phase_4.md)。


2. 分层架构与设计约束:为什么移除逻辑要拆成两层

在展开具体代码前,必须先理解 architecture.md 强调的分层约定。Kiln 的 Git 同步相关代码横跨三个层次,且依赖只能自上而下流动:

  • libs/core(kiln_ai):持久的用户配置(Config:projects 列表、git_sync_projects 字典)与项目工具函数。它不知道任何运行时管理器。
  • libs/server(kiln_server):核心 REST API,包括 POST /api/import_project 与 custom_errors 异常处理器。
  • app/desktop(git_sync):Git 同步中间件、GitSyncManager、GitSyncRegistry(内存中的管理器 + 后台同步)以及 Git 同步 API(save_config、delete_project)。

由此推导出一个关键结论:"移除项目"中涉及持久化配置的那部分,必须放在 libs/core,这样 libs/server(本地导入)与 app/desktop(git 的 save_config、delete_project)两处都能调用;"运行时注册表拆除"那部分则是 app/desktop 在核心助手之上包的一层异步包装。这正是 Phase 2 设计的骨架。

此外,git_sync_api.py 中的 delete_project 端点路径刻意放在 /api/delete_project/{project_id}(而非 /api/projects/*),源码注释(git_sync_api.py)写明:该路径刻意避开 GitSyncMiddleware 的拦截,因为删除只更新 Kiln 配置、不涉及任何 Git 操作,即使在 Git 凭据已失效时也必须能成功执行——这是恢复链路可行性的前提之一。


3. 核心助手一:remove_project_from_config(配置层)

Phase 2 的第一步是在 project_utils.py 中新增 remove_project_from_config。其职责是:从 Config.shared().projects 列表中移除该项目路径,并从 Config.shared().git_sync_projects 字典中移除对应条目;若该项目是 Git 同步项目,则返回其 clone_path,否则返回 None。

def remove_project_from_config(project_path: str) -> str | None:
    """Remove a project from Kiln config. Does NOT delete files from disk.

    Returns the git-sync clone_path if the project was git-synced, else None.
    """
    projects = Config.shared().projects or []
    Config.shared().save_setting("projects", [p for p in projects if p != project_path])

    git_sync = Config.shared().git_sync_projects or {}
    clone_path = None
    if project_path in git_sync:
        clone_path = git_sync[project_path].get("clone_path")
        git_sync.pop(project_path)
        Config.shared().save_setting("git_sync_projects", git_sync)
    return clone_path

逐段解读其行为与设计意图:

  1. 仅操作持久化配置:它只调用 Config.shared().save_setting(...) 写回 projects 与 git_sync_projects 两个配置项,不触碰任何运行时对象(GitSyncManager、BackgroundSync),也不会删除磁盘上的任何文件。文档字符串第一句就明确了这条不变量:"Does NOT delete files from disk."
  2. 对 None 的容错:Config.shared().projects or [] 与 git_sync_projects or {} 保证配置项尚未初始化时也能安全执行。
  3. 返回值即"是否 Git 同步"的判据:clone_path 取自 git_sync_projects[project_path]["clone_path"]。调用方(上层 _deregister_project)正是依据返回值决定是否要做运行时注册表拆除。
  4. 幂等性:如果传入路径本就不在配置中,projects 列表过滤后内容不变、git_sync 字典中也没有该键,函数依然正常返回 None——这是恢复流程中"标志位无冲突时是 no-op"的基础。

底层数据结构可从 config.py 印证:projects 是项目文件路径列表;git_sync_projects 被声明为 ConfigProperty(dict, default_lambda=lambda: {}, sensitive_keys=<a href="https://link.gitcode.com/i/db3d572cc8a0d9571648b21c41dfd3e5" target="_blank">"pat_token", "oauth_token"])([config.py),即该字典中存有 PAT/OAuth 令牌这类敏感字段,写入配置时会受到脱敏处理。

配套的单测位于 test_project_utils.py 的 TestRemoveProjectFromConfig 类,覆盖四个分支:

  • test_removes_project_and_git_sync:同时更新 projects 与 git_sync_projects,并返回 clone_path;
  • test_non_git_synced_project:非 Git 同步项目返回 None,仅更新 projects;
  • test_missing_project_is_idempotent:路径不在配置中时行为幂等,不抛异常;
  • test_none_projects_list:projects / git_sync_projects 为 None 时安全兜底。

4. 核心助手二:_deregister_project(应用层包装)

第二步是在 git_sync_api.py 中新增 _deregister_project。它是完整的应用层移除操作:先调用核心助手下沉配置,再根据返回的 clone_path 决定是否需要拆除运行时注册表:

async def _deregister_project(project_path: str) -> None:
    """Remove a project from config and unregister its git-sync manager."""
    clone_path = remove_project_from_config(project_path)
    if clone_path is not None:
        await GitSyncRegistry.unregister(Path(clone_path))

其中 GitSyncRegistry.unregister(registry.py)负责停止该路径的后台同步并关闭其 Git 执行器:

@classmethod
async def unregister(cls, path: Path) -> None:
    """Stop background sync and remove the manager for *path*.

    Idempotent: calling on an unregistered path is a no-op.
    """
    resolved = path.resolve()
    with cls._lock:
        bg_sync = cls._background_syncs.pop(resolved, None)
        manager = cls._managers.pop(resolved, None)
    if bg_sync is not None:
        await bg_sync.stop()
    if manager is not None:
        manager._git_executor.shutdown(wait=False)

这个设计体现了清晰的职责分层:

层次 函数 职责 所在层
配置层 remove_project_from_config 从 projects / git_sync_projects 中移除条目,返回 clone_path libs/core
注册表层 GitSyncRegistry.unregister 停止后台同步、关闭 Git 执行器、移除内存管理器 app/desktop
应用层组装 _deregister_project 先配置后注册表,按需串联 app/desktop

5. 重构 delete_project:复用共享助手,外部契约不变

第三步是把现有 delete_project 端点内联的"配置移除逻辑"替换为对 _deregister_project 的调用。重构后的实现(git_sync_api.py):

@app.delete(
    "/api/delete_project/{project_id}",
    summary="Delete Project",
    tags=["Projects"],
    openapi_extra=DENY_AGENT,
)
async def delete_project(
    project_id: Annotated[
        str, FastAPIPath(description="The unique identifier of the project.")
    ],
) -> dict:
    """Removes the project from Kiln but does not delete the files from disk."""
    project = project_from_id(project_id)
    await _deregister_project(str(project.path))
    return {"message": f"Project removed. ID: {project_id}"}

要点:

  • 行为零变化:此前 delete_project 的内联逻辑正是"移除 projects 条目 + 移除 git_sync_projects 条目 + 注销 GitSyncManager"(对应 functional_spec.md 中 C.3 的表述:"the body of delete_project factored into a shared helper"),现在被原样抽取为共享助手,因此对外响应形状与状态码不变(成功返回 {"message": f"Project removed. ID: {project_id}"},项目不存在时由 project_from_id 抛 404)。
  • 两条路径共用一个实现:设置页的"移除项目"与恢复流程中的"先注销冲突项目"现在共享同一份注销代码,避免语义漂移。
  • 配套测试见 test_git_sync_api.py:test_delete_project_success、test_delete_project_cleans_up_git_sync(验证 Git 同步项目的注销)、test_delete_project_not_found(验证 404 分支)。

6. SaveConfigRequest 新增 remove_conflicting_id 字段

第四步是为 POST /api/git_sync/save_config 的请求模型 SaveConfigRequest 增加可选布尔字段。Pydantic 模型定义(git_sync_api.py)中该字段的完整形态:

class SaveConfigRequest(BaseModel):
    """Request to save a complete git sync configuration for a project."""

    project_id: str = Field(description="Unique project identifier.")
    project_path: str = Field(
        description="Relative path to the project.kiln file within the clone."
    )
    git_url: str = Field(description="The git remote URL.")
    clone_path: str = Field(description="Local filesystem path of the clone.")
    branch: str = Field(description="Branch name to sync.")
    remote_name: str = Field(default="origin", description="Git remote name.")
    pat_token: str | None = Field(default=None, ...)
    oauth_token: str | None = Field(default=None, ...)
    auth_mode: Literal["system_keys", "pat_token", "github_oauth"] = Field(
        default="system_keys", ...
    )
    sync_mode: Literal["auto", "manual"] = Field(default="auto", ...)
    remove_conflicting_id: bool = Field(
        default=False,
        description="When true and a duplicate project ID conflict is detected, "
        "remove the existing project registration before saving.",
    )
    trusted: bool = Field(
        default=False,
        description="Must be true to confirm trust before importing. "
        "Kiln projects can contain code that runs on your machine.",
    )

字段语义小结:

字段 类型 默认值 含义
project_id str — 项目唯一标识
project_path str — project.kiln 在克隆内的相对路径(必须是相对路径,见下文校验)
git_url / clone_path / branch str — Git 远端 URL、本地克隆路径、同步分支
remote_name str "origin" Git 远端名称
pat_token / oauth_token str | None None PAT 或 OAuth 令牌
auth_mode 字面量 "system_keys" 认证方式:system_keys / pat_token / github_oauth
sync_mode 字面量 "auto" 同步模式:auto / manual
remove_conflicting_id bool False 检测到重复项目 ID 冲突时,先移除已有项目注册再保存
trusted bool False 必须为 true 才能导入(Kiln 项目可能包含在本机运行的代码)

值得注意的是 trusted 字段:api_save_config 的第一步就是校验 trusted(git_sync_api.py),不通过直接返回 400。同时端点还会校验 clone_path 必须位于默认项目目录内、project_path 必须是相对路径且不得越出克隆目录(_validate_clone_path 与路径逃逸检查,git_sync_api.py、git_sync_api.py),这些是恢复链路得以安全落地的既有护栏。


7. api_save_config 的冲突处理:先注销、后保存

第五步(也是最核心的一步)是改造 api_save_config 的重复项目处理逻辑。完整实现(git_sync_api.py):

async def api_save_config(request: SaveConfigRequest) -> GitSyncConfigResponse:
    if not request.trusted:
        raise HTTPException(
            status_code=400,
            detail="Import cancelled: you must confirm you trust this project before importing. Kiln projects can contain code that runs on your machine.",
        )
    _validate_clone_path(request.clone_path)
    if Path(request.project_path).is_absolute():
        raise HTTPException(
            status_code=400,
            detail="project_path must be a relative path within the clone",
        )
    clone_root = Path(request.clone_path).resolve()
    resolved_project = (clone_root / request.project_path).resolve()
    if not str(resolved_project).startswith(str(clone_root) + os.sep):
        raise HTTPException(
            status_code=400,
            detail="project_path must not escape the clone directory",
        )
    full_project_path = str(resolved_project)

    try:
        check_duplicate_project_id(request.project_id, full_project_path)
    except DuplicateProjectError as e:
        if not request.remove_conflicting_id:
            raise HTTPException(status_code=409, detail=str(e))
        conflicting = project_from_id_core(request.project_id)
        if conflicting is not None:
            await _deregister_project(str(conflicting.path))

    project_config = GitSyncProjectConfig(...)
    save_git_sync_config(full_project_path, project_config)
    add_project_to_config(full_project_path)

    return GitSyncConfigResponse(...)

7.1 冲突从何而来:check_duplicate_project_id

重复检测由 project_utils.py 的 check_duplicate_project_id 完成。它遍历 Config.shared().projects,逐个加载 project.kiln 比对 project.id,并区分两种冲突:

def check_duplicate_project_id(project_id: str, new_project_path: str) -> None:
    project_paths = Config.shared().projects
    if project_paths is None:
        return

    new_resolved = str(Path(new_project_path).resolve())

    for existing_path in project_paths:
        try:
            project = Project.load_from_file(existing_path)
        except Exception:
            continue

        if project.id != project_id:
            continue

        existing_resolved = str(Path(existing_path).resolve())
        if existing_resolved == new_resolved:
            raise DuplicateProjectError(
                "This project is already imported.",
                same_path=True,
            )
        else:
            raise DuplicateProjectError(
                f'You already have a project with this ID. You must remove project "{project.name}" before adding this.',
                same_path=False,
            )

DuplicateProjectError(project_utils.py)携带 same_path 布尔属性,用于区分"同路径重复导入"与"不同路径但 ID 相同"。重新导入恢复只针对 same_path=False 的场景:因为向导总是克隆到全新目录,旧克隆从未被覆盖。

7.2 两个分支的行为

  • remove_conflicting_id=False(默认):直接 raise HTTPException(status_code=409, detail=str(e)),行为与改造前完全一致——这正是测试 test_remove_conflicting_false_still_409 要守护的"默认行为不变"。
  • remove_conflicting_id=True 且检测到冲突:
    1. 通过 project_from_id_core(request.project_id) 解析冲突项目——由于冲突项目与 request.project_id 共享同一 ID(这正是它冲突的原因),所以用请求中的 ID 就能定位到它;
    2. 调用 await _deregister_project(str(conflicting.path)) 完成"配置移除 + 注册表拆除";
    3. 不 return、不 break,而是继续落入下方的正常保存路径:save_git_sync_config(full_project_path, project_config) 写入新的 Git 同步配置,add_project_to_config(full_project_path) 将新克隆注册进 projects 列表。

从 functional_spec.md 的表述看,这一步的净效果等价于"删除项目 + 保存配置"两个既有 API 调用被原子地合并为一次请求:恢复路径没有发明任何新的移除语义,而是完全复用了 delete_project 的"仅注销"行为。

7.3 两个值得注意的实现细节

  1. project_from_id_core 与 project_from_id 的区分:git_sync_api.py 同时导入了两个同名函数——kiln_ai.utils.project_utils.project_from_id(重命名为 project_from_id_core,纯 core 实现)与 kiln_server.project_api.project_from_id。前者用于冲突解析(只依赖 Config 遍历),后者用于 delete_project 的既有逻辑。这种显式命名区分了两个层次的同源函数,避免了导入遮蔽。
  2. GitSyncManager 的注销异步性:_deregister_project 是 async 函数,因为 GitSyncRegistry.unregister 需要 await bg_sync.stop() 优雅停止后台同步。而配置层助手 remove_project_from_config 是纯同步的,这正好对应"持久化配置同步完成、运行时拆除异步协调"的分层特性。

8. 边界情况与错误处理策略

Phase 2 的设计在 functional_spec.md 的 "Edge cases & error handling" 一节有系统性论述,结合源码可归纳为:

  • remove_conflicting_id=True 但实际无冲突:check_duplicate_project_id 根本不会抛异常,标志位是 no-op,保存正常继续。对应测试 test_remove_conflicting_no_conflict_is_noop(test_git_sync_api.py)。
  • 冲突项目在 config 中已不存在:project_from_id_core 返回 None,跳过注销直接落入保存路径,同样安全(对应 test_remove_conflicting_project_not_found_still_saves,test_git_sync_api.py)。
  • same_path 重复("This project is already imported"):重新导入流程中不会出现(总是克隆到新路径);即便出现,移除同路径注册再重新注册同一路径也只是良性 re-register。
  • 旧克隆文件保留在磁盘上:这是有意的设计——与 delete_project 的"不删除文件"保证一致,避免误删未推送的本地提交。
  • 项目 ID 保持稳定:项目 ID 存储在仓库内的 project.kiln 中,重新导入后 ID 不变,因此用户持久化的选择(ui_state.current_project_id)在恢复后依然能解析,不会出现悬空的选中状态。

9. 测试矩阵:Phase 2 的验证闭环

Phase 2 规划的测试清单(phase_2.md 的 "Tests" 一节)与仓库中的实际测试一一对应,构成完整的验证闭环:

规划中的测试 实际实现(test_git_sync_api.py / test_project_utils.py) 验证点
test_remove_project_from_config_removes_project_and_git_sync TestRemoveProjectFromConfig.test_removes_project_and_git_sync 两个配置项同时更新、返回 clone_path
test_remove_project_from_config_non_git_synced test_non_git_synced_project 非 Git 同步返回 None
test_remove_project_from_config_missing_project test_missing_project_is_idempotent 路径不在配置中时幂等
test_delete_project_uses_shared_helper test_delete_project_success / test_delete_project_cleans_up_git_sync delete_project 走共享助手且响应不变
test_save_config_remove_conflicting_deregisters_and_saves test_remove_conflicting_deregisters_and_saves(test_git_sync_api.py) 完整流程:remove_conflicting_id=True 时注销旧项目并保存成功
test_save_config_remove_conflicting_false_still_409 test_remove_conflicting_false_still_409(L1309-L1336) 默认行为不变,仍返回 409
test_save_config_remove_conflicting_no_conflict_is_noop test_remove_conflicting_no_conflict_is_noop(L1338-L1361) 无冲突时标志位是 no-op

以最核心的 test_remove_conflicting_deregisters_and_saves 为例,它通过 mock 依次验证了整条调用链(test_git_sync_api.py):

  • check_duplicate_project_id 被模拟为抛出 DuplicateProjectError(same_path=False);
  • project_from_id_core 返回 conflicting_project(路径为 /old/clone/project.kiln);
  • 请求体携带 "remove_conflicting_id": True 与 "trusted": True;
  • 断言最终响应为 200,且 remove_project_from_config 被以 /old/clone/project.kiln 调用、GitSyncRegistry.unregister 被以 Path("/old/clone") 异步调用——精确锁定了"配置层 + 注册表层"的双重注销。

10. 与上下游的衔接:从后端到前端、再到本地导入

Phase 2 是整条恢复链路的后端主干,理解它需要知道它与前后阶段的衔接点:

10.1 上游:Phase 1 的认证错误分类(恢复的"信号源")

恢复动作之所以能触发,前提是认证错误能被准确识别。Phase 1 在 git_sync_manager.py 的 fetch() 中实现了分类:

async def fetch(self) -> None:
    try:
        await self._run_git(self._fetch_sync)
    except pygit2.GitError as e:
        if _is_auth_error(e):
            raise GitAuthError(f"Git authentication failed: {e}") from e
        raise RemoteUnreachableError(f"Cannot sync with remote: {e}") from e

_is_auth_error 基于 git_sync_manager.py 的标记元组做最佳努力匹配:

_AUTH_ERROR_MARKERS = (
    "authentication",
    "authoriz",
    "credential",
    "401",
    "403",
    "too many redirects",
    "invalid username or password",
    "password authentication",
)

def _is_auth_error(e: Exception) -> bool:
    msg = str(e).lower()
    return any(marker in msg for marker in _AUTH_ERROR_MARKERS)

随后 middleware.py 的 ERROR_MAP 将 GitAuthError 映射为 HTTP 401 与提示文案 "Git authentication failed or expired. Re-import the project to reconnect with fresh credentials."。前端拿到这条准确信息后,才会引导用户走"重新导入"路线,最终命中 Phase 2 的 remove_conflicting_id。

10.2 下游:Phase 3 的前端对接

后端契约就绪后,前端在 api.ts 中做了三件事(详见 phase_3.md):

  1. 手写的 request() 助手改为抛出带状态码的类型化错误:
export class GitSyncRequestError extends Error {
  status: number
  constructor(message: string, status: number) {
    super(message)
    this.name = "GitSyncRequestError"
    this.status = status
  }
}

export function is_duplicate_project_error(e: unknown): boolean {
  return e instanceof GitSyncRequestError && e.status === 409
}
  1. saveConfig 的类型增加可选 remove_conflicting_id,透传到 POST body。
  2. 导入向导末步 step_complete.svelte 将保存逻辑抽取为 run_save(remove_conflicting_id = false)(L48-L89),在 catch 中通过 is_duplicate_project_error(e) 识别 409,并渲染红色按钮(L130-L132):
{#if is_conflict}
  <button class="btn btn-error btn-outline" on:click={() => run_save(true)}>
    Remove existing and sync
  </button>
{/if}

同时任务选择器 select_tasks_menu.svelte/select_tasks_menu.svelte) 在错误态(L236-L251)渲染准确的错误文案与低强调的 "Re-import project?" 链接,其目标地址通过 import_project_url prop 按上下文注入(setup 全屏场景为 /setup/import_project,应用内侧边栏为 /settings/import_project)。

10.3 对等扩展:Phase 4 的本地文件夹导入

由于恢复导航落在通用的导入方式选择页,用户可能选"Local Folder"而非"Git Auto Sync",因此 project_api.py 的 POST /api/import_project 也增加了同名查询参数,走同一个 core 助手:

except DuplicateProjectError as e:
    if not remove_conflicting_id:
        raise HTTPException(status_code=409, detail=str(e))
    # Resolve and de-register the conflicting project before proceeding.
    # Layering caveat: libs/server cannot call GitSyncRegistry.unregister
    # (app layer), so if the removed project was git-synced and had a live
    # manager this session, its in-memory manager lingers until restart.
    conflicting = project_from_id_core(project.id)
    if conflicting is not None:
        remove_project_from_config(str(conflicting.path))

源码注释明确记录了分层告诫(layering caveat):libs/server 无法调用 GitSyncRegistry.unregister(应用层),如果被移除的冲突项目恰好是 Git 同步且本会话存在活跃管理器,其内存中的管理器/后台同步会一直残留到重启。这是本地文件夹导入的罕见边界,可接受,但必须文档化——而 Git 路径(_deregister_project)则能做完整的注册表拆除。这再次印证了第 2 节的三层依赖约定。


11. API 合同变更汇总

综合 functional_spec.md 的 "API contract changes" 一节,Phase 2 及其衔接阶段带来的 API 层面变化如下:

端点 变更
POST /api/git_sync/save_config SaveConfigRequest 新增可选字段 remove_conflicting_id: bool = False。为 true 且存在"同 ID 不同路径"的重复项目时,先注销冲突项目(文件留在磁盘),再继续保存
POST /api/import_project 新增可选查询参数 remove_conflicting_id: bool = False,语义同上(Phase 4,本地文件夹对等)
GitSyncMiddleware 错误响应 错误体由 {"detail": ...} 统一改为 {"message": ...}(Phase 1),状态码不变
Git 同步读错误(经中间件) 新增 GitAuthError → HTTP 401 及认证专用文案;既有 RemoteUnreachableError(503)、SyncConflictError(409)、WriteLockTimeoutError(503)、CorruptRepoError(500) 不变
DELETE /api/delete_project/{project_id} 外部契约不变;实现重构为共享助手 _deregister_project

12. 总结:一条可独立追踪的恢复链路

Phase 2 的价值不在于新增复杂逻辑,而在于把既有行为正确地抽离与复用。回顾整条链路:

  1. 配置层助手 remove_project_from_config 下沉到 libs/core,成为"仅注销配置"的唯一事实来源;
  2. 应用层包装 _deregister_project 补上运行时注册表拆除,delete_project 与恢复路径共用同一实现;
  3. SaveConfigRequest.remove_conflicting_id 让 api_save_config 在 409 冲突时走"解析冲突 → 注销 → 落回正常保存"的原子路径;
  4. 以 project_id 不变、旧克隆保留、标志位幂等为三大不变量,配合 test_git_sync_api.py 与 test_project_utils.py 的完整测试矩阵,保证默认行为零回归;
  5. 上游 Phase 1 提供准确的 GitAuthError 信号,下游 Phase 3/4 将恢复能力交付到 Git 与本地两条导入路径的 UI。

如果读者想深入验证或继续追踪,建议依次阅读:phase_2.md(本阶段计划)→ architecture.md(分层设计)→ functional_spec.md(完整行为契约)→ project_utils.py 与 git_sync_api.py(核心实现)→ test_git_sync_api.py 与 test_project_utils.py(测试闭环)。

登录后查看全文
Kiln