Kiln 项目 Git 重新导入恢复的后端实现:remove_conflicting_id 与共享注销机制详解
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
逐段解读其行为与设计意图:
- 仅操作持久化配置:它只调用
Config.shared().save_setting(...)写回projects与git_sync_projects两个配置项,不触碰任何运行时对象(GitSyncManager、BackgroundSync),也不会删除磁盘上的任何文件。文档字符串第一句就明确了这条不变量:"Does NOT delete files from disk." - 对
None的容错:Config.shared().projects or []与git_sync_projects or {}保证配置项尚未初始化时也能安全执行。 - 返回值即"是否 Git 同步"的判据:
clone_path取自git_sync_projects[project_path]["clone_path"]。调用方(上层_deregister_project)正是依据返回值决定是否要做运行时注册表拆除。 - 幂等性:如果传入路径本就不在配置中,
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 ofdelete_projectfactored 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且检测到冲突:- 通过
project_from_id_core(request.project_id)解析冲突项目——由于冲突项目与request.project_id共享同一 ID(这正是它冲突的原因),所以用请求中的 ID 就能定位到它; - 调用
await _deregister_project(str(conflicting.path))完成"配置移除 + 注册表拆除"; - 不 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 两个值得注意的实现细节
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的既有逻辑。这种显式命名区分了两个层次的同源函数,避免了导入遮蔽。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):
- 手写的
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
}
saveConfig的类型增加可选remove_conflicting_id,透传到 POST body。- 导入向导末步 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 的价值不在于新增复杂逻辑,而在于把既有行为正确地抽离与复用。回顾整条链路:
- 配置层助手
remove_project_from_config下沉到libs/core,成为"仅注销配置"的唯一事实来源; - 应用层包装
_deregister_project补上运行时注册表拆除,delete_project与恢复路径共用同一实现; SaveConfigRequest.remove_conflicting_id让api_save_config在 409 冲突时走"解析冲突 → 注销 → 落回正常保存"的原子路径;- 以
project_id不变、旧克隆保留、标志位幂等为三大不变量,配合test_git_sync_api.py与test_project_utils.py的完整测试矩阵,保证默认行为零回归; - 上游 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(测试闭环)。