本页是 Mega2 storage-only(push_policy=trunk)形态下目录变更与 monorepo 标签 产品 HTTP 的单一契约正文,供外部调用方按 file:line pin。计划出处 ../plan/plan-20260917.md(ADR-LB-01..07)与跟进 ../plan/plan-20260918.md(ADR-FT-01..03)。
主消费者是 sibling Libra 的 libra mega2 browser TUI。写本页的目的,是让调用方读契约而不是猜路径。
每条路由标注实现状态。本页由 LB-01 创建;LB-02..04 把目录删移与 tag 挂载翻为 implemented。plan-20260918 FT-01 再冻结三处新 wire(is_directory、GET list、?path=),在对应实现卡落地前标 specified——那些小节描述落地后的契约,不是今日行为。
| 状态 | 含义 |
|---|---|
implemented |
程式已存在且已挂到 storage-only,可直接调用 |
specified-unimplemented |
本页冻结 wire;storage-only 上今日不可用(404 或行为不同) |
| 路由 | 状态 | 承接卡 | 今日实况 |
|---|---|---|---|
GET /api/v1/tree |
implemented |
— | 可用 |
POST /api/v1/create-entry |
implemented |
— | 可用 |
POST /api/v1/delete-entry |
implemented |
LB-02 / FT-02 | 可用:省略 is_directory 删目录;false 删文件 |
POST /api/v1/move-entry |
implemented |
LB-03 / FT-03 | 可用:省略 is_directory 移目录;false 移文件(同一 blob oid) |
POST /api/v1/tags |
implemented |
LB-04 | 可用:storage_only_routers_with 已 merge tag_router::routers();trunk 写经 push_auth,见「鉴权」 |
POST /api/v1/tags/list |
implemented |
FT-04 | 405(不再登记 POST) |
GET /api/v1/tags/list |
implemented |
FT-04 | 唯一 list:必填 query page、per_page、path |
GET /api/v1/tags/{name} |
implemented |
LB-04 / FT-06 | 可用(读,不要求 Authorization)。可选 ?path=(省略或空 = /) |
DELETE /api/v1/tags/{name} |
implemented |
LB-04 / FT-06 | 可用:鉴权 path = 选择器 path(省略或空 = /) |
POST /api/v1/path/provision |
implemented |
plan-20260923 FU-07 | 可用:只挂 storage-only(storage_only_write_routers);Review 形态 404 |
POST /api/v1/import-repo/remove |
implemented |
plan-20260923 FU-20 | 可用:只挂 storage-only(storage_only_routers_with 合并 import_repo_router::routers());token 须覆盖 path,push_auth=none 一律 403;Review 形态 404 |
公共前缀 /api/v1 由外层 nest 施加。
一律是 CommonResult<T>(src/contract/api/common.rs:4-9):
{ "req_result": true, "data": { }, "err_message": "" }三个键始终存在:req_result: bool、data: Option<T>(失败时为 null)、err_message: String(成功时为空串)。
- 只 pin 不改语义:
POST /create-entry。 - 错误语义修正(FIX-BB-10): GET /tree:路径不存在或指向非目录时返回 404;已有目录的请求字段与成功响应不变。
- 新增产品写:
POST /delete-entry、POST /move-entry(改名 = 同 parent 的 move)。plan-20260918 用同一路径扩文件(is_directory=false)。 - 挂载 + 鉴权 + 文档对齐: 四条
/tags*。plan-20260918 把 list 改 GET,并为 get/delete 加?path=。 - ImportRepo 叶子清理(plan-20260923 FU-20):
POST /import-repo/remove,见「ImportRepo 叶子清理」。 - 目录/文件变更是父目录 tree 改写后写新 commit,经
land_api_tip_push(trunk)或既有 CL 分支(Review)前进 tip;不是 Git delete command,也不走 CLapply_changes(ADR-LB-02 / ADR-FT-01)。
| 项 | 值 |
|---|---|
| 鉴权 | 无(不送 Authorization) |
| Query | path(可选,#[serde(default = "default_path")] → /)、refs(可选,#[serde(default)] → 空串 = 当前 tip) |
| 成功 | 200 + CommonResult<TreeResponse> |
| 路径不存在或指向非目录 | 404 + 失败 CommonResult(req_result=false、data=null、err_message 点名请求路径或首个缺失前缀) |
data.tree_items[] 每项为 { name, path, content_type }(TreeBriefItem)。
data.file_tree是祖先辅助结构,不得当导航权威——导航只用tree_items。
| 字段 | 规则 |
|---|---|
is_directory |
必填 bool。目录取 true |
name |
必填 |
path |
必填。父目录,rooted;根下用 / |
content |
目录创建时可省略或为 null;is_directory=false 时必填(缺失见「错误映射」) |
author_username / author_email |
可选 |
skip_build |
可选,#[serde(default)] → false。Libra 一律送 true |
mode |
可选,默认 EditCLMode::TryReuse(None)。调用方应省略该键,不得送字符串 "try_reuse" |
成功 data 为 CreateEntryResult { commit_id, new_oid, path, cl_link };trunk 上 cl_link 必为 null;path 只作回执。
is_directory=true 时服务端写一个带时间戳的 .gitkeep 占位。
与 create-entry 对齐,用 parent path + name,不用单一绝对路径字段当权威。HTTP 状态同样是 200 + CommonResult(不用 204)。
以下两表逐行复制自 ADR-LB-03,并由 ADR-FT-01 扩 is_directory。
{
"path": "/project",
"name": "old-dir",
"is_directory": true,
"author_username": null,
"skip_build": true
}| 字段 | 规则 |
|---|---|
path |
必填。父目录,rooted,默认语义与 create-entry 相同(根下用 /) |
name |
必填。要删的项名;禁止 /、.、..、分隔符、NUL、控制字符 |
is_directory |
可选。bool,#[serde(default = "default_is_directory")] → true。true = 目录(Tree);false = 文件(Blob 或 BlobExecutable)。省略与显式 true 同义。 |
author_username |
可选。Option<String>;可省略或 JSON null。不参与鉴权。 |
skip_build |
可选。bool,#[serde(default)] → false。Libra 一律送 true。与 create-entry 同形。 |
| 目标 | 必须已存在且 mode 与 is_directory 相符;禁止删 / |
| 鉴权 | authorize_trunk_api_write(path),path = 父目录 |
| HTTP | 200 + CommonResult(不用 204) |
成功 data |
{ "commit_id": "<hex>", "path": "/project/old-dir", "cl_link": null };无 new_oid。path 只作回执。 |
空目录与非空目录是同一条删除语义(删掉父 tree 中的该 item);不提供「只删空目录」。
落地事实(LB-02,mono_api_service.rs 的 delete_monorepo_entry):
path/name先过validate_entry_target(src/ceres/model/git.rs):path须 rooted(空串等于/,容忍一个尾随/),组件不得为空、.、..;name为单一组件,禁/、\、.、..、NUL 与控制字符。不合规一律 400。- 父目录被删空时,服务端补写一个带时间戳的
.gitkeep,父目录保留为空目录——与 create-entry 表示新建空目录的方式一致;Git 无法在路径上表示空 tree,这是唯一能保住父目录的做法。 - 同名的 blob 与 tree 可以并存(create-entry 的重名检查按 mode 区分)。
is_directory=true(或缺省)只匹配 Tree;false只匹配 Blob 或 BlobExecutable。父 tree 完全没有该name才是 404;同名但 mode 不符是 400(「不是目录」或「不是文件」)。 - 一次删除 = 一次 commit(父链 tree 改写 +
.gitkeep可选 blob),trunk 经land_api_tip_push前进 tip,Review 走既有 CL 分支(EditCLMode::TryReuse(None),与 create-entry 相同的政策分流)。 - trunk 上父目录为
/(即删除顶层目录)时,产品写从不在/落地,返回 400MONO_PATH_NOT_ALLOWED: …(plan-20260923.mdADR-FU-06;此前为 B0 的no non-root path tip under / for trunk API write);Review 形态则在/的 CL 上进行。 commit_id在 trunk 上是落地后的 tip;path只作回执。
{
"from_path": "/project",
"from_name": "old-dir",
"to_path": "/project/other",
"to_name": "new-dir",
"is_directory": true,
"author_username": null,
"skip_build": true
}| 字段 | 规则 |
|---|---|
from_path / from_name |
必填。源父 + 源名;源 mode 必须与 is_directory 相符 |
to_path / to_name |
必填。目标父 + 目标名;目标父必须已存在且为 directory |
is_directory |
可选。同 delete-entry:缺省 true;false 移文件(保留 Blob / BlobExecutable) |
author_username |
可选。同 delete-entry。 |
skip_build |
可选。同 delete-entry;Libra 一律送 true。 |
| 改名 | from_path == to_path 且 from_name != to_name |
| 拒绝 | 源目标相同;目标名已存在;把目录移进自己的子树;is_directory=true 时的 file 源(或 false 时的 directory 源);/;traversal;import_dir |
| 鉴权 | 两个 path(from_path 与 to_path)都必须通过 authorize_trunk_api_write;任一失败则不写 |
| HTTP | 200 + CommonResult(不用 204) |
成功 data |
{ "commit_id": "<hex>", "from_path": "/project/old-dir", "to_path": "/project/other/new-dir", "cl_link": null };无 new_oid。返回路径只作回执。 |
落地事实(LB-03,mono_api_service.rs 的 move_monorepo_entry):
- 两组
path/name都先过validate_entry_target(规则同 delete-entry);父路径经normalize_parent_path归一(空串 =/,容忍一个尾随/),改名 = 归一后from_path == to_path且名字不同。 - 校验顺序:源目标相同 → 移进自己的子树(仅
is_directory=true:目标父 = 源目录或其后代;文件源不做子树检查)→ 目标父落在 ImportRepo 下(router 只按from_path分派,monorepo handler 自查git_repo后以 409 拒绝)→ 源父不存在 / 源不存在 / 源 mode 不符 → 目标父不存在 → 目标名已存在(任何 mode 的同名项都算已存在)。全部检查在任何写入之前完成。省略字段只移目录;is_directory=false移Blob/BlobExecutable并保留同一 oid。 - 改写 = 源父 tree 去掉该项、目标父 tree 插入同一 oid 与同一 mode(改名只改
TreeItem.name),两条父链自底向上重算到根,一次 commit;目标父的项按 Git 顺序排序;源父被移空时补写带时间戳的.gitkeep(同 delete-entry)。 - 落地路径 = 两个父目录的最深公共目录:trunk 上经
land_api_tip_push前进覆盖该路径的最深非根 path tip(resolve_trunk_land_path,AW-03:落地/project/a(本身无 tip)时前进的是/project的 tip),因此跨顶层目录的移动(公共目录为/)在 trunk 上返回 400MONO_PATH_NOT_ALLOWED(产品写从不在/落地,plan-20260923.mdADR-FU-06;此前为 B0 文本no non-root path tip …);Review 形态在该公共目录(或/)的 CL 上进行,与 create/delete 相同的政策分流。 - 鉴权:
from_path与to_path各调一次trunk_write_requester,任一失败(401/403)即拒绝,此时尚未读任何 tree。
幂等的路径开通(mkdir -p):一次 commit 补齐目标路径上全部缺失的目录层级(叶子目录放一个内容每次唯一的 .gitkeep),由开通专用、绑定单一根树快照的建树逻辑构建,经 commit_tree_update 与 MonoWriteQueue 落地;整条路径已是目录时零写(plan-20260923.md ADR-FU-05)。只挂在 storage-only 表面(preview_router::storage_only_write_routers,由 api_router::storage_only_routers_with 合并);Review 形态不挂(path_provision_is_storage_only)。
{ "path": "/project/workbuddy/demo" }| 字段 | 规则 |
|---|---|
path |
必填。规范绝对路径,原样必须等于其规范形态(strict_creation_path_input:拒绝相对路径、. / .. 段、重复或尾斜杠、NUL、\ 与其它控制字符如换行 / 制表) |
成功(HTTP 200):
{ "req_result": true, "data": { "path": "/project/workbuddy/demo", "created": true, "commit_id": "<hex>" }, "err_message": "" }created: false 且 commit_id: null 表示路径已是目录,没有写入(重复调用恒返回该结果)。commit message 为 provision <path>;全新栈上与其它产品写一样落在惰性物化的一级根上(ADR-FU-06)。
判定顺序:
- 严格输入校验(400
MONO_PATH_INVALID)。 - 鉴权预检:以目标路径调用
authorize_trunk_api_write(401 / 403),此前不读树。 - 每轮:import 命名空间守卫与创建分类(400
MONO_PATH_NOT_ALLOWED/MONO_PATH_INVALID;/为MONO_PATH_INVALID)→ 逐级遍历,已存在的组件不是目录 → 409MONO_PATH_CONFLICT(点名该组件)→ 求出最高新建组件(第一个缺失组件;无缺失时即目标)→ 以它再次鉴权(403)→ 落地。 - 落地(apply)从同一份根树快照重新推导最高新建组件并在该快照上建树:推导结果与已鉴权的计划不同(例如计划之后某个容器被删除)→ 不写入、重新规划并重新鉴权;新建层级的位置出现任何同名条目(包括计划之后才出现的文件)→ 409
MONO_PATH_CONFLICT;落点 tip 的 tree 与快照不一致(期间有别的写落地),或队列以固定文本拒绝(ADR-TP-10 同路径冲突、non-fast-forward、墓碑、缺失路径)→ 重新规划并重新鉴权。带[code:…]的错误(路径策略、4xx)与其它错误立即返回,重试判定不对用户路径做子串匹配。至多 10 轮(线性退避,末轮不等待)。每次尝试的.gitkeep内容唯一,两个并发尝试不会构造出相同的 commit 而被队列当作同一操作重放。由此,一次开通创建的层级恒为某一轮已鉴权的最高新建组件及其下层;并发开通同一路径时恰有一个请求created: true,其余在重新规划后得到created: false。
鉴权按最高新建组件:只覆盖 /project/team/x 的 token 不能顺带创建尚不存在的容器 /project/team(403);容器由覆盖它的 token 开通之后,同一 token 即可开通 /project/team/x。每轮重新鉴权,且落地与本轮计划绑定(见上),落地重试不会扩大写范围。
错误:400 MONO_PATH_NOT_ALLOWED(根外路径、import_dir 命名空间)/ MONO_PATH_INVALID;401 / 403(token);409 MONO_PATH_CONFLICT(组件是文件)/ MONO_PATH_UNINITIALIZED。错误文本即 PathPolicyError 原文(../errors.md),经 CommonResult 返回。运行时 OpenAPI 声明 200 / 400 / 401 / 403 / 409(path_provision_openapi)。请求体本身无法解析时(缺 path、非 JSON、Content-Type 不是 application/json)由 axum 的 Json 提取器直接返回纯文本 4xx(422 / 400 / 415),不是 CommonResult,也不在 OpenAPI 中声明。
已知限制(登记于 plan-20260923.md):
- 嵌套
import_dir(如/third-party/vendor)时,在其严格祖先根(/third-party)之下开通返回 409MONO_PATH_UNINITIALIZED;该消息提示的开通动作正是本端点,此时提示是循环的——祖先根永不物化(GC-FU-04),目前只能用git push直接创建更深的路径(DEFER-FU-13)。 root_dirs中新增、但初始化时尚未创建的一级根(改root_dirs并重启之后)无法经本端点开通:唯一可能的落点是/,产品写从不在/落地,返回 400MONO_PATH_NOT_ALLOWED(点名/)。新增根需先经git push创建(DEFER-FU-14)。
| 情况 | 今日实况 / 落地要求 |
|---|---|
| 目录重复名 | 今日 create-entry 返回 500 + err_message:"Internal server error"。GitError::CustomError("Duplicate name") 没有 [code:] 前缀(mono_api_service.rs:2203、:2344),落到 ApiError::internal(common/errors/api.rs),而 IntoResponse 对 5xx 一律改写为 "Internal server error"。LB-02/03 的 delete/move 必须用 [code:400] 前缀返回可诊断 4xx,不得复制这个缺陷 |
is_directory=false 缺 content |
同上,今日为 500("content is required for file creation" 亦无 [code:] 前缀,mono_api_service.rs:2121) |
| tag 名非法 | 今日已是 400(tag_router.rs 的 validate_tag_name → ApiError::bad_request) |
| tag 已存在 | 400("[code:400] Tag '{}' already exists",mono_api_service.rs:1677/:1690) |
| tag 不存在(get) | 404;wire err_message = Tag '<name>' not found。由 router 直接构造(tag_router.rs:299-302),服务层 get_tag 只返回 Ok(None),不产生 [code:] |
| tag 不存在(delete) | 404;服务层抛 "[code:404] Tag not found"(mono_api_service.rs:1846),wire err_message = Tag not found(前缀被剥掉,见下) |
| delete-entry:目标是文件 | 400,wire err_message = '<name>' is not a directory(服务端 [code:400] 前缀被剥掉) |
| delete-entry:缺父目录 / 缺目标 | 404,parent directory <path> not found / entry '<name>' not found under <path> |
| delete-entry:父路径穿过一个文件 | 400,parent path <path> is not a directory |
delete-entry:path/name 不合规(未 rooted、./../空组件、分隔符、控制字符) |
400,validate_entry_target 的诊断原文 |
delete-entry:ImportRepo(import_dir 下) |
目标在存活 ImportRepo 内部(router 按 git_repo 分派给 ImportApiService):409,import dir does not support delete entry;其余 import_dir 命名空间目标(import_dir 本身、从父目录删除挂载叶子——不论该仓库是否存活、已无存活 git_repo 行的路径;嵌套 import_dir 时还包括其严格祖先):400 MONO_PATH_NOT_ALLOWED: …(plan-20260923 ADR-FU-06,两种形态均适用) |
| move-entry:源目标相同 | 400,source and destination are the same: <path> |
| move-entry:目标名已存在(任何 mode) | 400,'<to_name>' already exists under <to_path> |
| move-entry:移进自己的子树 | 400,cannot move <src> into its own subtree <to_path> |
move-entry:源是文件(省略 / is_directory=true) |
400,'<from_name>' is not a directory |
move-entry:源是目录且 is_directory=false |
400,'<from_name>' is not a file |
move-entry:from_path/from_name/to_path/to_name 不合规 |
400,validate_entry_target 的诊断原文 |
| move-entry:源父 / 目标父不存在;源不存在 | 404,source parent <path> not found / destination parent <path> not found / entry '<name>' not found under <path> |
| move-entry:源父 / 目标父路径穿过文件 | 400,source parent path <path> is not a directory / destination parent path <path> is not a directory |
| move-entry:源或目标在 ImportRepo 下 | 源在存活 ImportRepo 内部(分派给 ImportApiService)或目标父是存活 ImportRepo:409,import dir does not support move entry;其余源 / 目标落在 import_dir 命名空间(嵌套 import_dir 时源还包括其严格祖先):400 MONO_PATH_NOT_ALLOWED: …(ADR-FU-06,两种形态均适用) |
create-entry / edit-save:import_dir 命名空间内、无存活 ImportRepo 的路径 |
400 MONO_PATH_NOT_ALLOWED: …(ADR-FU-06,两种形态均适用;存活 ImportRepo 内部的写由 ImportApiService 处理;处理期间该仓库被清理见下一行) |
ImportRepo 产品写遇清理(edit/save、POST /tags、DELETE /tags/{name};请求已分派给存活 ImportRepo 的 ImportApiService,处理期间该仓库被 detach,plan-20260923 ADR-FU-09 第 5 条,FU-19) |
409,wire err_message = IMPORT_REPO_REMOVED: "<path>" was removed; push again to import it anew(码与含义见 错误码 的 ImportRepoError 小节)。本次写不落行:edit/save 的对象行经 save_entry 栅栏,默认分支 ref 在自己的带锁事务里写(二者之间遇 detach 时,已提交的对象行由清扫删除);annotated tag 的 git_tag 行与 ref 同一事务(ref 写失败时两者皆无),lightweight tag 只写 ref;tag 删除的 ref 与 git_tag 行同一事务,被拒的删除不改任何行(含不存在的名字)。预读因行已被删除而失败时同样答 409,存活仓库的 404 / 400 不受影响(存活仓库缺默认分支 / commit / tree 为 404)。detach 在分派之前已提交的请求不再到达 ImportApiService:edit/save 为上一行的 400 MONO_PATH_NOT_ALLOWED,tag 路由按 Monorepo 语义处理(计划 DEFER-FU-38)。事务的锁序与 detach 等待时长见 trunk-push.md 的「receive-pack 写路径存活栅栏」 |
| trunk 全新栈:写路径无非根 tip | 合法路径落在惰性物化的一级根上;首组件不在 root_dirs 中(含在 / 下新建顶层)→ 400 MONO_PATH_NOT_ALLOWED,点名用户写的路径;跨顶层 move 与删除顶层目录(落点只能是 /)→ 400 MONO_PATH_NOT_ALLOWED,点名 /;嵌套 import_dir 的严格祖先根 → 409 MONO_PATH_UNINITIALIZED,点名写入所在目录(新建目录时为该目录本身)(ADR-FU-06) |
| 缺目录 | delete / move 与 GET /tree 均返回 404(ADR-LB-03、FIX-BB-10);GET /tree 的路径穿过或指向文件也返回 404。create-entry 会自动补建缺失的父层级而不是 404 |
| 鉴权失败 | 见「鉴权」 |
[code:NNN]前缀是本仓真正的 4xx 约定(mono_api_service.rs:1097/:1677/:1690/:1846都在用)。缺前缀的CustomError会静默变成 500 并丢失原文。该前缀只是服务端内部标记,不会出现在 wire 上。
ApiError的IntoResponse与map_ceres_error(均在common/errors/api.rs)都会在写入err_message前把它剥掉;map_ceres_error只识别400、404与409(409自 FU-19 起),其余为 500。所以上表引号里的服务端字面量与调用方实际收到的err_message不同:例如 tag 重名的 wire 值是Tag 'foo' already exists,不含[code:400]。调用方不要按该前缀做字符串匹配。
服务层 MonoApiService 的 create_tag / list_tags / get_tag / delete_tag 已实现;LB-04 起 storage_only_routers_with(src/api/api_router.rs)merge 了 tag_router::routers()(tag_router.rs 的 routers),四条路由在 storage-only / trunk 与 Review 上都可用(storage-only OpenAPI 由 server::http_server::tests::storage_only_openapi_* 与 tag_router::tag_routes_registered_on_storage_only_routers 锁定)。trunk 写鉴权见「鉴权」。
Git 客户端 push Tag 仍然禁止(见使用指南);Tag 只能通过本节 HTTP API 管理。
CreateTagRequest:
| 字段 | 规则 |
|---|---|
name |
必填。服务端校验(validate_tag_name,tag_router.rs:96-141,违反即 400):非空;name.len() <= 255(字节,非字符);不含 ..;不含 @{;不含 //;不以 .lock 结尾;不含禁用字符 —— ASCII 空格、~、^、:、?、*、[、\(tag_router.rs:125 的 forbidden 数组,逐字为 [' ', '~', '^', ':', '?', '*', '[', '\\']);不含 NUL 与任何 char::is_control() 字符 |
target |
可选;serde alias target_commit |
path_context |
可选;省略则 handler 用 /(也是 trunk 鉴权 path) |
tagger_name / tagger_email / message |
可选 |
无请求键
tagger——tagger只是TagResponse的响应字符串。
成功:200 + CommonResult<TagResponse>。
OpenAPI 与运行时一致为 200:handler 返回
Json<CommonResult<TagResponse>>(tag_router.rs:166),LB-04 把 utoipa 注解从 201 改为 200(tag_router.rs:158);模块级回归tag_create_openapi_status_is_200与 ITtag_create_unauth_401(运行时/api/openapi.json)都断言200在、201不在。
落地后方法是 GET,不是 POST。 查询串为三个必填键:
| 键 | 规则 |
|---|---|
page |
必填 u64。从 1 起(内部 page.saturating_sub(1),故 page=0 等同 page=1) |
per_page |
必填 u64,必须 ≥ 1。handler 在分页前拒绝 per_page=0,返回 400 + CommonResult([code:400] per_page must be >= 1)。禁止把 0 交给 sea-orm paginate |
path |
必填。path context;trim().is_empty() 视同 /。调用方列 root 应显式送 path=/ |
缺键或非法数字(含 page=abc、缺 path)由 axum 0.8 Query<T> 在进 handler 前返回 400 FailedToDeserializeQueryString(不是 422)。
成功:200 + CommonResult<TagListResponse>,其中 TagListResponse = CommonPage<TagResponse> = { "total": <u64>, "items": [ TagResponse… ] }。
对该路径发 POST 必须 405。调用方改走 GET /api/v1/tags/list?page=&per_page=&path=。不再接受 JSON PageParams<String>。该路径没有成功体。
total= DB 里符合过滤的注解 tag 数 加上本次请求扫描到、且已扣除与本页 annotated 重名后的全部 lightweight ref 数。注意该加数在.take(need)之前就已算出,所以其中可能包含并未进入本页items的 refs。因此total随页而变,不是稳定的全局计数;分页请以items长度与per_page判断,不要把total当权威总量。注解 tag 按mega_tag.path过滤,轻量 ref 按mega_refs.path过滤。
两条路由都带查询键 path(可选;省略或空 / 空白 = /)。查找键是 (path, name),不是全局 name。delete 的 trunk 鉴权 path = 该选择器 path。create 写入 mega_tag.path;list 按 path 过滤注解 tag 与轻量 ref。
- get 成功:200 +
CommonResult<TagResponse>;该 path 下 tag 不存在 → 404。 - delete 成功:200 +
CommonResult<DeleteTagResponse>({ deleted_tag, message });该 path 下 tag 不存在 → 404。
TagResponse 字段:name、tag_id、object_id、object_type、tagger、message、created_at。七个字段全部是非 Option 的 String(ceres/model/tag.rs:37-52),键始终存在;created_at 是字符串不是数值时间戳;list / get 回传的 lightweight tag tagger 与 message 为空串(mono_api_service.rs:1755-1756、:1803-1804);create 的回应里 lightweight tag 的 tagger 是 tagger_name / tagger_email 的组合、两者都缺省时为 unknown(:1661-1666、:2668),message 为空串。
用途。 清理 import_dir 之下的一个 ImportRepo 叶子:经写入队列 detach(B3 op = detach:删除 git_repo 与 import_refs 行、从根树移除挂载叶子),再在本请求的预算内清扫对象行。机制见 trunk-push.md 与 ../plan/plan-20260923.md 的 ADR-FU-09 / ADR-FU-10,本节只写 HTTP 契约。只挂 storage-only(storage_only_routers_with 合并 import_repo_router::routers();单测 api::router::tests::import_repo_remove_is_storage_only 锁定 Review 不挂)。
请求。 新操作只给 path;续做再给上一次答复中的 cleanup_id:
{ "path": "/third-party/acme/lib" }{ "path": "/third-party/acme/lib", "cleanup_id": 7340032 }| 字段 | 类型 | 说明 |
|---|---|---|
path |
string,必填 | 已注册 ImportRepo 的存储路径:规范形式、组件级严格位于 import_dir 之下。拒绝 NUL、\、相对路径、. 与 .. 段、重复或结尾斜杠、首尾空白、import_dir 本身与其外的路径;NUL 以外的控制字符不拒绝(计划 DEFER-FU-44)。.git 是普通字符:Git URL …/lib.git 对应的 path 是 /third-party/acme/lib |
cleanup_id |
i64,可选 | 上一次答复中的 cleanup_id(同一 path);省略或 null 为新操作 |
未知字段(例如拼错的 cleanupId)→ axum 纯文本 422,以免续做被当成新操作。
判定顺序。
- axum
Json提取器:Content-Type、JSON 语法与字段类型(纯文本 400 / 413 / 415 / 422,不是CommonResult)。 - 严格路径校验 → 400
IMPORT_REPO_PATH_INVALID:先于鉴权,只回显调用方输入与公开的import_dir(Git 面相反,先 401 / 403)。 - 鉴权(
authorize_import_repo_removal,见「鉴权」)→ 固定体 401 / 403。 - 清理入口(FU-17):所有查询都在此之后,全部为绑定参数——仓库按精确路径查找,子仓检查为转义的前缀扫描,台账按 id 或
(path, state)读取。
成功体。 data 四键恒在:
{ "req_result": true, "data": { "path": "/third-party/acme/lib", "outcome": "removed", "repo_id": 7340037, "cleanup_id": 7340032 }, "err_message": "" }repo_id 与 cleanup_id 为 64 位整数,可能超过 2^53(JavaScript 客户端须用 BigInt);outcome = "absent" 时两者为 null。
结局(在请求结束时判定)。
| 请求 | 结局 |
|---|---|
新操作,path 有存活仓库 |
先做无写子仓预检(有子仓 → 409,不入队);detach 得到新 cleanup_id,先清扫本请求自己的台账行:完成 → removed(本行 id;剩余预算续做同路径更早的待续行,但不改变答复);超出预算 → pending(本行 id) |
新操作,path 无存活仓库 |
按 id 续做至多 16 行待续台账(至多 100 条清扫语句):已无剩余 → absent(两 id 为 null,即使本请求做了清扫);否则 pending,指名最小的剩余行(超过 16 行时可能是本请求未触及的行) |
续做(带 cleanup_id) |
只处理该台账行,永不 detach:不存在或属于另一路径 → 404(同一文本);已清扫或本次完成 → removed(幂等);否则 pending(同 id) |
repo_id 恒为该台账行的仓库。removed 不等于该路径已排空:更早的待续行由之后只带 path 的请求续做(计划 DEFER-FU-06)。
客户端协议。
- 遇
pending:带返回的cleanup_id续做,直到removed。续做对同路径的重新导入安全。 - 要排空路径:重复只带
path的请求,直到absent。只带path的请求会 detach 当时存活的仓库,包括期间重新导入的仓库。 - 丢失
cleanup_id且没有重新导入时,只带path的请求会续做待续行:在本次预算内清扫完则答absent(两 id 为null),否则以pending返回最小的待续cleanup_id。 - 500 可重试(见下表);续做重试总是安全的,只带
path的重试会 detach 期间重新导入的仓库。
错误。
| 情况 | 状态 | err_message / 体 |
|---|---|---|
| Review 形态 | 404 | 路由未挂载 |
| 非 POST | 405 | 空体,allow: POST |
缺 Content-Type: application/json / JSON 语法错 / 缺 path、字段类型错、未知字段 / 超过默认 2 MiB |
415 / 400 / 422 / 413 | axum 纯文本(不是 CommonResult) |
| 严格路径校验失败 | 400 | IMPORT_REPO_PATH_INVALID: "<输入>": <原因>(import_dir 本身、在其之外、非规范形式时 did you mean "<规范形式>"?、NUL、\) |
push_auth=token,无凭据或 token 未知 |
401 | 固定体 authentication required |
token 未覆盖 path;或 push_auth=none / 未配置 |
403 | 固定体 forbidden |
续做的 cleanup_id 不存在或属于另一路径 |
404 | IMPORT_REPO_CLEANUP_NOT_FOUND: no cleanup "<id>" for "<path>" |
| 目标之下仍有已注册 ImportRepo | 409 | IMPORT_REPO_HAS_CHILDREN: "<path>" contains other ImportRepos; remove them first |
| 写入队列暂停 / 硬停 / 满、detach 轮次未完成、存储错误 | 500 | Internal server error,可重试(计划 DEFER-FU-42) |
400 / 401 / 403 / 404 不写任何行;409 不写台账、审计、ref、对象与根树(入口的无写预检不入队;由写入队列锁内再检拒绝时留下一行 Failed 队列行)。未授权调用方对「不存在 / 叶子 / 有子仓的父」三类路径得到逐字节相同的体(响应头除 Date 与按请求的 x-request-id 外一致,无 WWW-Authenticate),error 日志只含固定文本。码的含义见 错误码 的 ImportRepoError 小节;运行时 OpenAPI 声明 200 / 400 / 401 / 403 / 404 / 409 / 500(IT import_repo_remove_openapi)。
保留策略。
- 对象存储中的 blob 字节保留(计划
DEFER-FU-01);清扫删除git_commit、git_tree、git_blob、git_tag行。 - detach 删除
git_repo与import_refs行,从根树移除挂载叶子,并修剪import_dir之下因此变空的、由挂载创建的目录(ADR-FU-09 第 2 条)。 - 台账
import_repo_cleanups与audit_logs(kind = import_repo.remove,phase为detached/swept,requester为 token 名)永久保留。 - 清理不可逆;再次推送即以新的
repo_id重新导入。
已知限制。
absent不等于该路径无人服务:存活 ImportRepo 的子路径由父仓库服务;子仓被清理后,该路径的 tag 与编辑路由作用于存活的父仓库,而 Git clone 该路径为 404(计划DEFER-FU-39)。- 被清理路径上的
POST /api/v1/tags按 Monorepo 语义处理,可在import_dir之下写出 Monorepo tag(计划DEFER-FU-38)。 - 清理只删除规范行:FU-15 保留的别名行(如
P/)不删、不算子仓、严格入口无法点名,之后以别名拼写的 Git 请求会使P重新存活(计划DEFER-FU-10)。 - 子仓检查为顺序扫描(
DEFER-FU-27);请求可能较长、无服务端超时,断连按崩溃点语义恢复(DEFER-FU-32);import_dir本身的存量行无法点名(DEFER-FU-09);400 回显输入的长度与控制字符见DEFER-FU-43/DEFER-FU-44。
目录变更写复用既有 authorize_trunk_api_write(src/api/api_write_auth.rs),与 LFS / create-entry 同一威胁模型;ImportRepo 清理端点例外,用更严格的 authorize_import_repo_removal(见「ImportRepo 叶子清理」)。
整节的形态前提: 这道闸仅在
push_policy=trunk(含 storage-only)时生效。Review 形态下trunk_write_requester返回Ok(None)并跳过鉴权(preview_router.rs:475-485;LB-04 起为pub(crate),tag_router复用同一实现),目录变更走既有 CL 分支,tag 写沿用 Review 既有面。
GET /tree、GET /tags/list、GET /tags/{name} —— 调用方不送、server 不要求 Authorization。
| 路由 | 状态 |
|---|---|
POST /create-entry |
implemented——今日确实鉴权(preview_router.rs:114-117 取 HeaderMap 并调 trunk_write_requester) |
POST /delete-entry |
implemented(LB-02)——delete_entry(preview_router.rs:138)取 HeaderMap 并调 trunk_write_requester(path = 父目录),鉴权先于任何存储访问 |
POST /move-entry |
implemented(LB-03)——move_entry(preview_router.rs:166)对 from_path 与 to_path 各调一次 trunk_write_requester,任一失败即拒绝,先于任何存储访问 |
POST /path/provision |
implemented(plan-20260923 FU-07)——先以目标路径调用 authorize_trunk_api_write(先于任何树读取),再每轮以最高新建组件重新鉴权,见该小节 |
POST /import-repo/remove |
implemented(plan-20260923 FU-20)——严格路径校验之后、任何查询之前以 authorize_import_repo_removal 鉴权;只接受覆盖目标路径的 token;push_auth=none 或未配置一律 403;401 / 403 为固定体 |
POST /tags / DELETE /tags/{name} |
implemented(LB-04 / FT-06)——create_tag 以 path_context.unwrap_or("/")、delete_tag 以选择器 path(省略或空 = /)各调一次 trunk_write_requester,先于任何存储访问 |
落地状态(LB-04,安全相关):
create_tag(tag_router.rs:162)与delete_tag(:321)都接收HeaderMap,并在任何存储访问之前调用trunk_write_requester;这关闭了计划里的GAP-LB-04(裸挂会让token部署匿名写refs/tags)。Review 形态该函数返回Ok(None),tag 写沿用 Review 既有面(cedar_guard 只覆盖/cl,不覆盖/tags)——本卡不为 Review 新增 401(ITtag_review_form_no_trunk_gate)。
trunk / storage-only 上的 push_auth 行为(IT tag_create_unauth_401 / tag_delete_unauth_401 / tag_auth_none_write_ok):
push_auth |
行为 |
|---|---|
token |
Bearer,或 Basic 的密码栏 |
none |
无 header 即可写(目录写把 requester 记为 anonymous 进 push_queue;tag 写不记录 requester,两个 handler 丢弃返回值)(POST /import-repo/remove 除外:一律 403) |
状态码分类(api_write_auth.rs 的 authorize_trunk_api_write):
- 凭据缺失,或提供了但查不到对应 push token → 401(两者都走
api_write_auth_challenge) - 凭据识别成功、但
paths未覆盖目标 path → 403 push_auth未配置(None)→ 401,fail-closed- 例外:
POST /import-repo/remove在push_auth不为token(含未配置)时一律 403;两种拒绝都只用固定体(401authentication required、403forbidden),不回显路径
错误响应、JSON 与 trace 不得回显 token。
本小节描述 trunk / storage-only 形态(IT
tag_write_path_scoped_token_403);Review 形态不经这道闸。
tag 写的鉴权 path 不是你操作的业务路径:
- create 用
path_context.as_deref().unwrap_or("/");path_context = "/project"之类被 token 覆盖的 path 可过闸,tag 落在该 path 的mega_refs/mega_tag.path下; - delete 用查询
path(省略或空 =/)。覆盖/project的 token 可删该 path 的 tag;删 root tag 仍需覆盖/。
因此 push_tokens.paths = ["/project"] 这类不含 / 的 token:
- 对省略
path(即/)的 tag delete → 403;对?path=/project→ 可过闸; - 对省略或显式
path_context = "/"的 create → 403。
因此:省略 path 的 delete 与 root / 缺省 path_context 的 create 必须持有能覆盖 / 的 token(paths 省略或为空 = whole repo);?path=/project 的 delete 与 非根 path_context 的 create 可用覆盖该 path 的 token。Libra pin 八行见下节(FT-08 已重钉)。
契约页不写真实凭据;示例一律用
secret-ok一类占位。
| 形态 | 目录变更落地 | cl_link |
|---|---|---|
| trunk / storage-only | land_api_tip_push |
必为 null |
| Review | 既有 find_or_create_cl_for_edit CL 分支 |
可为非 null |
POST /path/provision 只挂 storage-only(preview_router::storage_only_write_routers,由 storage_only_routers_with 合并;单测 path_provision_is_storage_only 锁定 Review 的 routers() 不含它)。POST /import-repo/remove 同样只挂 storage-only(import_repo_router::routers(),单测 import_repo_remove_is_storage_only)。write_routers(preview_router.rs)同时挂在 Review 与 trunk;LB-02 / LB-03 已把 delete-entry 与 move-entry 登记进同一函数,因此两者在两种形态下都可用(storage-only OpenAPI 由 server::http_server::tests::storage_only_openapi_* 锁定)。tag_router::routers() 同样两边都挂:Review 的 routers() 原本就有,LB-04 把它 merge 进 storage_only_routers_with(api_router.rs:83);三份 OpenAPI 锁(storage-only / trunk / OAuth)都断言 /tags、/tags/list、/tags/{name}。
ImportRepo(import_dir 下)对 delete/move 必须返回 409(计划门 delete_entry_reject_import_repo_409,见 plan-20260917 LB-02 卡「判据规范 — EX-LB-01」门 11;该门只规定状态码)。LB-02 的 delete_monorepo_entry(import_api_service.rs)返回 [code:409] import dir does not support delete entry,wire 上 err_message = import dir does not support delete entry;LB-03 的 move 同样:源在 ImportRepo 下由 ImportApiService 返回 [code:409] import dir does not support move entry,目标在 ImportRepo 下由 monorepo handler 自查 git_repo 后返回同一文本。实现上必须带 [code:409] 前缀——这是推导出来的必要条件,不是计划原文:GitError::CustomError 只有带该前缀才会被 common/errors/api.rs 中 From<E> for ApiError 的 409 分支映射成 StatusCode::CONFLICT。另有一个陷阱: map_ceres_error(common/errors/api.rs)FU-19 之前只识别 400 与 404,FU-19 起增加 409(ImportRepo tag 写的 IMPORT_REPO_REMOVED),其余一律 500,而它正是 tag_router.rs 的惯用写法。因此 delete/move 走裸 ?(From<E> for ApiError,如 preview_router 的 delete_entry)那条路径;FU-19 之前若用 map_ceres_error 包装,带 [code:409] 也会落成 500;delete/move 仍走裸 ?。今日 create-entry 的同类拒绝(ImportApiService::create_monorepo_entry 的 CustomError("import dir does not support create entry"))没有前缀,因而落成 500 + "Internal server error"——见「错误映射」,delete/move 不得复制该缺陷。其 Git 多分支与客户端 tag 语义不受本计划影响。
目录变更成功后,对同一 path tip 的 git clone / git fetch + git pull 必须看到删除结果或新路径。
tag create/delete 之后,GET /tags/{name} 与 GET /tags/list 必须在同一 path 下一致。
- 不预览/编辑 blob;不把建文件从既有
POST /create-entry(is_directory=false)拆到新路径。 - 不改
GET /tree/POST /create-entry/POST /edit/save的既有请求字段或成功语义。 - 不以 Git receive-pack delete command、parent-path 客户端 push 或 CL
apply_changes冒充产品 HTTP。 - 不新增
DELETE /tags/delete-file/POST /move-file之类平行路径;文件删移扩既有delete-entry/move-entry。 - 不把 GET list 做成「GET+POST 双挂」窗口(FT-04 卸 POST,对该路径 POST → 405)。
- 不校验 tag
targetcommit 是否属于该 path 子树(DEFER-FT-02/DEFER-LB-11目标归属)。 - 不回收被删/移入口后的 blob / LFS / Media 物件(
DEFER-FT-01)。 - 不接入 Review OAuth / Cedar enforce;不为 storage-only 打开 SSH receive-pack。
- 不实现 Libra 客户端(
DEP-LB-03/DEFER-FT-03)。 - 本页 Libra pin 八行由 FT-08 重钉到 FT-07 tip(
v0.11.3/75ca189)。
供 Libra 计划(
../libra/docs/development/plan/plan-20260912.md)把DEP-MB-04(delete / move)与DEP-MB-05(四条/tags*)覆盖的表面从 outgoing 改为 incoming,并吸收 plan-20260918 的破坏性 list 改 GET、is_directory、以及?path=。tree 与 create-entry 两行供重核 Libra 既有的 pin。行号以上述 revision 为准;本卡不改../libra/**。
| # | 方法与路径 | 成功 HTTP | 鉴权(trunk / storage-only) | 请求字段 | 成功 data 字段 |
本仓 file:line |
|---|---|---|---|---|---|---|
| 1 | GET /api/v1/tree?path=<dir> |
200;路径不存在或指向非目录 404 | 不要求 Authorization | query CodePreviewQuery:path(可选,缺省 /)、refs(可选,缺省空串 = 当前 tip;可为 40 位完整 commit SHA 或 tag 名);没有 oid 键(oid 属于另一条路由 GET /api/v1/file/tree 的 TreeQuery——api_router.rs 以 .route() 直挂、不进 OpenAPI,与 /tree 无关) |
TreeResponse:tree_items[](TreeBriefItem:name、path、content_type)与 file_tree(map:祖先路径 → FileTreeItem { tree_items, total_count },仅辅助结构,见上文 GET /tree 节);没有顶层 total_count |
preview_router.rs:266(path)/ :276 get_tree_info;git.rs:53 CodePreviewQuery、:155 TreeBriefItem、:514 TreeResponse |
| 2 | POST /api/v1/create-entry |
200 | push_auth(token:Bearer / Basic 密码栏;none:无 header) |
CreateEntryInfo:is_directory、name、path;可选 content、author_username、author_email、skip_build(默认 false)、mode(EditCLMode,serde snake_case 外部标签枚举:"force_create" 或 {"try_reuse": <cl_link 或 null>},缺省 try_reuse(null);只影响 Review 形态的 CL 复用,trunk 忽略) |
CreateEntryResult:commit_id、new_oid、path、cl_link(trunk 必为 null) |
preview_router.rs:112;git.rs:13 / :239 |
| 3 | POST /api/v1/delete-entry |
200 | 同上;鉴权 path = path(父目录) |
DeleteEntryInfo:path、name;可选 is_directory(bool,default_is_directory → true;false 删文件)、author_username、skip_build(默认 false;Libra 送 true) |
DeleteEntryResult:commit_id、path、cl_link(trunk null);无 new_oid |
preview_router.rs:138;git.rs:253 / :280 |
| 4 | POST /api/v1/move-entry |
200 | 同上;from_path 与 to_path 各鉴权一次 |
MoveEntryInfo:from_path、from_name、to_path、to_name;可选 is_directory(同 delete:缺省 true;false 移文件并保留同一 blob oid)、author_username、skip_build(默认 false;Libra 送 true) |
MoveEntryResult:commit_id、from_path、to_path、cl_link(trunk null);无 new_oid |
preview_router.rs:166;git.rs:293 / :339 |
| 5 | POST /api/v1/tags |
200(不是 201) | push_auth;鉴权 path = path_context(缺省 /) |
CreateTagRequest:name;可选 target(alias target_commit)、path_context、tagger_name、tagger_email、message(非空 message 即注解 tag,空串按轻量 tag 处理) |
TagResponse:name、tag_id、object_id、object_type、tagger、message、created_at(全为字符串) |
tag_router.rs:165;tag.rs:19 / :37 |
| 6 | GET /api/v1/tags/list |
200 | 不要求 Authorization | query TagListQuery 三键必填:page(u64,从 1 起)、per_page(u64,必须 ≥ 1)、path(path context;空 / 空白视同 /)。缺键或非法数字 → axum 0.8 Query 400 纯文本。per_page=0 → handler 400 + CommonResult。对该路径 POST → 405 |
TagListResponse:total、items[](TagResponse) |
tag_router.rs:226;tag.rs:60 |
| 7 | GET /api/v1/tags/{name}?path= |
200;该 path 下不存在 404 | 不要求 Authorization | 路径参数 name;可选 query ?path=(TagPathQuery;省略或空 / 空白 = /)。查找键 (path, name) |
TagResponse |
tag_router.rs:283;tag.rs:69 |
| 8 | DELETE /api/v1/tags/{name}?path= |
200;该 path 下不存在 404 | push_auth;鉴权 path = 选择器 path(省略或空 = /)。覆盖 /project 的 token 可删该 path 的 tag;删 root tag 仍需覆盖 / |
路径参数 name;可选 query ?path=(同 get) |
DeleteTagResponse:deleted_tag、message |
tag_router.rs:332;tag.rs:82 |
Libra 必须按以下事实实现,不得反向假设:
- 挂载锚点(Libra 的 incoming 条件要求):delete-entry / move-entry 登记在 storage-only 与 Review 共用的
write_routers(preview_router.rs:57-63);四条/tags*由storage_only_routers_withmergetag_router::routers()(api_router.rs:83),三份 OpenAPI 锁与运行时/api/openapi.json都含它们。list 在 OpenAPI 上只登记 GET。 - list 是唯一 GET;
page、per_page、path三键必填。POST /tags/list→ 405。per_page=0是 handler 400 +CommonResult(不再 panic)。缺 query 键是 extractor 400 纯文本,不是 422。 - get / delete 有 path 选择器
?path=(省略或空 =/)。查找、create 重名与 list 注解过滤都是(path, name)(隔离已落地)。delete 的鉴权 path = 该选择器,不是固定/。 - trunk / storage-only 上目录写与 tag 写都不建 CL:目录写(create / delete / move)回应的
cl_link必为null,tag 回应没有cl_link键;delete / move 的回应没有new_oid,以commit_id为凭。 - handler 的成功回应与应用错误(
ApiError)外层是CommonResult(req_result、data、err_message);err_message不含[code:NNN]前缀。例外: axumQuery/Jsonextractor 的拒绝在 handler 之前以纯文本回应,不经ApiError、没有CommonResult外层:缺 query 键 / 非法数字(如GET /tags/list少path)→ 400;JSON 语法非法 → 400;Content-Type 不对 → 415。Libra 解析前先看状态码,且 400 既可能是应用错误(带外层)也可能是 extractor 的纯文本拒绝。 - 错误码分类:401(无凭据 / 凭据不识别)、403(token
paths未覆盖鉴权 path)、400(校验 / 目标错误 / 错 mode;或 extractor 的纯文本拒绝,见上)、404(父 tree 完全无名 / 该 path 下 tag 不存在)、405(POST /tags/list)、409(ImportRepo 下的 delete / move;ImportRepo 在 edit-save 或 tag 写处理期间被清理时的IMPORT_REPO_REMOVED)。另: create-entry 的目录重名与is_directory=false缺content今日是 500(见「错误映射」),不得按 400 假设。
建议 Libra 侧动作:把 DEP-MB-04 / DEP-MB-05 由 outgoing 改为 incoming(引用本节的 revision 与行号),并据此重核 MB-07 与 MB-10(list 必须改 GET + 三 query;delete/move 接受 is_directory;get/delete 带 ?path=;delete 鉴权 path = 选择器;cl_link / new_oid 不变)。
../user-guide.zh.md—— 用户可见规则:Tag 通过 HTTP API 管理,Git 客户端不能推送 Tag。本页以 API 契约和 OpenAPI 定义为准。../plan/plan-20260918.md—— 文件删移、GET list、path 级 tag 跟进../deploy-trunk.md—— storage-only 运维手册与产品 API 写契约../plan/plan-20260904.md—— create-entry / edit/save +push_auth+land_api_tip_push的来源计划integration.md—— 集成测试与黑盒矩阵../errors.md—— 错误码(ImportRepoError:IMPORT_REPO_PATH_INVALID、IMPORT_REPO_HAS_CHILDREN、IMPORT_REPO_CLEANUP_NOT_FOUND)trunk-push.md—— ImportRepo 的 detach、清扫与存活栅栏