# git-misc ## 结论 - 检查调用点 30 个 / 操作 30 个 - 确认问题 3 条(Q 1 / B 0 / P 1 / R 1 / G 0),可疑 5 条,未实证 16 个操作 - 14 个操作在本机 `http://localhost:7051` 上拿到真实响应(含 3 条 400 原文与 2 条 404 原文); 其余 16 个是写操作或需空间所有者身份(`require_writer` / `require_owner`), 匿名 `demo/demorepos` 够不到,只做源码逐字比对,见文末「未实证清单」。 ## 确认问题 | # | 类 | 位置 | 操作 | 现象 | 后端依据 | 建议修法 | |---|---|---|---|---|---|---| | 1 | R | `view/app/src/domains/repo/history/ui/commit-view.tsx:99-104`;`view/app/src/domains/repo/model/commits.ts:69-70` | `diffs.diff_raw` | 根提交(`parents: []`)走 `const base = commit.parents[0] ?? EMPTY_TREE`,`EMPTY_TREE = 4b825dc642cb6eb9a060e54bf8d69288fbee4904`(`model/diff.ts:39`)。实测该 base 返回 **404**,而 `fetchRawDiff` 没有 `.catch()`,`Promise.all` 整体 reject → 根提交页整页失败(同批的 numstat 加了 `.catch(() => [])`,patch 没有)。实证:`GET /repos/demo/demorepos/diff/raw?base=4b825dc642cb6eb9a060e54bf8d69288fbee4904&head=27c58541bd555b8b9791ed6e2d8d7bdb252230ca` → `{"code":404,"message":"not found","data":null,"error":null,"error_code":null,...}`;同一 head 换真实父提交则 200 | `lib/service/git/src/handler/diffs/raw.rs:10-13,25`(返回 `AppResponseData`);`lib/service/git/src/service/diffs.rs:96-117` `diff_request` 对空树做字符串特判后直接交给 baby diff,对象不在库即 `GitError::NotFound` → 404。根提交 `27c5854…` 的 `parents: []` 实证自 `GET /commits?limit=2&offset=2599` | 给 `fetchRawDiff` 补 `.catch(() => '')`(与 numstat 同策略),或根提交不请求 diff 而由清单自行渲染 | | 2 | P | `view/app/src/domains/repo/model/archives.ts:46-48`(`revision`);`view/app/src/domains/repo/model/tree.ts:91-93`(`revision`);`view/app/src/domains/repo/model/remotes.ts:25-26`(`name`) | `archives.get_archive` / `trees.get_tree` / `remotes.upsert_remote` | path 参数里含 `/` 时路径多切一段,路由整体不匹配。实证:`GET /repos/demo/demorepos/tree/HEAD/extra` → `{"code":404,"message":"unknown api route","data":null,"error":null,"error_code":"not_found",...}`;`GET /repos/demo/demorepos/archives/main/1.0?format=Tar` → 同样 `unknown api route`。UI 触发器明确:`archives-settings.tsx:115-120` 的 revision 是自由文本输入,placeholder 就是 `main / v1.0 / sha`。根因在 `packages/apiclient`:355 个 `get*Url` 全部裸插值,`encodeURIComponent` 出现 0 次(`grep -c` 实测)。**query 参数不受影响**——生成器用 `URLSearchParams`,编码正确 | 路由本身要求单段:`lib/service/git/src/handler/archives/download.rs:12` `/archives/{revision}`、`handler/trees/read.rs:36` `/tree/{revision}`、`handler/remotes/add.rs:23` `/remotes/{name}`。编码后能正确进 handler(实证 `GET /tree/feature%2Ffoo` 返回业务 `404 not found` 而非 `unknown api route`,说明已到达 handler 层) | 在调用点对 path 参数做 `encodeURIComponent`(`invoke()` 只把 `pathParams` 的值传给 builder,编码值会原样拼进 URL,`%2F` 已验证可解析) | | 3 | Q | `view/app/src/domains/repo/history/ui/commits-tab.tsx:232` | `commits.list_commits` | 字段名本身正确,但 **值域越界**:`onClick={() => setSkip(skip + PAGE_SIZE)}` 只受 `!page.hasMore` 约束,没有 5000 上限;提交数 >5000 的仓库翻到第 101 页后请求 `offset=5050`。实证:`GET /commits?offset=5001` → `{"code":400,"message":"offset must not exceed 5000","data":null,...}`;`GET /commits?limit=101` → `{"code":400,"message":"limit must be between 1 and 100",...}`。`PAGE_SIZE = 50`(同文件 :10),`limit` 在范围内没问题 | `lib/service/git/src/handler/commits/list.rs:19-20`(`limit` 1-100、`offset` ≤5000);`service/commits.rs:24-39` `ListCommitsCmd` 字段确为 `revision/path/limit/offset/since/until/first_parent`,与前端发送的 `revision, offset, limit, first_parent, path` 逐字一致 | `disabled` 增加 `skip + PAGE_SIZE > 5000` 分支并提示「请用 path/revision 缩小范围」,或改成 cursor 分页 | ## 可疑(需人工确认) | # | 位置 | 操作 | 疑点 | 已排除的可能 | |---|---|---|---|---| | 1 | `view/app/src/domains/repo/history/ui/commit-view.tsx:101-104` | `diffs.get_diff` | 前端注释(`model/commits.ts:46`)称「`shortstat` 500s on the server today, so totals are summed client-side」,据此放弃 `view=shortstat` 改本地累加。实测 `GET /diff?view=shortstat` → **200**,返回 `{"additions":76,"deletions":1,"files_changed":2}` | 不是 400/500 契约问题(端点正常);也不是形状误用(`ShortStat` 字段与本地累加结果同义)。属过时前提,功能不受影响,故未计入确认问题 | | 2 | `view/app/src/domains/repo/model/blame.ts:32-39` | `submodules.submodule_resolve` | 注释写「Resolve a submodule path at `revision` to its **repository path (space/name)**」,但后端返回的是 gitlink 提交 sha。实证 `GET /submodules/resolve?revision=HEAD&path=browser-extension` → `{"code":200,...,"data":"385d36c0807221e6674d1325ef06505746c49ceb",...}` | 返回类型 `string` 与 `AppResponseData` 一致,UI(`submodules-section.tsx:51`)只把它当不透明字符串渲染,不会崩。故只是注释失配,不是契约错配 | | 3 | `view/app/src/domains/repo/branches/ui/submodules-section.tsx:49` | `submodules.submodule_resolve` | 后端只在**根树**里按 `e.name == path` 精确匹配(`service/submodules.rs:71-79`:`get_tree_entries(&revision, Path::new(""))`)。`.gitmodules` 允许嵌套路径(如 `vendor/lib`),这类 entry 会被传进来但根树里没有同名条目 → `GitError::NotFound` | 已实证 `path=server`(非子模块)→ 404、`path=browser-extension`/`embed` → 200;本仓库没有嵌套子模块,无法造出「嵌套路径应当成功却 404」的反例,故列可疑 | | 4 | `view/app/src/domains/repo/model/tree.ts:89-97` | `trees.get_tree` | 参数 `show_trees: true` 在非递归分支里是**空转**:`handler/trees/read.rs:86-92` 的 `tree_read` 根本不接收 `show_trees`(只有 `recurse` 分支的 `tree_walk` 用它)。实证 `GET /tree/HEAD?path=` 与 `?path=&show_trees=true` 都返回 32 条、`modes=['Blob','Submodule','Tree']`,完全一致 | 不是功能缺陷——`tree_read` 默认就包含 Tree 条目,前端去掉也不会坏。只是参数无效,易误导读代码的人 | | 5 | `view/app/src/domains/repo/model/tree.ts:5-12` | `trees.get_tree` | 文件头注释断言 `oid` 是 `{ "Sha1": [20 raw bytes] }`、`name` 已含仓库根相对路径(`assets/logo.jpg`)。实测两者都不成立:`oid` 是纯 hex 字符串(`"8358e08199cf3c8db41dacbe06298fca81a10b40"`),子目录列举时 `name` 是 basename(`path=.github` 返回 `[{"name":"FUNDING.yml",...}]` 而非 `.github/FUNDING.yml`) | `normalizeTreeRows` / `oidToHex` 同时兼容 string 与 `{Sha1:[…]}`、basename 与 root-relative 两种形态,两种现实都归一正确,未产生实际错配。属文档漂移 | 补充一条环境观察(不计入上表):`GET /diff?…&bogus=1` 返回 **200**,未知 query 参数被静默忽略,与 openapi 前言「未知参数同样返回 400」不符。影响取证——本域内 Q 类错误不会以 400 形式暴露,只能靠结构体逐字比对发现。 ## 已核对无问题 | 操作 | 依据 | |---|---| | `diffs.get_diff`(`view` 枚举) | 后端 `handler/diffs/get_diff.rs:12-19` `DiffView` 为 `#[serde(rename_all="lowercase")]` 的 `Shortstat/Numstat/Files/Tree`,前端 `commits.ts:52` 发 `'numstat'` 正确。大小写实证:`view=Numstat` → `{"code":400,...,"error":"Query deserialize error: unknown variant `Numstat`, expected one of `shortstat`, `numstat`, `files`, `tree`"}`;`view=bogus` 同。生成类型 `DiffView` 与之一致 | | `diffs.get_diff`(`use_merge_base`) | 仅 Tree 分支使用(`get_diff.rs:87-92`);前端不传(`commits.ts:52` 只发 base/head/view)。实证 `view=numstat&use_merge_base=true` → 200(被接受并忽略),无副作用 | | `diffs.diff_raw`(返回形状) | 后端 `handler/diffs/raw.rs:25` 声明 `AppResponseData`,实测是 **JSON envelope 内 data 为字符串**(`{"code":200,...,"data":"diff --git a/...`),并非裸 `text/plain`。前端 `unwrapApi`(`commits.ts:70`)消费方式正确 | | `diffs.get_diff`(numstat 行字段) | `rpcgit/diff/types.rs:60-65` `NumStat{additions,deletions,path,old_path}`;前端 `model/diff.ts:184-189` `NumstatRow` 同名同义。实证返回 `{"additions":73,"deletions":0,"old_path":null,"path":"server/…"}` | | `diffs.get_diff`(非法 base/head) | 不可解析 revision → 404(实证 `base=doesnotexist` → `{"code":404,"message":"not found"}`),不是 400;前端按通用错误处理,无形状假设 | | `trees.get_tree`(query 名) | `handler/trees/read.rs:20-32` 允许 `path/recurse/resolve/show_trees/view`;前端 `{path, show_trees}` 均在集合内。注意后端是 `recurse` 不是 `recursive`,前端未误用 | | `trees.get_tree`(path 语义) | `path` 为空字符串时被 `invoke()` 的 `query()` 丢弃(`services/api/index.ts:61-70` 过滤 `''`),后端 `#[serde(default)]` 补 `""`,等价。子目录实证 `path=.github` 正常返回 | | `blobs.blob_content` | `service/blobs.rs:30-36` `BlobContentDto{oid,data:Vec,size,is_binary,truncated}` → `data` 序列化为数字数组。实证 `data:[118,45,101,110,118,...]`。前端 `readme.ts:150-153` 用 `Array.from(blob.data)` 转字节、`blob.is_binary`/`blob.truncated`/`blob.size` 全部按此消费。路径 3 段 `{space_name, repo_name, oid}` 与 `/blobs/{oid}/content` 一致 | | `commits.list_commits`(字段) | 前端发 `revision/offset/limit/first_parent/path`,`service/commits.rs:24-39` `ListCommitsCmd` 逐字包含;`limit:50`、`offset:0` 边界内。实证 200 且 `limit=1` 生效 | | `commits.list_commits`(`has_more`) | `service/commits.rs:74-95` `CommitsPage{items,total,limit,offset,has_more,diff_view_style}`;前端 `commits.ts:32-36` 读 `page.items/total/has_more`。实证 `"total":2601,"limit":1,"offset":0,"has_more":true` | | `blame.blame` | `handler/blame.rs:10-18` query=`revision,path,range_start,range_end`;前端只发 `revision,path`,合法。返回 `Vec`,实测 `{"starting_line":1,"ending_line":2,"commit_sha":"ac0b1d4…","abbreviated_oid":"ac0b1d4","path":null}`,前端 `blameLineMap`(`blame.ts:54-61`)用的 `starting_line/ending_line` 存在 | | `conflicts.get_conflicts` | `handler/conflicts/get_conflicts.rs:10-16` query=`base,head,ancestor`;前端 `pulls/model/api.ts:156-160` 发 `{base,head}` 合法。实证 `{"mergeable":true,"conflicts":[],"fast_forwardable":true}`,与 `MergeStatusDto` 一致 | | `conflicts.conflict_detail` | `handler/conflicts/detail.rs:10-15` query=`base,head,path`;前端 `blame.ts:26-29` 三字段齐全。返回 `ConflictDetailDto{path,content:Option}` 与前端 `{path, content?}` 一致。**正例未实证**:`demo/demorepos` 无冲突对,`base=HEAD~1&head=HEAD&path=README.md` 返回 404 | | `submodules.submodule_list` | `handler/submodules/list.rs:10-13` query=`revision`;前端 `blame.ts:44-47` 只发 `revision`。返回 `Vec`,实证 `[{"path":"browser-extension","url":"","branch":null,"commit_sha":"385d36c…"},…]`,`submodules-section.tsx` 消费的四个字段全部存在 | | `archives.get_archive`(format 枚举) | `service/archives.rs:21-25` 的 `format` 是 `rpcgit::archive::types::ArchiveFormat`(`rpcgit/archive/types.rs:5-11`,**无** `rename_all`)→ serde 只认 `Tar/TarGz/TarBz2/Zip`。前端 `archives.ts:30-35` `QUERY_FORMAT` 映射正是这四个值,正确。实证 `format=tar_gz` → `{"code":400,...,"error":"Query deserialize error: unknown variant `tar_gz`, expected one of `Tar`, `TarGz`, `TarBz2`, `Zip`"}`;`format=Tar` → 通过解析,仅因无产物报 `invalid argument: archive artifact not found` | | `archives.create_archive`(body) | `handler/archives/create.rs:30-36` `ArchiveCreateCmd{revision,format:ArchiveFormatDto,path_filter}`,而 `ArchiveFormatDto` 是 `#[serde(rename_all="snake_case")]`(同文件 :11)→ 只认 `tar/tar_gz/tar_bz2/zip`。前端 POST 传 `cmd.format` 的 snake_case 值、GET 才映射 PascalCase,两边分别正确。`path_filter: null` 对 `Option + #[serde(default)]` 等价于 None | | `archives.get_archive`(返回值/无轮询) | `ArchiveResultDto{format:String,size:i64,cache_key,download_url:Option}` **没有状态字段**;前端 `archives-settings.tsx:140-183` 三处都是一次性调用后读 `download_url`/`size`,不做轮询,不存在「轮询错字段」 | | `archives.create_bundle` | `handler/archives/create_bundle.rs:10-24` 无 query/body,路径 2 段;前端 `archives.ts:26` 仅传 pathParams,一致 | | `remotes.list_remotes` | 返回裸 `Vec`(`handler/remotes/list.rs:17,24`),前端 `unwrapApi`(`remotes.ts:21`)按数组消费。实证 `"data":[]` | | `remotes.upsert_remote` / `remove` | `handler/remotes/add.rs:16-23`、`remove.rs:16-19`:path=`{space_name,repo_name,name}` 3 段、body 仅 `{url}`;前端 `remotes.ts:25-27`、`:32` 一致。返回 `AppResponseData`,前端 `unwrapApi` 与 `data:null` 对应 | | `remotes.fetch` / `push` / `clone_import` | body 分别为 `RemoteFetchBody{remote,auth}`、`RemotePushBody{remote,refspec,auth}`、`RemoteImportBody{url,auth}`(`handler/remotes/{fetch,push,clone_import}.rs`)。`RemoteAuthDto{token:Option,username:Option}`(`service/remotes.rs:28-34`)⊇ 前端 `auth: {token}`;`auth: null` 与 `Option + #[serde(default)]` 等价。`refspec: null` 同理 | | `lfs.list_locks` | `handler/lfs/list_locks.rs:10-16` query=`cursor,limit`;前端 `lfs.ts:15` 发 `{cursor, limit:50}`(`cursor` 为空时回落 `undefined` 因而被丢弃)。返回 `{locks,next_cursor?}`(`lfs/types.rs:110-115`),实证 `{"locks":[]}`(无 `next_cursor` 时不出现,`skip_serializing_if`),前端 `page.data?.locks ?? []` 兼容 | | `lfs.verify_locks` | `handler/lfs/verify_locks.rs:10-14` query=`limit`,是 **POST**;生成签名 `verifyLocks(spaceName, repoName, params?, options?)` 与 OPS `{query:true, body:false}` 吻合。返回 `{ours,theirs,next_cursor?}`,前端 `locks-settings.tsx:58-61` 读 `ours`/`theirs` 正确 | | `lfs.lock` | `handler/lfs/lock.rs:10-15` body=`{path,oid}`;前端 `lfs.ts:23` 发 `{path, oid: null}`,`Option + #[serde(default)]` 接受 null | | `lfs.unlock` / `unlock_by_id` | `handler/lfs/unlock.rs:9-14` query=`path,force`;`unlock_by_id.rs:9-20` path=`{space_name,repo_name,lock_id}` + query=`force`。前端 `lfs.ts:31`、`:39` 键名与顺序一致。**注意 `path` 走 query(`URLSearchParams` 编码),不受上文 P 类缺陷影响** | | `cleanup.list_cleanup_objects` | `lib/service/webapi/src/cleanup.rs:250,257` 返回**裸数组** `AppResponseData>`;前端 `rows`(`space-admin.ts:376`)按数组消费,正确。`path:1` = `/spaces/{space_name}/cleanup` 单段一致 | | `cleanup.set_cleanup_visibility` / `set_cleanup_archive` | body 分别 `SetCleanupVisibilityCmd{is_private}`(`cleanup.rs:71-74`)、`SetRepoArchivedCmd{archived}`(`:76-79`);前端 `space-admin.ts:382`、`:390` 逐字一致。`path:3` 分别对应 `/visibility` 与 `/archive` 两个后缀 | | `cleanup.delete_cleanup_object` / `clear_cleanup_overwrites` | 无 body,`path:3` 对应 `:539` 与 `:668` 的 `{kind}/{object_id}[/overwrites]`;前端 `space-admin.ts:396`、`:403` 一致。返回 `AppResponseData` ↔ `unwrapApi` | | `push.push_config` | `lib/service/user/src/handlers/push_config.rs:13-17,21` → `PushConfigDto{enabled,public_key}`、`path="/push/config"`、无参数。前端 `notifications-section.tsx:26-31` 类型 `{enabled, public_key}` 一致。实证 `{"enabled":true,"public_key":"BCRWNH5S0jM0wMo0…"}` | ## 未实证清单 | 操作 | 原因 | |---|---| | `remotes.upsert_remote` / `remove` / `fetch` / `push` / `clone_import` | 写操作,需目标仓库写权限;匿名会话被拒。仅做源码比对 | | `lfs.lock` / `unlock` / `unlock_by_id` / `verify_locks` | 写操作(`require_writer`),同上 | | `archives.create_archive` / `create_bundle` | `AuthedUser` + `require_writer`,同上;`format` 枚举已在 GET 上实证同一 serde 规则 | | `cleanup.*`(4 写 + 1 读,共 5 个) | `require_owner`(仅空间所有者);`list_cleanup_objects` 同样受 `AuthedUser + require_owner` 限制,匿名不可达 | | `conflicts.conflict_detail` 正例 | 被测仓库无冲突文件对,只能确认 query 名与 DTO 字段 |