Skip to content

Commit ea07b5f

Browse files
committed
FIX-BB-10: return 404 for unknown tree paths
Signed-off-by: Eli Ma <eli@patch.sh>
1 parent 27c66f6 commit ea07b5f

7 files changed

Lines changed: 209 additions & 37 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mega2"
3-
version = "0.41.57"
3+
version = "0.41.58"
44
edition = "2024"
55
license = "MIT OR Apache-2.0"
66
description = "Monorepo engine and Git hosting server for AI agents: trunk-based storage, Git Smart HTTP, Git LFS, OCI registry, and Agent Session Capture"

‎docs/plan/plan-20261001.md‎

Lines changed: 136 additions & 29 deletions
Large diffs are not rendered by default.

‎docs/refactoring/directory-entry-api.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,14 @@
4141

4242
## 范围
4343

44-
- **只 pin 不改语义:** `GET /tree`、`POST /create-entry`。
44+
- **只 pin 不改语义:** `POST /create-entry`。
45+
- **错误语义修正(FIX-BB-10):** GET /tree:路径不存在或指向非目录时返回 **404**;已有目录的请求字段与成功响应不变。
4546
- **新增产品写:** `POST /delete-entry`、`POST /move-entry`(改名 = 同 parent 的 move)。plan-20260918 用同一路径扩文件(`is_directory=false`)。
4647
- **挂载 + 鉴权 + 文档对齐:** 四条 `/tags*`。plan-20260918 把 list 改 GET,并为 get/delete 加 `?path=`。
4748
- **ImportRepo 叶子清理(plan-20260923 FU-20):** `POST /import-repo/remove`,见「ImportRepo 叶子清理」。
4849
- 目录/文件变更是**父目录 tree 改写**后写新 commit,经 `land_api_tip_push`(trunk)或既有 CL 分支(Review)前进 tip;**不是** Git delete command,也不走 CL `apply_changes`(ADR-LB-02 / ADR-FT-01)。
4950

50-
## 已有 API(本计划不改语义)
51+
## 已有 API 与错误语义修正
5152

5253
### `GET /api/v1/tree` — `implemented`
5354

@@ -56,6 +57,7 @@
5657
| 鉴权 | 无(不送 Authorization) |
5758
| Query | `path`(可选,`#[serde(default = "default_path")]` → `/`)、`refs`(可选,`#[serde(default)]` → 空串 = 当前 tip) |
5859
| 成功 | `200` + `CommonResult<TreeResponse>` |
60+
| 路径不存在或指向非目录 | `404` + 失败 `CommonResult`(`req_result=false`、`data=null`、`err_message` 点名请求路径或首个缺失前缀) |
5961

6062
`data.tree_items[]` 每项为 `{ name, path, content_type }`(`TreeBriefItem`)。
6163

@@ -216,7 +218,7 @@
216218
| create-entry / edit-save:`import_dir` 命名空间内、无存活 ImportRepo 的路径 | **400** `MONO_PATH_NOT_ALLOWED: …`(ADR-FU-06,两种形态均适用;存活 ImportRepo 内部的写由 `ImportApiService` 处理;处理期间该仓库被清理见下一行) |
217219
| ImportRepo 产品写遇清理(`edit/save`、`POST /tags`、`DELETE /tags/{name}`;请求已分派给存活 ImportRepo 的 `ImportApiService`,处理期间该仓库被 detach,[plan-20260923](../plan/plan-20260923.md) ADR-FU-09 第 5 条,FU-19) | **409**,wire `err_message` = `IMPORT_REPO_REMOVED: "<path>" was removed; push again to import it anew`(码与含义见 [错误码](../errors.md) 的 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](trunk-push.md) 的「receive-pack 写路径存活栅栏」 |
218220
| trunk 全新栈:写路径无非根 tip | 合法路径落在惰性物化的一级根上;首组件不在 `root_dirs` 中(含在 `/` 下新建顶层)→ **400** `MONO_PATH_NOT_ALLOWED`,点名用户写的路径;跨顶层 move 与删除顶层目录(落点只能是 `/`)→ **400** `MONO_PATH_NOT_ALLOWED`,点名 `/`;嵌套 `import_dir` 的严格祖先根 → **409** `MONO_PATH_UNINITIALIZED`,点名写入所在目录(新建目录时为该目录本身)(ADR-FU-06) |
219-
| 缺目录(delete / move 均已按此落地) | `404`(ADR-LB-03)。注意这**不是** `GET /tree` / create-entry 的今日行为:今日 `GET /tree` 对不存在的 path 返回 **200 + `tree_items: []`**(`search_tree_by_path` 取不到时返回 `Ok(None)`,`get_tree_info` 再把它映射成 `Ok(vec![])`——`tree_ops.rs:93`/`:98`/`:256`),而 `create-entry` 会**自动补建**缺失的父层级而不是 404 |
221+
| 缺目录 | delete / move 与 `GET /tree` 均返回 `404`(ADR-LB-03、FIX-BB-10);`GET /tree` 的路径穿过或指向文件也返回 404。`create-entry` 会**自动补建**缺失的父层级而不是 404 |
220222
| 鉴权失败 | 见「鉴权」 |
221223

222224
> `[code:NNN]` 前缀是本仓真正的 4xx 约定(`mono_api_service.rs:1097`/`:1677`/`:1690`/`:1846` 都在用)。缺前缀的 `CustomError` 会静默变成 500 并丢失原文。
@@ -453,7 +455,7 @@ tag create/delete 之后,`GET /tags/{name}` 与 `GET /tags/list` 必须在同
453455
454456
| # | 方法与路径 | 成功 HTTP | 鉴权(trunk / storage-only) | 请求字段 | 成功 `data` 字段 | 本仓 `file:line` |
455457
|---|---|---|---|---|---|---|
456-
| 1 | `GET /api/v1/tree?path=<dir>` | 200 | 不要求 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:226`(path)/ `:233` `get_tree_info`;`git.rs:53` `CodePreviewQuery`、`:155` `TreeBriefItem`、`:514` `TreeResponse` |
458+
| 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` |
457459
| 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` |
458460
| 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` |
459461
| 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` |

‎src/api/router/preview_router.rs‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,8 @@ async fn get_latest_commit(
268268
CodePreviewQuery
269269
),
270270
responses(
271-
(status = 200, body = CommonResult<TreeResponse>, content_type = "application/json")
271+
(status = 200, body = CommonResult<TreeResponse>, content_type = "application/json"),
272+
(status = 404, description = "Tree path not found", body = CommonResult<TreeResponse>, content_type = "application/json")
272273
),
273274
tag = CODE_PREVIEW
274275
)]
@@ -565,6 +566,23 @@ mod tests {
565566
}
566567
}
567568

569+
#[test]
570+
fn tree_openapi_documents_not_found() {
571+
let api = OpenApiRouter::with_openapi(ApiDoc::openapi())
572+
.merge(readonly_routers())
573+
.split_for_parts()
574+
.1;
575+
let get = api
576+
.paths
577+
.paths
578+
.get("/tree")
579+
.and_then(|item| item.get.as_ref())
580+
.expect("GET /tree");
581+
let codes: Vec<&String> = get.responses.responses.keys().collect();
582+
assert!(codes.iter().any(|code| code.as_str() == "200"), "{codes:?}");
583+
assert!(codes.iter().any(|code| code.as_str() == "404"), "{codes:?}");
584+
}
585+
568586
#[test]
569587
fn write_routers_register_create_entry_and_save() {
570588
let paths = path_list(write_routers());

‎src/ceres/api_service/tree_ops.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ pub async fn get_tree_info<T: ApiHandler + ?Sized>(
313313
.collect();
314314
Ok(items)
315315
}
316-
None => Ok(vec![]),
316+
None => Err(MegaError::NotFound(format!("tree path not found: {}", path.display())).into()),
317317
}
318318
}
319319

‎tests/integration_api_write_trunk.rs‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -998,6 +998,51 @@ impl EntryCase {
998998
}
999999
}
10001000

1001+
#[test]
1002+
fn tree_unknown_path_returns_404() {
1003+
let case = EntryCase::boot(ApiWriteEnv::with_token_config());
1004+
case.seed();
1005+
case.create_entry(Some(&EntryCase::bearer()), "fix-bb-10-file.txt", false);
1006+
1007+
let (known_status, known) = case.get("tree?path=/project", None);
1008+
assert_eq!(known_status, 200, "known directory must remain readable");
1009+
assert_eq!(known["req_result"], true);
1010+
assert!(
1011+
known["data"]["tree_items"]
1012+
.as_array()
1013+
.expect("tree_items")
1014+
.iter()
1015+
.any(|item| { item["name"] == "fix-bb-10-file.txt" && item["content_type"] == "file" }),
1016+
"created file must be visible in its parent tree: {known}"
1017+
);
1018+
1019+
for (path, reported_path) in [
1020+
("/project/fix-bb-10-missing", "/project/fix-bb-10-missing"),
1021+
(
1022+
"/project/fix-bb-10-parent/child",
1023+
"/project/fix-bb-10-parent",
1024+
),
1025+
("/project/fix-bb-10-file.txt", "/project/fix-bb-10-file.txt"),
1026+
] {
1027+
let (status, json) = case.get(&format!("tree?path={path}"), None);
1028+
assert_eq!(status, 404, "GET /tree must reject {path}: {json}");
1029+
assert_eq!(json["req_result"], false);
1030+
assert!(json["data"].is_null());
1031+
assert_eq!(
1032+
json["err_message"],
1033+
format!("tree path not found: {reported_path}")
1034+
);
1035+
assert!(
1036+
!json["err_message"]
1037+
.as_str()
1038+
.expect("err_message string")
1039+
.contains(PUSH_TOKEN)
1040+
);
1041+
}
1042+
1043+
case.finish();
1044+
}
1045+
10011046
/// Create `/project/lb02-gone` through the API, delete it with the token, and
10021047
/// return the delete response (asserted 200) plus the tip before the delete.
10031048
fn delete_success_flow(case: &EntryCase) -> (Value, String) {

0 commit comments

Comments
 (0)