From 210205d87775b94497f70ed3ff49a5381d663be5 Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Wed, 1 Apr 2026 22:24:56 +0800 Subject: [PATCH 01/14] fix(branch-tag-reset): close review gaps Signed-off-by: Quanyi Ma --- docs/improvement/README.md | 14 +- docs/improvement/branch.md | 216 +++--- docs/improvement/reset.md | 170 ++-- docs/improvement/tag.md | 160 ++-- src/command/branch.rs | 1162 ++++++++++++++++------------ src/command/reset.rs | 608 +++++++++------ src/command/tag.rs | 430 +++++----- src/internal/branch.rs | 170 ++-- tests/command/branch_test.rs | 73 +- tests/command/output_flags_test.rs | 36 + tests/command/reset_test.rs | 49 +- tests/command/tag_test.rs | 83 +- 12 files changed, 1969 insertions(+), 1202 deletions(-) diff --git a/docs/improvement/README.md b/docs/improvement/README.md index 17cff843..59b3ecbe 100644 --- a/docs/improvement/README.md +++ b/docs/improvement/README.md @@ -73,11 +73,11 @@ | 顺序 | 命令 | 当前状态 | 改进重点 | |------|------|--------|--------| -| **9** | `switch` | 有 JSON + 确认消息 | `SwitchError` typed enum + 显式 `StableErrorCode`;`run_switch()` 返回 `Result`;Levenshtein 模糊匹配;`--help` EXAMPLES(详见 [switch.md](switch.md)) | -| **9a** | `checkout`(兼容收口) | 依赖 `switch::ensure_clean_status()` | 随 `switch` 联动:`err.message()` 字符串匹配改为 `SwitchError` 变体匹配;`--help` EXAMPLES。**不是完整现代化**——JSON / `CheckoutError` / render split 仍留第六批(详见 [checkout.md](checkout.md)) | -| **10** | `reset` | 有确认消息,无 JSON | 输出 "HEAD is now at \ \";JSON 输出;错误码 | -| **11** | `tag` | 有短标志 -l/-d/-m/-a | 补齐 JSON 输出;重复创建时 hint;退出码对齐 exit 1 | -| **12** | `branch` | 有 JSON | 补齐 StableErrorCode;退出码对齐(删除不存在分支 exit 1) | +| **9** | `switch` | ✅ 已落地 | 第二批主改造已落地;后续仅维护回归测试、文档同步与大仓库切换性能观察(详见 [switch.md](switch.md)) | +| **9a** | `checkout`(兼容收口) | ✅ 第二批兼容收口已落地 | 已完成 `SwitchError` 变体匹配适配与 `--help` EXAMPLES;**不是完整现代化**——`CheckoutError` / JSON / render split 仍留第六批(详见 [checkout.md](checkout.md)) | +| **10** | `reset` | 部分已落地:已有确认消息、JSON/machine、显式 `StableErrorCode`、`run_reset()` / `render_reset_output()` | 补齐 `ResetError` typed enum;移除 string-based runtime 错误分类与直写 warning;补齐 `--help` EXAMPLES(详见 [reset.md](reset.md)) | +| **11** | `tag` | 部分已落地:已有 JSON/machine、显式 `StableErrorCode`、重复创建 hint | 补齐 `TagError` typed enum;统一 run/render 分层;收口 list/show 路径的显式错误码与 human 确认消息;补齐 `--help` EXAMPLES(详见 [tag.md](tag.md)) | +| **12** | `branch` | 部分已落地:JSON 已覆盖 list/create/delete/rename/set-upstream/show-current,`StableErrorCode` 已大体补齐 | 补齐 `BranchError` typed enum;统一 run/render 分层;补齐 create/force-delete 确认消息、fuzzy suggestion 与 `--help` EXAMPLES(详见 [branch.md](branch.md)) | **理由:** 这些命令改变仓库状态,必须告知用户发生了什么。`checkout` 的兼容收口随 `switch` 一起落地,因为 `switch` 的 `ensure_clean_status()` 签名变更强制要求 `checkout` 同步适配。 @@ -180,8 +180,8 @@ - [Commit 命令改进详细计划](commit.md) ✅ 已落地 - [Push 命令改进详细计划](push.md) ✅ 已落地 - [Pull 命令改进详细计划](pull.md) ✅ 已落地 -- [Switch 命令改进详细计划](switch.md) -- [Checkout 命令改进详细计划(第二批兼容收口)](checkout.md) +- [Switch 命令改进详细计划](switch.md) ✅ 已落地 +- [Checkout 命令改进详细计划(第二批兼容收口)](checkout.md) ✅ 已落地(完整现代化留第六批) - [Reset 命令改进详细计划](reset.md) - [Tag 命令改进详细计划](tag.md) - [Branch 命令改进详细计划](branch.md) diff --git a/docs/improvement/branch.md b/docs/improvement/branch.md index 837878c4..214edde3 100644 --- a/docs/improvement/branch.md +++ b/docs/improvement/branch.md @@ -1,12 +1,14 @@ ## Branch 命令改进详细计划 -> 最后编写时间:2026-03-30 +> 最后编写时间:2026-04-01 同时落地 [Cross-Cutting Improvements A/B/F/G](README.md#全局层面改进贯穿所有命令)。 +> 当前工作区实现已按本文范围落地一部分改动;以下内容改为记录已落地能力、剩余遗漏和后续收口项。 + ### 已完成前置条件与当前代码状态 -第一批全部 8 个命令的主改造已在当前代码库落地。`branch` 是第二批(状态变更确认命令)中管理分支的命令,已有部分 JSON 支持。 +第一批全部 8 个命令的主改造已在当前代码库落地。`branch` 是第二批(状态变更确认命令)中管理分支的命令,JSON 已覆盖主要操作,但错误建模和 human 输出一致性仍未完全现代化。 **已确认落地的基线:** @@ -14,31 +16,49 @@ - `OutputConfig` + `emit_json_data()` + `info_println!()` 输出框架已可用 - `StableErrorCode` 体系已有 18 个错误码 - `CliError` 支持 `.with_hint()`、`.with_stable_code()`、`.with_detail()` -- `execute()` / `execute_safe(args, output)` 双入口已存在(`branch.rs:99/108`) +- `execute()` / `execute_safe(args, output)` 双入口已存在 +- `BranchOutput` + `run_branch_json()` 已覆盖 list / create / delete / rename / set-upstream / show-current 的 JSON 输出 - `--list` / `--delete` / `--delete-force` / `--set-upstream-to` / `--show-current` / `--move` / `--remotes` / `--all` / `--contains` / `--no-contains` 已实现 -- **JSON 输出已部分实现**:list 操作使用 `emit_json_data("branch", ...)` 返回 `{ branches: [...] }`(`branch.rs:137-145`),每个分支包含 `name`/`current`/`commit` 字段 +- create / delete / rename / set-upstream / show-current 路径都已补上命令层 `StableErrorCode` - `is_valid_git_branch_name()` 分支名验证已实现(`branch.rs:694-725`) - `delete_branch_safe()` 已有 merge 检查和 `.with_hint()`(`branch.rs:342-349`) +- human 路径已至少覆盖 delete-safe、rename、set-upstream、show-current 的确认输出 +- `after_help` 已有 compatibility notes,但尚未补 EXAMPLES + +**基于当前代码的 Review 结论(已改进部分 vs 仍需改进部分):** + +已改进(当前代码已具备): -**基于当前代码的 Review 结论(branch 仍需改进的部分):** +- **JSON 已覆盖主要操作**:`BranchOutput` + `run_branch_json()` 已支持 list / create / delete / rename / set-upstream / show-current,list schema 也已保持向后兼容 +- **大部分命令层错误已带显式 `StableErrorCode`**:invalid name、already exists、invalid commit、branch not found、detached HEAD、I/O 写失败等主要路径已显式映射 +- **退出码对齐已部分落地**:删除不存在分支已走 `CliInvalidTarget`(exit `129`),删除当前分支走 `RepoStateInvalid`(exit `128`) +- **部分成功确认消息已落地**:safe delete、rename、set-upstream、show-current 均已有 human 输出 +- **现有测试已验证关键契约**:`branch_test.rs` 已覆盖 invalid start point error code、detached HEAD set-upstream 和 JSON create schema -- **JSON 输出仅覆盖 list**:create / delete / rename / set-upstream / show-current 操作无 JSON 输出 -- **零 `StableErrorCode`**:所有 25+ 处错误使用 `CliError::fatal()` / `CliError::failure()` 无显式错误码 -- **无 `BranchError` typed enum**:错误散落在多个函数中 -- **退出码不对齐**:删除不存在分支时应返回明确的 exit `129`(当前通过 `CliError::fatal()` 返回 128) -- **`delete_branch()` 和 `delete_branch_safe()` 重复代码**:locked/current 检查在两个函数中重复 -- **测试期望 `LBR-CLI-003` 但代码未赋值**:`branch_test.rs:24` 期望 error code `LBR-CLI-003`,但代码中未调用 `.with_stable_code()` +仍需改进: + +- **无 `BranchError` typed enum**:错误仍散落在 create/delete/rename/list 各函数中 +- **无统一 `run_branch()` / `render_branch_output()` 分层**:当前只有 JSON 路径走 `run_branch_json()`,human 路径仍按分支直接执行 +- **create / force-delete 仍缺确认消息**:`create_branch_safe()` 和 `delete_branch()` 成功后仍然沉默,不符合第二批“状态变更必须确认”的目标 +- **缺少 fuzzy suggestion**:分支不存在时还没有 Levenshtein 类 `did you mean ...` 提示 +- **`--help` 仍缺 EXAMPLES**:当前 `after_help` 只有 compatibility notes +- **仍有零散错误未显式赋码**:例如 `cannot get HEAD commit` 等路径还依赖默认推断 +- **`delete_branch()` / `delete_branch_safe()` 仍有重复前置检查**:locked/current/not-found 检查可继续抽取复用 +- **`internal::branch` 仍吞掉底层失败**:`list_branches_with_conn()` 里存在 `unwrap()`,`find_branch_with_conn()` / `delete_branch_with_conn()` 仍用 `eprintln!()` 吞掉查询/删除失败;不先把这些 API 改成 fallible,命令层无法真正收口为 `BranchError` ### 目标与非目标 **本批目标:** - 引入 `BranchError` typed error enum,覆盖 branch 层面的错误场景 - 所有 `BranchError → CliError` 映射使用显式 `StableErrorCode` -- 拆分执行层与渲染层:新增 `run_branch(args) -> Result` 纯执行入口 -- 扩展 JSON 输出到所有操作(create / delete / rename / set-upstream / show-current),不仅仅是 list -- 退出码对齐:删除不存在分支 exit `129`,删除当前分支 exit `128` +- 在保留既有 `BranchOutput` JSON schema 的前提下,补齐统一的 `run_branch()` / `render_branch_output()` 分层 +- 先把 `internal::branch` 的 `list/find/delete` 改成 fallible API(去掉 `unwrap()` / `eprintln!()`),让命令层能接住真实失败 +- 抽取 delete 共享前置检查,减少 `delete_branch()` / `delete_branch_safe()` 重复逻辑 +- 补齐 create / force-delete 的 human 确认消息 +- 收口剩余未显式赋码的错误路径 - 消除 `delete_branch()` 和 `delete_branch_safe()` 的重复代码 - 补齐 `--help` EXAMPLES 段 +- 为分支不存在路径补齐 fuzzy suggestion **本批非目标:** - **不改变 `--contains` / `--no-contains` 过滤逻辑**。BFS 可达性检查保持现有算法 @@ -52,6 +72,8 @@ 2. **JSON 输出通过 `action` 字段区分操作类型**:list / create / delete / rename / set-upstream / show-current 3. **错误码显式映射**:每个 `BranchError` 变体都有确定的 `StableErrorCode` 4. **JSON list 向后兼容**:现有 `branches` 数组 schema 不变,仅添加 `action` 字段作为 envelope 增量 +5. **先让底层 branch store 变成可失败 API**:命令层 typed error 不能建立在 `unwrap()` / `eprintln!()` / `None` 伪装失败之上 +6. **对复用 helper 保留 passthrough 例外**:`resolve_commits()` / `commit_contains()` 等当前仍返回 `CliError` 的路径,可先通过 `DelegatedCli` 透传,避免本批次过度扩散 ### 特性 1:BranchError typed error enum @@ -69,8 +91,12 @@ pub enum BranchError { #[error("a branch named '{0}' already exists")] AlreadyExists(String), - #[error("branch '{0}' not found")] - NotFound(String), + #[error("branch '{name}' not found")] + NotFound { + name: String, + /// Local branches with Levenshtein distance ≤ 2, pre-computed at the error site + similar: Vec, + }, #[error("cannot delete the branch '{0}' which you are currently on")] DeleteCurrent(String), @@ -90,14 +116,30 @@ pub enum BranchError { #[error("invalid upstream '{0}'")] InvalidUpstream(String), + #[error("failed to query branch storage: {0}")] + StorageQueryFailed(String), + + #[error("stored branch reference is corrupt: {0}")] + StoredReferenceCorrupt(String), + #[error("failed to create branch '{branch}': {detail}")] CreateFailed { branch: String, detail: String }, + #[error("failed to delete branch '{branch}': {detail}")] + DeleteFailed { branch: String, detail: String }, + #[error("too many arguments for rename")] RenameTooManyArgs, + + #[error(transparent)] + DelegatedCli(#[from] CliError), } ``` +> **`NotFound` 携带 `similar` 列表**:与 `SwitchError::BranchNotFound` 模式一致,在错误构造点预计算 Levenshtein ≤ 2 近似分支名列表,`impl From for CliError` 只负责渲染 hint。Levenshtein 距离计算复用 switch 批次落地的共享工具函数(~10 行,位于 `src/utils/` 或 `src/command/mod.rs`)。 + +> **关于底层 branch store 的前置收口**:在引入 `BranchError` 之前,需要先把 `src/internal/branch.rs` 中的 `list_branches_with_conn()`、`find_branch_with_conn()`、`delete_branch_with_conn()` 改成 `Result` 风格,去掉当前的 `unwrap()` / `eprintln!()`。否则命令层根本拿不到真实失败,只能把查询失败误判成 `NotFound` 或直接 panic。 + **`BranchError → CliError` 显式映射:** | BranchError 变体 | StableErrorCode | 退出码 | hint | @@ -105,85 +147,78 @@ pub enum BranchError { | `NotInRepo` | `RepoNotFound` | 128 | `run 'libra init' to create a repository` | | `InvalidName` | `CliInvalidArguments` | 129 | `branch names cannot contain spaces, '..', '~', '^', ':'` | | `AlreadyExists` | `ConflictOperationBlocked` | 128 | `delete it first or choose a different name` | -| `NotFound` | `CliInvalidTarget` | 129 | `use 'libra branch -l' to list branches` | +| `NotFound` | `CliInvalidTarget` | 129 | `use 'libra branch -l' to list branches` + Levenshtein `did you mean '{name}'?` | | `DeleteCurrent` | `RepoStateInvalid` | 128 | `switch to another branch first` | | `NotFullyMerged` | `RepoStateInvalid` | 128 | `if you are sure, run 'libra branch -D {name}'` | | `Locked` | `ConflictOperationBlocked` | 128 | 无 | | `DetachedHead` | `RepoStateInvalid` | 128 | `checkout a branch first` | | `InvalidCommit` | `CliInvalidTarget` | 129 | `use 'libra log --oneline' to see available commits` | | `InvalidUpstream` | `CliInvalidTarget` | 129 | `expected format: 'remote/branch'` | +| `StorageQueryFailed` | `IoReadFailed` | 128 | 无 | +| `StoredReferenceCorrupt` | `RepoCorrupt` | 128 | 无 | | `CreateFailed` | `IoWriteFailed` | 128 | 无 | +| `DeleteFailed` | `IoWriteFailed` | 128 | 无 | | `RenameTooManyArgs` | `CliInvalidArguments` | 129 | `usage: libra branch -m [old-name] new-name` | +| `DelegatedCli` | 保持被委托 helper 原有错误码 | 保持原退出码 | 保持原 hints | + +**与当前代码中 inline 错误的对应关系:** + +| 当前代码位置 | 当前 inline 错误 | 对应 BranchError 变体 | +|-------------|-----------------|---------------------| +| `internal::branch::list_branches_with_conn()` | `all(db).await.unwrap()` | `StorageQueryFailed` / `StoredReferenceCorrupt`(先改底层 API) | +| `internal::branch::find_branch_with_conn()` | `eprintln!("fatal: failed to query branch ...")` + `None` | `StorageQueryFailed`(先改底层 API) | +| `internal::branch::delete_branch_with_conn()` | `eprintln!("fatal: failed to delete branch ...")` | `DeleteFailed`(先改底层 API) | +| `create_branch_safe:244-247` | `CliError::fatal("... is not a valid branch name")` | `InvalidName` | +| `create_branch_safe:249-254` | `CliError::fatal("... branch is locked")` | `Locked` | +| `create_branch_safe:262` | `CliError::fatal("... already exists")` | `AlreadyExists` | +| `create_branch_safe:269-271` | `CliError::fatal("not a valid object name")` | `InvalidCommit` | +| `create_branch_safe:304` | `CliError::fatal("failed to create branch")` | `CreateFailed` | +| `delete_branch_safe:322` | branch not found | `NotFound` | +| `delete_branch_safe:334` | `CliError::fatal("cannot delete ... currently on")` | `DeleteCurrent` | +| `delete_branch_safe:349` | `CliError::fatal("... not fully merged")` | `NotFullyMerged` | +| `delete_branch:360` | branch not found | `NotFound` | +| `delete_branch:373` | `CliError::fatal("cannot delete ... currently on")` | `DeleteCurrent` | +| `rename_branch:428-431` | `CliError::command_usage("too many arguments")` | `RenameTooManyArgs` | +| `rename_branch:436-439` | `CliError::fatal("invalid branch name")` | `InvalidName` | +| `rename_branch:442-455` | `CliError::fatal("... is locked")` | `Locked` | +| `rename_branch:459-462` | `CliError::fatal("... not found")` | `NotFound` | +| `rename_branch:467-469` | `CliError::fatal("... already exists")` | `AlreadyExists` | +| `rename_branch:477-480` | `CliError::fatal("failed to create branch")` | `CreateFailed` | +| `set_upstream_safe_with_output:210-213` | `CliError::fatal("invalid upstream")` | `InvalidUpstream` | +| `execute_safe:161` | `detached_head_branch_error()` | `DetachedHead` | +| `collect_branch_names()` / `list_branches()` | `resolve_commits()` / `commit_contains()` 现返 `CliError` | `DelegatedCli`(本批允许透传) | + +**跨命令公开 API 边界说明:** + +`create_branch_safe()`、`set_upstream_safe()`、`set_upstream_safe_with_output()` 是被 `switch.rs` 通过 `DelegatedCli` 调用的公开 API。本批这些函数**继续返回 `CliResult`**(内部由 `BranchError → CliError` 转换),不要求 `switch` 同步修改 `DelegatedCli` 处理。与此同时,`src/internal/branch.rs` 的底层 `list/find/delete` helper 可以先收紧为 `Result` 风格,再由命令层转换为 `CliError`。等 `switch` 后续收紧 `DelegatedCli` 时,可选择让这些 API 返回 `Result<_, BranchError>` 并在 `switch` 侧包装为 `SwitchError::DelegatedBranch(BranchError)`。 ### 特性 2:执行层与渲染层拆分 -**方案:** +**已落地部分(保持不变):** `BranchOutput` enum(含 `List`/`Create`/`Delete`/`Rename`/`SetUpstream`/`ShowCurrent` 六变体)和 `BranchListEntry` 结构体均已存在于 `branch.rs:28-58`,JSON schema 已稳定。 -```rust -#[derive(Debug, Clone, Serialize)] -#[serde(tag = "action")] -pub enum BranchOutput { - #[serde(rename = "list")] - List(BranchListOutput), - #[serde(rename = "create")] - Create(BranchCreateOutput), - #[serde(rename = "delete")] - Delete(BranchDeleteOutput), - #[serde(rename = "rename")] - Rename(BranchRenameOutput), - #[serde(rename = "set-upstream")] - SetUpstream(BranchSetUpstreamOutput), - #[serde(rename = "show-current")] - ShowCurrent(BranchShowCurrentOutput), -} - -#[derive(Debug, Clone, Serialize)] -pub struct BranchListOutput { - pub branches: Vec, -} +> **向后兼容说明:** 现有 `--json -l` 返回 `{ "branches": [...] }` 的 schema 通过 `BranchOutput::List { branches }` 保留。`action` 字段由 `#[serde(tag = "action")]` 自动添加到 JSON envelope 的 `data` 层。 -#[derive(Debug, Clone, Serialize)] -pub struct BranchListEntry { - pub name: String, - pub current: bool, - pub commit: String, -} +**本批变更:统一 `run_branch()` / `render_branch_output()` 分层** -#[derive(Debug, Clone, Serialize)] -pub struct BranchCreateOutput { - pub name: String, - pub commit: String, -} +当前架构问题:human 路径在 `execute_safe()` 内按分支直接执行(create/delete/rename/set-upstream/list 各自独立),JSON 路径单独走 `run_branch_json()`。两条路径各自拼装逻辑,create 和 force-delete 成功后在 human 路径沉默。 -#[derive(Debug, Clone, Serialize)] -pub struct BranchDeleteOutput { - pub name: String, - pub commit: String, - /// Whether merge check was skipped (-D) - pub force: bool, -} +目标架构: -#[derive(Debug, Clone, Serialize)] -pub struct BranchRenameOutput { - pub old_name: String, - pub new_name: String, -} +```rust +/// 纯执行入口——收集结构化结果,不输出 +async fn run_branch(args: &BranchArgs) -> Result -#[derive(Debug, Clone, Serialize)] -pub struct BranchSetUpstreamOutput { - pub branch: String, - pub upstream: String, -} +/// 渲染层——根据 OutputConfig 决定 human/JSON/machine/quiet 输出 +fn render_branch_output(result: &BranchOutput, output: &OutputConfig) -> CliResult<()> -#[derive(Debug, Clone, Serialize)] -pub struct BranchShowCurrentOutput { - pub name: Option, - pub detached: bool, - pub commit: Option, +/// execute_safe 调用链 +pub async fn execute_safe(args: BranchArgs, output: &OutputConfig) -> CliResult<()> { + let result = run_branch(&args).await.map_err(CliError::from)?; + render_branch_output(&result, output) } ``` -> **向后兼容说明:** 现有 `--json -l` 返回 `{ "branches": [...] }` 的 schema 通过 `BranchListOutput.branches` 保留。新增的 `action` 字段由 `#[serde(tag = "action")]` 自动添加到 JSON envelope 的 `data` 层。 +现有 `run_branch_json()` 将被合并入 `run_branch()`。`list_branches()`、`display_branches()` 的渲染逻辑将移入 `render_branch_output()` 的 human list 分支。 **渲染规则:** @@ -293,10 +328,10 @@ pub struct BranchShowCurrentOutput { | ID | 改进 | branch 中的具体落地 | |----|------|-----------------| -| **A** | 退出码 `0/128/129` | 参数错误(无效分支名、不存在的分支、无效 commit、rename 参数过多)→ exit `129`;运行时错误(locked、merge 检查失败、detached HEAD、already exists)→ exit `128`;成功 → exit `0` | +| **A** | 退出码 `0/128/129` | 参数错误(无效分支名、不存在的分支、无效 commit、rename 参数过多、无效 upstream)→ exit `129`;运行时错误(locked、merge 检查失败、detached HEAD、already exists、I/O 失败)→ exit `128`;成功 → exit `0` | | **B** | `--help` EXAMPLES | 见下方 EXAMPLES 段 | -| **F** | 拼写纠错 | 分支不存在时基于现有分支列表做 Levenshtein 距离 ≤ 2 fuzzy match | -| **G** | Issues URL | 仅在 `CreateFailed` 错误时输出 Issues URL | +| **F** | 拼写纠错 | 分支不存在时基于现有分支列表做 Levenshtein 距离 ≤ 2 fuzzy match;复用 switch 批次落地的共享 Levenshtein 工具函数 | +| **G** | Issues URL | 与 switch 保持一致——仅在映射为 `InternalInvariant` 的内部不变式错误时输出。当前 `branch` 计划内的 `CreateFailed`/`DeleteFailed` 属于 `IoWriteFailed`,不附带 Issues URL | ### `--help` EXAMPLES 段 @@ -323,13 +358,20 @@ EXAMPLES: - **(已有)** invalid start point 错误码、create/list/show_current、create from remote、invalid name、rename、rename current、rename to existing、list all、delete safe、contains filter、error propagation - **(新增)`BranchError` 变体覆盖**: - `NotFound`:删除不存在分支返回 exit `129` + `LBR-CLI-003` - - `DeleteCurrent`:删除当前分支返回 exit `128` - - `InvalidName`:无效分支名返回 exit `129` - - `DetachedHead`:detached HEAD 下 set-upstream 返回 exit `128` -- **(新增)成功确认消息**:human 模式下 create/delete/rename 各自输出确认消息 -- **(新增)fuzzy match**:删除名为 `mian` 的分支时提示 `did you mean 'main'?` + - `DeleteCurrent`:删除当前分支返回 exit `128` + `LBR-REPO-003` + - `InvalidName`:无效分支名返回 exit `129` + `LBR-CLI-002` + - `DetachedHead`:detached HEAD 下 set-upstream 返回 exit `128` + `LBR-REPO-003` +- **(新增)成功确认消息**:human 模式下 create 输出 `Created branch '{name}' at {hash}`,delete 输出 `Deleted branch {name} (was {hash})`,rename 输出 `Branch '{old}' renamed to '{new}'` +- **(新增)fuzzy match**:删除名为 `mian` 的分支时提示 `did you mean 'main'?`(复用 switch 的 Levenshtein 工具函数) + +#### `src/internal/branch.rs`(底层错误面补测) + +- `list_branches_with_conn()`:数据库查询失败不再 `unwrap()` panic,改为 `Result` 返回 +- `find_branch_with_conn()`:查询失败不再 `eprintln!()` 后伪装成 `None` +- `delete_branch_with_conn()`:删除失败不再仅打印 fatal,而是返回可断言的错误 +- malformed stored commit/hash:不再 `unwrap()` panic,改为 `StoredReferenceCorrupt` -#### `tests/command/branch_json_test.rs`(JSON schema 稳定性,新增文件) +#### `tests/command/branch_json_test.rs`(JSON schema 稳定性,可选拆分文件) - **list `--json` 向后兼容**:`action == "list"`,`branches` 数组存在且 schema 不变 - **create `--json`**:`action == "create"`,`name` 和 `commit` 存在 @@ -354,7 +396,7 @@ EXAMPLES: | 文件 | 改动类型 | 说明 | |------|---------|------| -| `src/command/branch.rs` | **重构** | 新增 `BranchError` typed enum;新增 `BranchOutput` tagged enum 及各操作结构体;新增 `run_branch()` 纯执行入口;消除 `delete_branch()` / `delete_branch_safe()` 重复代码;`BranchError → CliError` 显式 `StableErrorCode` 映射;扩展 JSON 输出到所有操作;添加 create/delete/rename 确认消息;补齐 `--help` EXAMPLES | -| `tests/command/branch_test.rs` | **扩展** | 新增 `BranchError` 变体覆盖、确认消息验证、fuzzy match | -| `tests/command/branch_json_test.rs` | **新增** | JSON schema 完整性和稳定性验证(含 list 向后兼容) | -| `tests/command/mod.rs` | **修改** | 注册新增的测试文件 | +| `src/internal/branch.rs` | **收口** | 把 `list_branches_with_conn()` / `find_branch_with_conn()` / `delete_branch_with_conn()` 从 `unwrap()` / `eprintln!()` 风格改为 `Result` 风格,暴露真实查询/删除失败与存储损坏,并扩展该文件内现有单元测试覆盖这些失败面 | +| `src/command/branch.rs` | **收口** | 保持已落地的 `BranchOutput` / JSON schema / 主要 `StableErrorCode` 不回退;后续补齐 `BranchError` typed enum(含 `StorageQueryFailed`/`StoredReferenceCorrupt`/`DeleteFailed` 新变体和 `DelegatedCli` 透传)、统一 `run_branch()` / `render_branch_output()`(替代 `run_branch_json()`)、抽取 delete 共享前置检查、补齐 create/force-delete 确认消息、fuzzy suggestion 和 `--help` EXAMPLES | +| `tests/command/branch_test.rs` | **扩展** | 在现有错误码和 JSON create 回归基础上,补齐 `BranchError` 变体覆盖、create/force-delete 确认消息和 fuzzy suggestion | +| `tests/command/branch_json_test.rs` | **可选拆分** | 若 `branch_test.rs` 中的 JSON 覆盖继续膨胀,可再拆出独立 schema 稳定性文件;当前不是阻断项 | diff --git a/docs/improvement/reset.md b/docs/improvement/reset.md index caf404c9..718458d1 100644 --- a/docs/improvement/reset.md +++ b/docs/improvement/reset.md @@ -1,9 +1,11 @@ ## Reset 命令改进详细计划 -> 最后编写时间:2026-03-30 +> 最后编写时间:2026-04-01 同时落地 [Cross-Cutting Improvements A/B/F/G](README.md#全局层面改进贯穿所有命令)。 +> 当前工作区实现已按本文范围落地一部分改动;以下内容改为记录已落地能力、剩余遗漏和后续收口项。 + ### 已完成前置条件与当前代码状态 第一批全部 8 个命令的主改造已在当前代码库落地。`reset` 是第二批(状态变更确认命令)中改变仓库状态最激烈的命令——用户必须知道 HEAD 移动到了哪里。 @@ -14,33 +16,46 @@ - `OutputConfig` + `emit_json_data()` + `info_println!()` 输出框架已可用 - `StableErrorCode` 体系已有 18 个错误码 - `CliError` 支持 `.with_hint()`、`.with_stable_code()`、`.with_detail()` -- `execute()` / `execute_safe(args, _output)` 双入口已存在(`reset.rs:70/79`) +- `execute()` / `execute_safe(args, output)` 双入口已存在 +- `run_reset()` + `render_reset_output()` 执行层/渲染层拆分已落地 +- `ResetOutput` 已定义,`--json` / `--machine` 已通过 `emit_json_data("reset", ...)` 输出结构化结果 - `--soft` / `--mixed` / `--hard` 三种模式已实现 - pathspec 支持已实现(reset 特定文件) - reflog 记录已集成(`ReflogContext` + `with_reflog()`) - `reset_index_to_commit()` / `reset_working_directory_to_commit()` 核心逻辑已实现 - `rebuild_index_from_tree()` 和 `restore_working_directory_from_tree()` 已实现 - 空目录清理 `remove_empty_directories()` 已实现 +- human 成功确认消息已落地:全量 reset 输出 `HEAD is now at `;pathspec reset 输出 `Unstaged changes after reset:` +- pathspec 与 `--soft` / `--hard` 的冲突校验已接入显式 `StableErrorCode` + +**基于当前代码的 Review 结论(已改进部分 vs 仍需改进部分):** + +已改进(当前代码已具备): -**基于当前代码的 Review 结论(reset 仍需改进的部分):** +- **结构化输出已落地**:`ResetOutput` + `render_reset_output()` 已覆盖 human / `--json` / `--machine` / `--quiet` +- **执行层与渲染层已拆分**:`execute_safe()` 调用 `run_reset()` 收集结构化结果,再统一渲染 +- **成功确认消息已落地**:全量 reset 会输出 `HEAD is now at ...`,pathspec reset 会输出 unstaged 文件列表 +- **主要错误已带显式 `StableErrorCode`**:invalid revision、pathspec/mode 冲突、repo corrupt、I/O 失败等路径都已显式映射 +- **JSON 回归测试已存在**:`tests/command/reset_test.rs` 已覆盖 `--json` schema、`--hard HEAD` restore 计数和 pathspec usage error -- **零 JSON / machine 输出**:`OutputConfig` 参数标记为 `_output` 完全未使用(`reset.rs:79`) -- **零 `StableErrorCode`**:所有错误使用 `CliError::fatal()` 无显式错误码 -- **无 `ResetError` typed enum**:错误散落在多个函数中,内部函数使用 `Result` -- **成功时沉默**:审计报告核心发现——reset 完成后无任何输出告知用户 HEAD 移动到了哪里 -- **缺少 `"HEAD is now at "` 输出**:Git 在 hard/mixed reset 后输出此行,libra 不输出 -- **内部函数使用 `Result`**:`reset_index_to_commit()`、`reset_working_directory_to_commit()` 等返回 `String` 错误,无类型信息 -- **`cli_error!` 宏直接打印**:`reset.rs` 中有 `cli_error!()` 直接写 stderr 而非通过 `CliError` 返回 +仍需改进: + +- **无 `ResetError` typed enum**:`run_reset()` 仍返回 `CliResult`,typed error 收口尚未完成 +- **运行时错误仍是 stringly typed**:`perform_reset()` / `remove_empty_directories()` 等内部 helper 仍返回 `Result`,`map_reset_runtime_error()` 依赖关键词匹配分类,较脆弱 +- **pathspec 错误仍未纳入 typed enum**:如 `path contains invalid UTF-8`、`pathspec ... did not match any file(s) known to libra` 仍是 inline `CliError` +- **非致命 warning 仍直写 stderr**:目录清理失败仍通过 `eprintln!()` 输出,尚未接入共享 warning/output 管线 +- **缺少 `--help` EXAMPLES 段**:Cross-Cutting **B** 在 `reset` 上仍未落地 +- **Cross-Cutting `G` 尚未接入**:意外内部错误还未统一附带 Issues URL ### 目标与非目标 **本批目标:** -- 引入 `ResetError` typed error enum,覆盖 reset 层面的错误场景 -- 所有 `ResetError → CliError` 映射使用显式 `StableErrorCode` -- 拆分执行层与渲染层:新增 `run_reset(args) -> Result` 纯执行入口 -- 实现 JSON 输出(reset 结果结构化) -- 添加 "HEAD is now at \ \" 成功确认消息 -- 补齐 `--help` EXAMPLES 段 +- 引入 `ResetError` typed error enum,收口剩余的 string-based runtime 错误路径 +- 将 pathspec 相关的用户输入错误一并纳入 typed enum,避免残留 inline `CliError` +- 将 `perform_reset()` / `remove_empty_directories()` 等 helper 从 `Result` 升级到 typed error +- 将非致命 cleanup warning 接入共享 `emit_warning()` / warning tracker,避免直写 stderr,同时不改变现有 `ResetOutput` JSON schema +- 保持已落地的 `ResetOutput` / JSON / human 确认消息契约不回退 +- 补齐 `--help` EXAMPLES 段,并为异常内部错误预留 Issues URL 接入点 **本批非目标:** - **不改变 soft/mixed/hard reset 核心逻辑**。索引重建和工作树恢复行为不变 @@ -54,6 +69,7 @@ 2. **成功时必须确认**:human 模式下输出 `HEAD is now at ` 3. **错误码显式映射**:每个 `ResetError` 变体都有确定的 `StableErrorCode` 4. **内部函数错误类型升级**:从 `Result` 升级到 `Result` +5. **warning 不进入 `ResetOutput` schema**:cleanup warning 通过共享 warning 管线输出并参与 `--exit-code-on-warning`,不新增 JSON 字段污染已稳定的 success schema ### 特性 1:ResetError typed error enum @@ -68,6 +84,9 @@ pub enum ResetError { #[error("invalid revision: '{0}'")] InvalidRevision(String), + #[error("HEAD is unborn — no commits in this repository")] + HeadUnborn, + #[error("failed to load commit '{commit_id}': {detail}")] CommitLoad { commit_id: String, detail: String }, @@ -86,8 +105,17 @@ pub enum ResetError { #[error("failed to restore working tree: {0}")] WorktreeRestore(String), + #[error("path contains invalid UTF-8: {0}")] + InvalidPathspecEncoding(String), + #[error("pathspec '{0}' is not compatible with --soft reset")] PathspecWithSoft(String), + + #[error("cannot do hard reset with paths")] + PathspecWithHard, + + #[error("pathspec '{0}' did not match any file(s) known to libra")] + PathspecNotMatched(String), } ``` @@ -97,48 +125,88 @@ pub enum ResetError { |----------------|-----------------|--------|------| | `NotInRepo` | `RepoNotFound` | 128 | `run 'libra init' to create a repository` | | `InvalidRevision` | `CliInvalidTarget` | 129 | `check the revision name and try again` | +| `HeadUnborn` | `RepoStateInvalid` | 128 | `create a commit first` | | `CommitLoad` | `RepoCorrupt` | 128 | `the object store may be corrupted` | | `TreeLoad` | `RepoCorrupt` | 128 | `the object store may be corrupted` | | `IndexLoad` | `RepoCorrupt` | 128 | `the index file may be corrupted` | | `IndexSave` | `IoWriteFailed` | 128 | 无 | | `HeadUpdate` | `IoWriteFailed` | 128 | 无 | | `WorktreeRestore` | `IoWriteFailed` | 128 | 无 | +| `InvalidPathspecEncoding` | `CliInvalidArguments` | 129 | `rename the path or invoke libra from a path representable as UTF-8` | | `PathspecWithSoft` | `CliInvalidArguments` | 129 | `--soft only moves HEAD; use --mixed to reset index for specific paths` | +| `PathspecWithHard` | `CliInvalidArguments` | 129 | `--hard updates the working tree; omit pathspecs or use --mixed for specific paths` | +| `PathspecNotMatched` | `CliInvalidTarget` | 129 | `check the path and try again` | + +**与当前代码中 inline 错误的对应关系:** + +| 当前代码位置 | 当前 inline 错误 | 对应 ResetError 变体 | +|-------------|-----------------|---------------------| +| `run_reset:113` | `util::require_repo().map_err(...)` | `NotInRepo` | +| `run_reset:126-131` | `command_usage("pathspec ... is not compatible with --soft reset")` | `PathspecWithSoft` | +| `run_reset:133-138` | `command_usage("Cannot do hard reset with paths.")` | `PathspecWithHard` | +| `run_reset:141-143` | `resolve_commit().map_err(map_reset_invalid_revision)` | `InvalidRevision` | +| `run_reset:159-161` | `resolve_commit().map_err(map_reset_invalid_revision)` | `InvalidRevision` | +| `run_reset:163-165` | `perform_reset().map_err(map_reset_runtime_error)` | 见下方 `map_reset_runtime_error` 分拆 | +| `reset_pathspecs:206-212` | `path contains invalid UTF-8` | `InvalidPathspecEncoding` | +| `reset_pathspecs:236-240` | `pathspec ... did not match any file(s) known to libra` | `PathspecNotMatched` | +| `map_reset_runtime_error:740-741` | `message.contains("HEAD is unborn")` | `HeadUnborn` | +| `map_reset_runtime_error:742-749` | `message.contains("load commit/tree/index/blob")` | `CommitLoad` / `TreeLoad` / `IndexLoad` | +| `map_reset_runtime_error:734-739` | `message.contains("save index/write file/update HEAD")` | `IndexSave` / `HeadUpdate` / `WorktreeRestore` | +| `remove_empty_directories:600-605,617-621` | `eprintln!("warning: failed to remove empty directory")` | 改为收集 warning 字符串,经 `emit_warning()` / warning tracker 输出(非致命,不映射为 ResetError) | ### 特性 2:执行层与渲染层拆分 -**方案:** +**已落地部分(无需变更):** `ResetOutput` 结构体、`render_reset_output()` 渲染函数、`execute_safe()` → `run_reset()` → `render_reset_output()` 调用链均已存在。 + +**本批变更:`run_reset()` 返回内部执行结果,显式携带 warning** + +为避免把 cleanup warning 塞进已稳定的 `ResetOutput` JSON schema,本批引入一个**仅命令内部使用**的包装结果: + +```rust +struct ResetExecution { + output: ResetOutput, + warnings: Vec, +} +``` + +当前签名: +```rust +async fn run_reset(args: ResetArgs) -> CliResult +``` +目标签名: ```rust -#[derive(Debug, Clone, Serialize)] -pub struct ResetOutput { - /// Reset mode: "soft", "mixed", "hard" - pub mode: String, - /// Target commit hash (full) - pub commit: String, - /// Target commit short hash - pub short_commit: String, - /// Target commit subject line - pub subject: String, - /// Previous HEAD commit hash - pub previous_commit: Option, - /// Files unstaged (mixed/hard only) - pub files_unstaged: usize, - /// Files restored in working tree (hard only) - pub files_restored: usize, - /// Pathspecs that were reset (empty for full reset) - pub pathspecs: Vec, +async fn run_reset(args: ResetArgs) -> Result +``` + +`execute_safe()` 调用层转换: +```rust +pub async fn execute_safe(args: ResetArgs, output: &OutputConfig) -> CliResult<()> { + let result = run_reset(args).await.map_err(CliError::from)?; + render_reset_output(&result.output, output)?; + for warning in &result.warnings { + emit_warning(warning); + } + Ok(()) } ``` -**渲染规则:** +辅助函数签名同步变更: +```rust +async fn perform_reset(target: ObjectHash, mode: ResetMode, target_name: &str) -> Result +fn remove_empty_directories(workdir: &Path) -> Result, ResetError> +// 注:warning 统一经 execute_safe() 调用 emit_warning() 输出, +// quiet 模式不抑制 warning;--exit-code-on-warning 继续生效 +``` + +**渲染规则(已落地,无需变更):** | 模式 | stdout | stderr | |------|--------|--------| -| human(默认) | `HEAD is now at ` | 无 | -| human + pathspec | `Unstaged changes after reset:` + 文件列表 | 无 | -| human + `--quiet` | 无 | 无 | -| `--json` / `--machine` | JSON envelope | 无 | +| human(默认) | `HEAD is now at ` | warning 经 `emit_warning()` 输出 | +| human + pathspec | `Unstaged changes after reset:` + 文件列表 | warning 经 `emit_warning()` 输出 | +| human + `--quiet` | 无 | warning 经 `emit_warning()` 输出 | +| `--json` / `--machine` | JSON envelope | warning 经 `emit_warning()` 输出 | **human 模式确认消息:** @@ -214,10 +282,10 @@ M src/lib.rs | ID | 改进 | reset 中的具体落地 | |----|------|-----------------| -| **A** | 退出码 `0/128/129` | 参数错误(无效 revision、pathspec + soft 冲突)→ exit `129`;运行时错误(object 损坏、I/O 失败)→ exit `128`;成功 → exit `0` | +| **A** | 退出码 `0/128/129` | 参数错误(无效 revision、pathspec + soft/hard 冲突)→ exit `129`;运行时错误(object 损坏、HEAD unborn、I/O 失败)→ exit `128`;成功 → exit `0` | | **B** | `--help` EXAMPLES | 见下方 EXAMPLES 段 | | **F** | 拼写纠错 | **不适用**——reset 的参数是 revision 和 pathspec,无 enum 值可做 fuzzy match | -| **G** | Issues URL | 仅在 `CommitLoad` / `TreeLoad` / `IndexLoad` 错误时输出 Issues URL | +| **G** | Issues URL | 与 switch 保持一致——仅在映射为 `InternalInvariant` 的内部不变式错误时输出。当前 `reset` 计划内没有 `InternalInvariant` 变体,`RepoCorrupt` 是数据问题而非代码 bug,不附带 Issues URL | ### `--help` EXAMPLES 段 @@ -237,13 +305,16 @@ EXAMPLES: - **(已有)** 仓库外执行、soft/mixed/hard reset、HEAD~ 引用、分支上 reset - **(新增)`ResetError` 变体覆盖**: - - `InvalidRevision`:无效 revision 返回 exit `129` - - `PathspecWithSoft`:`--soft` + pathspec 返回 exit `129` - - `PathspecWithHard`:`--hard` + pathspec 返回 exit `129` + - `InvalidRevision`:无效 revision 返回 exit `129` + `LBR-CLI-003` + - `PathspecWithSoft`:`--soft` + pathspec 返回 exit `129` + `LBR-CLI-002` + - `PathspecWithHard`:`--hard` + pathspec 返回 exit `129` + `LBR-CLI-002` + - `HeadUnborn`:空仓库 reset 返回 exit `128` + `LBR-REPO-003` - **(新增)成功确认消息**:human 模式下 stdout 包含 `HEAD is now at` - **(新增)pathspec reset 输出**:mixed 模式 + pathspec 后 stdout 包含 unstaged 文件列表 +- **(新增)warning 管线**:目录清理 warning 不再直写 stderr,统一经 `emit_warning()` 输出并触发 warning tracker +- **(新增)`--exit-code-on-warning` 回归**:成功 reset 伴随 cleanup warning 时返回 exit `9`,且 JSON schema 不新增 `warnings` 字段 -#### `tests/command/reset_json_test.rs`(JSON schema 稳定性,新增文件) +#### `tests/command/reset_json_test.rs`(JSON schema 稳定性,可选拆分文件) - **schema 完整性**:验证 `--json` 输出中每个字段的类型和存在性 - **`--hard --json`**:`mode == "hard"`,`files_restored` 反映实际被恢复的 tracked 文件数;dirty 工作区时 `> 0`,clean repo 上对 `HEAD` 执行时可为 `0` @@ -266,7 +337,6 @@ EXAMPLES: | 文件 | 改动类型 | 说明 | |------|---------|------| -| `src/command/reset.rs` | **重构** | 新增 `ResetError` typed enum;新增 `ResetOutput` 结构体;新增 `run_reset()` 纯执行入口;内部函数从 `Result` 升级到 `Result`;`ResetError → CliError` 显式 `StableErrorCode` 映射;JSON 输出;添加 "HEAD is now at" 成功确认消息;消除 `cli_error!()` 直接打印;补齐 `--help` EXAMPLES | -| `tests/command/reset_test.rs` | **扩展** | 新增 `ResetError` 变体覆盖、成功消息验证 | -| `tests/command/reset_json_test.rs` | **新增** | JSON schema 完整性和稳定性验证 | -| `tests/command/mod.rs` | **修改** | 注册新增的测试文件 | +| `src/command/reset.rs` | **收口** | 保持已落地的 `ResetOutput` / `run_reset()` / `render_reset_output()` / JSON / human 确认消息;后续补齐 `ResetError` typed enum、移除 `map_reset_runtime_error()` 的关键词分类、把目录清理 warning 接入共享输出、补齐 `--help` EXAMPLES | +| `tests/command/reset_test.rs` | **扩展** | 在现有 JSON / human 输出回归基础上,补齐 typed error、warning 路径与 help EXAMPLES 回归 | +| `tests/command/reset_json_test.rs` | **可选拆分** | 若 `reset_test.rs` 中的 JSON 覆盖继续膨胀,可再拆出独立 schema 稳定性文件;当前不是阻断项 | diff --git a/docs/improvement/tag.md b/docs/improvement/tag.md index f5afe579..ef937656 100644 --- a/docs/improvement/tag.md +++ b/docs/improvement/tag.md @@ -1,9 +1,11 @@ ## Tag 命令改进详细计划 -> 最后编写时间:2026-03-30 +> 最后编写时间:2026-04-01 同时落地 [Cross-Cutting Improvements A/B/F/G](README.md#全局层面改进贯穿所有命令)。 +> 当前工作区实现已按本文范围落地一部分改动;以下内容改为记录已落地能力、剩余遗漏和后续收口项。 + ### 已完成前置条件与当前代码状态 第一批全部 8 个命令的主改造已在当前代码库落地。`tag` 是第二批(状态变更确认命令)中管理版本标记的命令。 @@ -14,31 +16,43 @@ - `OutputConfig` + `emit_json_data()` + `info_println!()` 输出框架已可用 - `StableErrorCode` 体系已有 18 个错误码 - `CliError` 支持 `.with_hint()`、`.with_stable_code()`、`.with_detail()` -- `execute()` / `execute_safe(args, _output)` 双入口已存在(`tag.rs:42/51`) +- `execute()` / `execute_safe(args, output)` 双入口已存在 +- `run_tag_json()` + `TagOutput` 已实现 list / create / delete 的 JSON / machine 输出 - `-l` / `-d` / `-m` / `-f` / `-n` 短标志已实现 - `create_tag_safe()` 已有 `.with_hint()` 提供重复创建时的 hint(`tag.rs:87-96`) - `render_tags()` 支持 `-n` 控制注释行数显示 - `internal::tag` 模块提供底层 tag API -- 内部 tag API 已返回 `LBR-CONFLICT-002` 错误码(从 `tag_test.rs:157` 验证) +- create / delete / find major error path 已在命令层映射显式 `StableErrorCode` +- quiet delete、malformed ref delete 和 JSON schema 已有回归测试覆盖 + +**基于当前代码的 Review 结论(已改进部分 vs 仍需改进部分):** + +已改进(当前代码已具备): -**基于当前代码的 Review 结论(tag 仍需改进的部分):** +- **结构化输出已落地**:`run_tag_json()` + `TagOutput` 已覆盖 list / create / delete 三类操作,`--json` / `--machine` 可直接使用 +- **主要命令层错误已带显式 `StableErrorCode`**:重复创建、HEAD unborn、tag not found、delete I/O 失败、repo read failure 等路径已有稳定错误码 +- **重复创建 hint 已落地**:`map_create_tag_error()` 已保留删除旧 tag 或更换 tag 名的提示 +- **quiet / malformed ref delete 回归已覆盖**:当前测试已覆盖 quiet delete、删除损坏 tag ref、JSON delete `hash = null` 等边界 -- **零 JSON / machine 输出**:`OutputConfig` 参数标记为 `_output` 完全未使用(`tag.rs:51`) -- **零 `StableErrorCode` 在命令层**:虽然内部 tag API 返回 `LBR-CONFLICT-002`,但命令层的 `CliError::fatal()` 无显式错误码 -- **无 `TagError` typed enum**:错误散落在 `execute_safe()`、`create_tag_safe()`、`delete_tag_safe()`、`show_tag_safe()` 中 -- **退出码不对齐**:重复创建时退出码应为明确的非零值(当前通过 `CliError::fatal()` 返回 128,但无 stable code) -- **删除不存在 tag 时退出码不对齐**:应返回 exit `1` 或 `129` -- **测试注释有全角括号**:`tag_test.rs` 中有 `(lightweight tag)` 等全角括号应改为半角 +仍需改进: + +- **无 `TagError` typed enum**:错误仍散落在 `execute_safe()`、`create_tag_safe()`、`delete_tag_safe()`、`show_tag_safe()` 中 +- **无统一 `run_tag()` / `render_tag_output()` 分层**:human 路径仍在 `execute_safe()` 内分支拼装,JSON 路径单独走 `run_tag_json()` +- **list / show 路径仍有隐式错误码**:`render_tags()` 失败在 human 路径仍通过 `CliError::fatal(e.to_string())` 返回,缺少显式 `StableErrorCode` +- **human 成功反馈仍不完全一致**:lightweight create 复用 `show_tag_safe()` 输出对象详情,annotated create 没有单独确认消息,delete 也未回显被删 tag 的 hash +- **create 失败来源尚未结构化区分**:`CheckExisting` / `SerializeTag` / `StoreObject` / `PersistReference` 需要映射到不同稳定错误码,不能继续折叠成一个泛化的 create 失败 +- **缺少 `--help` EXAMPLES 段** +- **测试注释仍有全角括号**:`tag_test.rs` 中 `(lightweight tag)` 等注释尚未清理 ### 目标与非目标 **本批目标:** - 引入 `TagError` typed error enum,覆盖 tag 层面的错误场景 - 所有 `TagError → CliError` 映射使用显式 `StableErrorCode` -- 拆分执行层与渲染层:新增 `run_tag(args) -> Result` 纯执行入口 -- 实现 JSON 输出(tag 操作结果 + tag 列表结构化) -- 重复创建时保留 hint(已有)并补齐 `StableErrorCode` -- 删除不存在 tag 时返回 exit `129` + hint +- 在保留既有 `TagOutput` JSON schema 的前提下,补齐统一的 `run_tag()` / `render_tag_output()` 分层 +- 补齐 list / show 路径的显式 `StableErrorCode` +- 统一 human create / delete 成功反馈为简短确认消息,不再沿用当前 lightweight/annotated 两套不同输出习惯 +- 让 create 失败来源在 `TagError` 中显式编码,避免同一变体对应多个 `StableErrorCode` - 修复测试注释中的全角括号 - 补齐 `--help` EXAMPLES 段 @@ -50,10 +64,11 @@ ### 设计原则 -1. **执行路径与渲染职责拆分**:`execute_safe()` 根据 `OutputConfig` 分流 human / JSON 路径,JSON 路径返回结构化 `TagOutput` +1. **执行层与渲染层拆分**:`execute_safe()` 调用 `run_tag()` 收集结构化 `TagOutput` 结果,再根据 `OutputConfig` 通过 `render_tag_output()` 渲染 human / JSON / machine,消除当前 human 路径与 JSON 路径分治的架构 2. **JSON 覆盖 list、create、delete 三种操作**:通过 `action` 字段区分 3. **错误码显式映射**:每个 `TagError` 变体都有确定的 `StableErrorCode` 4. **保留现有 hint**:重复创建时的 hint 保持一致 +5. **typed enum 自身携带错误分类信息**:不能依赖 `TagError` 变体外的来源注释再决定 `StableErrorCode` ### 特性 1:TagError typed error enum @@ -74,8 +89,20 @@ pub enum TagError { #[error("tag name is required")] MissingName, - #[error("failed to create tag '{name}': {detail}")] - CreateFailed { name: String, detail: String }, + #[error("cannot create tag: HEAD does not point to a commit")] + HeadUnborn, + + #[error("failed to read existing tags before creating '{name}': {detail}")] + CheckExistingFailed { name: String, detail: String }, + + #[error("failed to serialize annotated tag object: {0}")] + SerializeAnnotatedTag(String), + + #[error("failed to store annotated tag object: {0}")] + StoreObjectFailed(String), + + #[error("failed to persist tag reference '{name}': {detail}")] + PersistReferenceFailed { name: String, detail: String }, #[error("failed to delete tag '{name}': {detail}")] DeleteFailed { name: String, detail: String }, @@ -88,6 +115,8 @@ pub enum TagError { } ``` +> **与 `internal::tag::CreateTagError` 的关系**:`CreateTagError` 是底层业务模块定义的错误类型(含 `AlreadyExists`、`HeadUnborn`、`CheckExisting`、`SerializeTag`、`StoreObject`、`PersistReference`)。`TagError` 是命令层 typed enum,通过 `impl From for TagError` 收口映射(现有 `map_create_tag_error()` 将被替代):`CheckExisting` → `CheckExistingFailed`,`SerializeTag` → `SerializeAnnotatedTag`,`StoreObject` → `StoreObjectFailed`,`PersistReference` → `PersistReferenceFailed`。 + **`TagError → CliError` 显式映射:** | TagError 变体 | StableErrorCode | 退出码 | hint | @@ -96,64 +125,67 @@ pub enum TagError { | `AlreadyExists` | `ConflictOperationBlocked` | 128 | `delete it first with 'libra tag -d {name}'` + `or choose a different tag name` | | `NotFound` | `CliInvalidTarget` | 129 | `use 'libra tag -l' to list available tags` | | `MissingName` | `CliInvalidArguments` | 129 | `provide a tag name` | -| `CreateFailed` | `IoWriteFailed` | 128 | 无 | +| `HeadUnborn` | `RepoStateInvalid` | 128 | `create a commit first before tagging HEAD` | +| `CheckExistingFailed` | `RepoCorrupt` | 128 | 无 | +| `SerializeAnnotatedTag` | `InternalInvariant` | 128 | 附带 Issues URL | +| `StoreObjectFailed` | `IoWriteFailed` | 128 | 无 | +| `PersistReferenceFailed` | `IoWriteFailed` | 128 | 无 | | `DeleteFailed` | `IoWriteFailed` | 128 | 无 | | `LoadFailed` | `RepoCorrupt` | 128 | 无 | -| `ListFailed` | `IoReadFailed` | 128 | 无 | +| `ListFailed` | `RepoCorrupt` | 128 | 无 | + +**与当前代码中 inline 错误的对应关系:** + +| 当前代码位置 | 当前 inline 错误 | 对应 TagError 变体 | +|-------------|-----------------|---------------------| +| `execute_safe:75` | `validate_named_tag_action()` | `MissingName`(delete/force 缺少 tag 名) | +| `execute_safe:89` | `CliError::fatal(e.to_string())` render_tags 失败 | `ListFailed` | +| `create_tag_safe:148-151` | `map_create_tag_error()` → `AlreadyExists` | `AlreadyExists` | +| `map_create_tag_error:162-165` | `CreateTagError::HeadUnborn` | `HeadUnborn` | +| `map_create_tag_error:167-171` | `CreateTagError::CheckExisting` | `CheckExistingFailed` | +| `map_create_tag_error:172-175` | `CreateTagError::SerializeTag` | `SerializeAnnotatedTag` | +| `map_create_tag_error:176-178` | `CreateTagError::StoreObject` | `StoreObjectFailed` | +| `map_create_tag_error:180-184` | `CreateTagError::PersistReference` | `PersistReferenceFailed` | +| `delete_tag_safe:242-246` | `tag::delete().map_err(...)` | `DeleteFailed` | +| `show_tag_safe:274-276` | `Ok(None)` tag not found | `NotFound` | +| `show_tag_safe:277-279` | `Err(e)` repo corrupt | `LoadFailed` | +| `run_tag_json:315-317` | `tag::list().map_err(...)` | `ListFailed` | +| `lookup_tag:332-334` | `Ok(None)` tag not found | `NotFound` | +| `lookup_tag:335-337` | `Err(e)` repo corrupt | `LoadFailed` | ### 特性 2:执行层与渲染层拆分 -**方案:** +**已落地部分(保持不变):** `TagOutput` enum(含 `List`/`Create`/`Delete` 三变体)、`TagListEntry` 结构体均已存在于 `tag.rs:41-63`,JSON schema 已稳定。 -```rust -#[derive(Debug, Clone, Serialize)] -#[serde(tag = "action")] -pub enum TagOutput { - #[serde(rename = "list")] - List(TagListOutput), - #[serde(rename = "create")] - Create(TagCreateOutput), - #[serde(rename = "delete")] - Delete(TagDeleteOutput), -} +**本批变更:统一 `run_tag()` / `render_tag_output()` 分层** -#[derive(Debug, Clone, Serialize)] -pub struct TagListOutput { - pub tags: Vec, -} +当前架构问题:human 路径在 `execute_safe()` 内分支拼装(list 走 `render_tags()`,create 走 `create_tag_safe()` + `show_tag_safe()`,delete 走 `delete_tag_safe()`),JSON 路径单独走 `run_tag_json()`。两条路径各自拼装逻辑,违反执行/渲染分离原则。 -#[derive(Debug, Clone, Serialize)] -pub struct TagListEntry { - pub name: String, - pub hash: String, - /// "lightweight" or "annotated" - pub tag_type: String, - /// Annotation message (first N lines, None for lightweight) - pub message: Option, -} +目标架构: -#[derive(Debug, Clone, Serialize)] -pub struct TagCreateOutput { - pub name: String, - pub hash: String, - pub tag_type: String, - pub message: Option, -} +```rust +/// 纯执行入口——收集结构化结果,不输出 +async fn run_tag(args: &TagArgs) -> Result -#[derive(Debug, Clone, Serialize)] -pub struct TagDeleteOutput { - pub name: String, - pub hash: Option, +/// 渲染层——根据 OutputConfig 决定 human/JSON/machine/quiet 输出 +fn render_tag_output(result: &TagOutput, output: &OutputConfig) -> CliResult<()> + +/// execute_safe 调用链 +pub async fn execute_safe(args: TagArgs, output: &OutputConfig) -> CliResult<()> { + let result = run_tag(&args).await.map_err(CliError::from)?; + render_tag_output(&result, output) } ``` +现有 `run_tag_json()` 将被合并入 `run_tag()`,`render_tags()` 的渲染逻辑将移入 `render_tag_output()` 的 human list 分支。 + **渲染规则:** | 模式 | stdout | stderr | |------|--------|--------| | human list | tag 名称列表(可选 `-n` 注释行数) | 无 | -| human create | 保留现有创建路径输出(lightweight create 后展示 tag/commit 信息;annotated create 无额外确认消息) | 无 | -| human delete | 确认消息(如 `Deleted tag 'v1.0' (was abc1234)`) | 无 | +| human create | 统一确认消息:`Created lightweight tag 'v1.0' at abc1234` 或 `Created annotated tag 'v1.0' at abc1234` | 无 | +| human delete | 确认消息:`Deleted tag 'v1.0' (was abc1234)`;target 丢失时退化为 `Deleted tag 'v1.0'` | 无 | | human + `--quiet` | 无 | 无 | | `--json` / `--machine` | JSON envelope(含 `action` 字段区分操作类型) | 无 | @@ -227,10 +259,10 @@ When deleting malformed refs that have no stored target, `hash` is `null`. | ID | 改进 | tag 中的具体落地 | |----|------|-----------------| -| **A** | 退出码 `0/128/129` | 参数错误(缺少 tag 名、不存在的 tag 名)→ exit `129`;运行时错误(重复创建、I/O 失败)→ exit `128`;成功 → exit `0` | +| **A** | 退出码 `0/128/129` | 参数错误(缺少 tag 名、不存在的 tag 名)→ exit `129`;运行时错误(重复创建、HEAD unborn、I/O 失败)→ exit `128`;成功 → exit `0` | | **B** | `--help` EXAMPLES | 见下方 EXAMPLES 段 | | **F** | 拼写纠错 | **不适用**——tag 名是用户自定义值,无 enum 可做 fuzzy match | -| **G** | Issues URL | 仅在 `LoadFailed` / `ListFailed` 错误时输出 Issues URL | +| **G** | Issues URL | 与 switch 保持一致——仅在映射为 `InternalInvariant` 的内部不变式错误时输出。当前仅 `SerializeAnnotatedTag` 属于此类;`RepoCorrupt`/`IoWriteFailed` 是数据或 I/O 问题,不附带 Issues URL | ### `--help` EXAMPLES 段 @@ -252,9 +284,11 @@ EXAMPLES: - **(已有)** 重复 tag 错误码、basic creation、annotated tag、force tag、list、delete、annotation lines - **(新增)`TagError` 变体覆盖**: - - `NotFound`:删除不存在 tag 返回 exit `129` - - `MissingName`:无 tag 名返回 exit `129` + - `NotFound`:删除不存在 tag 返回 exit `129` + `LBR-CLI-003` + - `MissingName`:无 tag 名返回 exit `129` + `LBR-CLI-002` + - `HeadUnborn`:空仓库创建 tag 返回 exit `128` + `LBR-REPO-003` - **(新增)quiet / delete 输出约束**:`--quiet tag -d` 不应污染 stdout;human delete 保持确认消息 +- **(新增)human create 输出统一**:lightweight / annotated create 均输出单行确认消息,不再依赖 `show_tag_safe()` 打印详情 - **(新增)force 失败路径回归**:`-f` 遇到对象存储失败时必须保留原有 ref,不得丢 tag - **(修复)全角括号**:将 `(lightweight tag)` 等改为 `(lightweight tag)` @@ -280,5 +314,5 @@ EXAMPLES: | 文件 | 改动类型 | 说明 | |------|---------|------| -| `src/command/tag.rs` | **重构** | 新增 `TagError` typed enum;新增 `TagOutput` / `TagListOutput` / `TagCreateOutput` / `TagDeleteOutput` 结构体;命令层 `TagError → CliError` 显式 `StableErrorCode` 映射;JSON 输出;quiet/delete 输出约束;补齐 `--help` EXAMPLES | -| `tests/command/tag_test.rs` | **扩展** | 新增 `TagError` 变体覆盖、JSON schema 回归、force 失败路径保护、修复全角括号 | +| `src/command/tag.rs` | **收口** | 保持已落地的 `TagOutput` / `run_tag_json()` / JSON schema / create hint 不回退;后续补齐 `TagError` typed enum、统一 `run_tag()` / `render_tag_output()`、收口 list/show 路径的显式错误码、统一 human 确认消息、补齐 `--help` EXAMPLES | +| `tests/command/tag_test.rs` | **扩展** | 在现有 JSON / quiet / malformed ref delete 回归基础上,补齐 `TagError` 变体覆盖、human 成功反馈一致性校验和全角括号清理 | diff --git a/src/command/branch.rs b/src/command/branch.rs index b15096c9..b9ffb762 100644 --- a/src/command/branch.rs +++ b/src/command/branch.rs @@ -5,6 +5,7 @@ use std::collections::{HashSet, VecDeque}; use clap::{ArgGroup, Parser}; use colored::Colorize; use git_internal::{hash::ObjectHash, internal::object::commit::Commit}; +use sea_orm::ConnectionTrait; use serde::Serialize; use crate::{ @@ -12,6 +13,7 @@ use crate::{ internal::{ branch::{self, Branch}, config::ConfigKv, + db::get_db_conn_instance, head::Head, }, utils::{ @@ -26,11 +28,33 @@ pub enum BranchListMode { All, } +const BRANCH_AFTER_HELP: &str = "\ +Compatibility Notes: + Libra's global --quiet suppresses the branch listing itself. + This differs from `git branch --quiet`, which still prints the primary list. + +EXAMPLES: + libra branch feature-x Create a branch from HEAD + libra branch feature-x main Create a branch from another branch + libra branch -d topic Delete a fully merged branch + libra branch -D topic Force-delete a branch + libra branch --set-upstream-to origin/main + Set upstream for the current branch + libra branch --json --show-current Structured JSON output for agents"; + #[derive(Debug, Clone, Serialize)] #[serde(tag = "action")] pub enum BranchOutput { #[serde(rename = "list")] - List { branches: Vec }, + List { + branches: Vec, + #[serde(skip_serializing)] + head_name: Option, + #[serde(skip_serializing)] + detached_head: Option, + #[serde(skip_serializing)] + show_unborn_head: bool, + }, #[serde(rename = "create")] Create { name: String, commit: String }, #[serde(rename = "delete")] @@ -56,14 +80,10 @@ pub struct BranchListEntry { pub name: String, pub current: bool, pub commit: String, + #[serde(skip_serializing)] + pub display_name: String, } -const BRANCH_AFTER_HELP: &str = "\ -Compatibility Notes: - Libra's global --quiet suppresses the branch listing itself. - This differs from `git branch --quiet`, which still prints the primary list. -"; - // action options are mutually exclusive with query options // query options can be combined #[derive(Parser, Debug)] @@ -139,136 +159,349 @@ pub async fn execute(args: BranchArgs) { /// errors and exiting. Creates, deletes, renames, or lists branches depending /// on the provided arguments. pub async fn execute_safe(args: BranchArgs, output: &OutputConfig) -> CliResult<()> { - if output.is_json() { - let result = run_branch_json(args, output).await?; - return emit_json_data("branch", &result, output); - } + let result = run_branch(&args).await.map_err(CliError::from)?; + render_branch_output(&result, output) +} - if let Some(new_branch) = args.new_branch { - create_branch_safe(new_branch, args.commit_hash).await - } else if let Some(branch_to_delete) = args.delete { - delete_branch(branch_to_delete).await - } else if let Some(branch_to_delete) = args.delete_safe { - delete_branch_safe(branch_to_delete, output).await - } else if args.show_current { - if !output.quiet { - show_current_branch().await; +#[derive(Debug, thiserror::Error)] +enum BranchError { + #[error("not a libra repository")] + NotInRepo, + + #[error("'{0}' is not a valid branch name")] + InvalidName(String), + + #[error("a branch named '{0}' already exists")] + AlreadyExists(String), + + #[error("branch '{name}' not found")] + NotFound { name: String, similar: Vec }, + + #[error("Cannot delete the branch '{0}' which you are currently on")] + DeleteCurrent(String), + + #[error("The branch '{0}' is not fully merged.")] + NotFullyMerged(String), + + #[error("the '{0}' branch is locked and cannot be modified")] + Locked(String), + + #[error("HEAD is detached")] + DetachedHead, + + #[error("not a valid object name: '{0}'")] + InvalidCommit(String), + + #[error("invalid upstream '{0}'")] + InvalidUpstream(String), + + #[error("{0}")] + ConfigReadFailed(String), + + #[error("failed to persist branch config '{key}': {detail}")] + ConfigWriteFailed { key: String, detail: String }, + + #[error("failed to query branch storage: {0}")] + StorageQueryFailed(String), + + #[error("stored branch reference is corrupt: {0}")] + StoredReferenceCorrupt(String), + + #[error("failed to create branch '{branch}': {detail}")] + CreateFailed { branch: String, detail: String }, + + #[error("failed to delete branch '{branch}': {detail}")] + DeleteFailed { branch: String, detail: String }, + + #[error("too many arguments")] + RenameTooManyArgs, + + #[error(transparent)] + DelegatedCli(#[from] CliError), +} + +impl From for CliError { + fn from(error: BranchError) -> Self { + match error { + BranchError::NotInRepo => CliError::repo_not_found(), + BranchError::InvalidName(name) => { + CliError::fatal(format!("'{name}' is not a valid branch name")) + .with_stable_code(StableErrorCode::CliInvalidArguments) + .with_hint( + "branch names cannot contain spaces, '..', '@{', or control characters.", + ) + } + BranchError::AlreadyExists(name) => { + CliError::fatal(format!("a branch named '{name}' already exists")) + .with_stable_code(StableErrorCode::ConflictOperationBlocked) + .with_hint("delete it first or choose a different name.") + } + BranchError::NotFound { name, similar } => { + let mut err = CliError::fatal(format!("branch '{name}' not found")) + .with_stable_code(StableErrorCode::CliInvalidTarget) + .with_hint("use 'libra branch -l' to list branches"); + for suggestion in similar { + err = err.with_hint(format!("did you mean '{suggestion}'?")); + } + err + } + BranchError::DeleteCurrent(name) => CliError::fatal(format!( + "Cannot delete the branch '{name}' which you are currently on" + )) + .with_stable_code(StableErrorCode::RepoStateInvalid) + .with_hint("switch to another branch first."), + BranchError::NotFullyMerged(name) => { + CliError::failure(format!("The branch '{name}' is not fully merged.")) + .with_stable_code(StableErrorCode::RepoStateInvalid) + .with_hint(format!( + "If you are sure you want to delete it, run 'libra branch -D {name}'." + )) + } + BranchError::Locked(name) => CliError::fatal(format!( + "the '{name}' branch is locked and cannot be modified" + )) + .with_stable_code(StableErrorCode::ConflictOperationBlocked), + BranchError::DetachedHead => CliError::fatal("HEAD is detached") + .with_stable_code(StableErrorCode::RepoStateInvalid) + .with_hint("checkout a branch first"), + BranchError::InvalidCommit(target) => { + CliError::fatal(format!("not a valid object name: '{target}'")) + .with_stable_code(StableErrorCode::CliInvalidTarget) + .with_hint("use 'libra log --oneline' to see available commits.") + } + BranchError::InvalidUpstream(upstream) => { + CliError::fatal(format!("invalid upstream '{upstream}'")) + .with_stable_code(StableErrorCode::CliInvalidTarget) + .with_hint("expected format: 'remote/branch'") + } + BranchError::ConfigReadFailed(detail) => CliError::fatal(detail) + .with_stable_code(StableErrorCode::IoReadFailed) + .with_hint("check whether the repository database is readable."), + BranchError::ConfigWriteFailed { key, detail } => { + CliError::fatal(format!("failed to persist branch config '{key}': {detail}")) + .with_stable_code(StableErrorCode::IoWriteFailed) + .with_hint("check whether the repository database is writable.") + } + BranchError::StorageQueryFailed(detail) => { + CliError::fatal(format!("failed to query branch storage: {detail}")) + .with_stable_code(StableErrorCode::IoReadFailed) + } + BranchError::StoredReferenceCorrupt(detail) => { + CliError::fatal(format!("stored branch reference is corrupt: {detail}")) + .with_stable_code(StableErrorCode::RepoCorrupt) + } + BranchError::CreateFailed { branch, detail } => { + CliError::fatal(format!("failed to create branch '{branch}': {detail}")) + .with_stable_code(StableErrorCode::IoWriteFailed) + } + BranchError::DeleteFailed { branch, detail } => { + CliError::fatal(format!("failed to delete branch '{branch}': {detail}")) + .with_stable_code(StableErrorCode::IoWriteFailed) + } + BranchError::RenameTooManyArgs => CliError::command_usage("too many arguments") + .with_stable_code(StableErrorCode::CliInvalidArguments) + .with_hint("usage: libra branch -m [old-name] new-name"), + BranchError::DelegatedCli(cli_error) => cli_error, } - Ok(()) - } else if let Some(upstream) = args.set_upstream_to { - match Head::current().await { - Head::Branch(name) => set_upstream_safe_with_output(&name, &upstream, output).await, - Head::Detached(_) => Err(detached_head_branch_error()), + } +} + +fn detached_head_branch_error() -> BranchError { + BranchError::DetachedHead +} + +fn map_branch_store_error(error: branch::BranchStoreError) -> BranchError { + match error { + branch::BranchStoreError::Query(detail) => BranchError::StorageQueryFailed(detail), + branch::BranchStoreError::Corrupt { detail, .. } => { + BranchError::StoredReferenceCorrupt(detail) } - } else if !args.rename.is_empty() { - rename_branch(args.rename, output).await + branch::BranchStoreError::NotFound(name) => BranchError::NotFound { + name, + similar: Vec::new(), + }, + branch::BranchStoreError::Delete { name, detail } => BranchError::DeleteFailed { + branch: name, + detail, + }, + } +} + +fn short_hash(hash: &str) -> &str { + let end = hash.len().min(7); + &hash[..end] +} + +fn levenshtein(a: &str, b: &str) -> usize { + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + let (a, b) = if a.len() > b.len() { + (&b, &a) } else { - // Default behavior: list branches - let list_mode = if args.all { - BranchListMode::All - } else if args.remotes { - BranchListMode::Remote - } else { - BranchListMode::Local - }; + (&a, &b) + }; + let mut prev: Vec = (0..=a.len()).collect(); + let mut curr = vec![0; a.len() + 1]; + for (i, cb) in b.iter().enumerate() { + curr[0] = i + 1; + for (j, ca) in a.iter().enumerate() { + let cost = usize::from(ca != cb); + curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1); + } + std::mem::swap(&mut prev, &mut curr); + } + prev[a.len()] +} + +fn find_similar_branch_names(branch_name: &str, branches: &[Branch]) -> Vec { + let target_len = branch_name.chars().count(); + let mut best: Option<(usize, String)> = None; + + for branch in branches { + if branch.name.chars().count().abs_diff(target_len) > 2 { + continue; + } - if output.quiet { - // Quiet mode: suppress branch listing. - Ok(()) - } else { - list_branches(list_mode, &args.contains, &args.no_contains).await + let distance = levenshtein(&branch.name, branch_name); + if distance > 2 { + continue; + } + + match &mut best { + Some((best_distance, best_name)) + if distance < *best_distance + || (distance == *best_distance && branch.name < *best_name) => + { + *best_distance = distance; + *best_name = branch.name.clone(); + } + None => best = Some((distance, branch.name.clone())), + _ => {} } } + + best.into_iter().map(|(_, name)| name).collect() } -fn detached_head_branch_error() -> CliError { - CliError::fatal("HEAD is detached") - .with_stable_code(StableErrorCode::RepoStateInvalid) - .with_hint("checkout a branch first") +async fn branch_not_found_error(branch_name: &str) -> BranchError { + let similar = Branch::list_branches_result(None) + .await + .map(|branches| find_similar_branch_names(branch_name, &branches)) + .unwrap_or_default(); + BranchError::NotFound { + name: branch_name.to_string(), + similar, + } } -pub async fn set_upstream(branch: &str, upstream: &str) { - if let Err(err) = set_upstream_safe(branch, upstream).await { - err.print_stderr(); +async fn require_existing_local_branch(branch_name: &str) -> Result { + match Branch::find_branch_result(branch_name, None) + .await + .map_err(map_branch_store_error)? + { + Some(branch) => Ok(branch), + None => Err(branch_not_found_error(branch_name).await), } } -pub async fn set_upstream_safe(branch: &str, upstream: &str) -> CliResult<()> { - set_upstream_safe_with_output(branch, upstream, &OutputConfig::default()).await +fn branch_config_read_error(scope: impl Into, error: impl ToString) -> BranchError { + let scope = scope.into(); + BranchError::ConfigReadFailed(format!("failed to read {scope}: {}", error.to_string())) } -pub async fn set_upstream_safe_with_output( +fn branch_config_write_error(key: &str, error: impl ToString) -> BranchError { + BranchError::ConfigWriteFailed { + key: key.to_string(), + detail: error.to_string(), + } +} + +async fn set_upstream_with_conn( + db: &C, branch: &str, upstream: &str, - output: &OutputConfig, -) -> CliResult<()> { - let branch_config = ConfigKv::branch_config(branch).await.ok().flatten(); - if branch_config.is_none() { - let (remote, remote_branch) = match upstream.split_once('/') { - Some((remote, branch)) => (remote, branch), - None => { - return Err(CliError::fatal(format!("invalid upstream '{}'", upstream)) - .with_stable_code(StableErrorCode::CliInvalidTarget) - .with_hint("expected format: 'remote/branch'")); - } - }; - let _ = ConfigKv::set(&format!("branch.{branch}.remote"), remote, false).await; - // set upstream branch (tracking branch) - let _ = ConfigKv::set( - &format!("branch.{branch}.merge"), - &format!("refs/heads/{remote_branch}"), - false, - ) - .await; +) -> Result<(), BranchError> { + let (remote, remote_branch) = upstream + .split_once('/') + .ok_or_else(|| BranchError::InvalidUpstream(upstream.to_string()))?; + let branch_config = ConfigKv::branch_config_with_conn(db, branch) + .await + .map_err(|e| { + branch_config_read_error(format!("upstream config for branch '{branch}'"), e) + })?; + let merge_ref = format!("refs/heads/{remote_branch}"); + let should_write = branch_config + .as_ref() + .map(|config| config.remote != remote || config.merge != remote_branch) + .unwrap_or(true); + + if should_write { + let remote_key = format!("branch.{branch}.remote"); + ConfigKv::set_with_conn(db, &remote_key, remote, false) + .await + .map_err(|e| branch_config_write_error(&remote_key, e))?; + let merge_key = format!("branch.{branch}.merge"); + ConfigKv::set_with_conn(db, &merge_key, &merge_ref, false) + .await + .map_err(|e| branch_config_write_error(&merge_key, e))?; } - crate::info_println!( - output, - "Branch '{branch}' set up to track remote branch '{upstream}'" - ); + Ok(()) } -pub async fn create_branch(new_branch: String, branch_or_commit: Option) { - if let Err(err) = create_branch_safe(new_branch, branch_or_commit).await { - err.print_stderr(); +async fn set_upstream_impl(branch: &str, upstream: &str) -> Result<(), BranchError> { + let db = get_db_conn_instance().await; + set_upstream_with_conn(&db, branch, upstream).await +} + +async fn load_remote_branches_with_conn( + db: &C, +) -> Result, BranchError> { + let remote_configs = ConfigKv::all_remote_configs_with_conn(db) + .await + .map_err(|e| branch_config_read_error("remote configuration", e))?; + let mut remote_branches = Vec::new(); + for remote in remote_configs { + remote_branches.extend( + Branch::list_branches_result_with_conn(db, Some(&remote.name)) + .await + .map_err(map_branch_store_error)?, + ); } + Ok(remote_branches) } -pub async fn create_branch_safe( +async fn load_remote_branches() -> Result, BranchError> { + let db = get_db_conn_instance().await; + load_remote_branches_with_conn(&db).await +} + +async fn create_branch_impl( new_branch: String, branch_or_commit: Option, -) -> CliResult<()> { +) -> Result { tracing::debug!("create branch: {} from {:?}", new_branch, branch_or_commit); if !is_valid_git_branch_name(&new_branch) { - return Err( - CliError::fatal(format!("'{}' is not a valid branch name", new_branch)) - .with_stable_code(StableErrorCode::CliInvalidArguments), - ); + return Err(BranchError::InvalidName(new_branch)); } if branch::is_locked_branch(&new_branch) { - return Err(CliError::fatal(format!( - "the '{}' branch is locked and cannot be created", - new_branch - )) - .with_stable_code(StableErrorCode::ConflictOperationBlocked)); - } - - // check if branch exists - let branch = Branch::find_branch(&new_branch, None).await; - if branch.is_some() { - return Err( - CliError::fatal(format!("a branch named '{}' already exists", new_branch)) - .with_stable_code(StableErrorCode::ConflictOperationBlocked), - ); + return Err(BranchError::Locked(new_branch)); + } + + if Branch::find_branch_result(&new_branch, None) + .await + .map_err(map_branch_store_error)? + .is_some() + { + return Err(BranchError::AlreadyExists(new_branch)); } let base_name = branch_or_commit.clone(); let commit_id = match branch_or_commit { - Some(branch_or_commit) => get_target_commit(&branch_or_commit).await.map_err(|_| { - CliError::fatal(format!("not a valid object name: '{}'", branch_or_commit)) - .with_stable_code(StableErrorCode::CliInvalidTarget) - })?, + Some(branch_or_commit) => get_target_commit(&branch_or_commit) + .await + .map_err(|_| BranchError::InvalidCommit(branch_or_commit))?, None => { if let Some(commit_id) = Head::current_commit().await { commit_id @@ -277,259 +510,357 @@ pub async fn create_branch_safe( Head::Branch(name) => name, Head::Detached(commit_hash) => commit_hash.to_string(), }; - return Err( - CliError::fatal(format!("not a valid object name: '{}'", current)) - .with_stable_code(StableErrorCode::CliInvalidTarget), - ); + return Err(BranchError::InvalidCommit(current)); } } }; - tracing::debug!("base commit_id: {}", commit_id); - // check if commit_hash exists let commit_id_display = commit_id.to_string(); load_object::(&commit_id).map_err(|_| { - CliError::fatal(format!( - "not a valid object name: '{}'", - base_name.as_deref().unwrap_or(commit_id_display.as_str()) - )) - .with_stable_code(StableErrorCode::CliInvalidTarget) + BranchError::InvalidCommit( + base_name + .as_deref() + .unwrap_or(commit_id_display.as_str()) + .to_string(), + ) })?; - // create branch Branch::update_branch(&new_branch, &commit_id.to_string(), None) .await - .map_err(|e| { - CliError::fatal(format!("failed to create branch '{}': {e}", new_branch)) - .with_stable_code(StableErrorCode::IoWriteFailed) + .map_err(|e| BranchError::CreateFailed { + branch: new_branch.clone(), + detail: e.to_string(), })?; - Ok(()) -} - -async fn delete_branch(branch_name: String) -> CliResult<()> { - if branch::is_locked_branch(&branch_name) { - return Err(CliError::fatal(format!( - "the '{}' branch is locked and cannot be deleted", - branch_name - )) - .with_stable_code(StableErrorCode::ConflictOperationBlocked)); - } - - Branch::find_branch(&branch_name, None) - .await - .ok_or_else(|| { - CliError::fatal(format!("branch '{}' not found", branch_name)) - .with_stable_code(StableErrorCode::CliInvalidTarget) - .with_hint("use 'libra branch -l' to list branches") - })?; - let head = Head::current().await; - if let Head::Branch(name) = head - && name == branch_name - { - return Err(CliError::fatal(format!( - "Cannot delete the branch '{}' which you are currently on", - branch_name - )) - .with_stable_code(StableErrorCode::RepoStateInvalid)); - } - - Branch::delete_branch(&branch_name, None).await; - Ok(()) + Ok(BranchOutput::Create { + name: new_branch, + commit: commit_id_display, + }) } -/// Safely delete a branch, refusing if it contains unmerged commits. -/// -/// This performs a merge check to ensure the branch is fully merged into HEAD -/// before deletion. If the branch is not fully merged, prints an error and -/// suggests using `branch -D` for force deletion. -async fn delete_branch_safe(branch_name: String, output: &OutputConfig) -> CliResult<()> { +async fn delete_branch_impl(branch_name: String, force: bool) -> Result { if branch::is_locked_branch(&branch_name) { - return Err(CliError::fatal(format!( - "the '{}' branch is locked and cannot be deleted", - branch_name - )) - .with_stable_code(StableErrorCode::ConflictOperationBlocked)); + return Err(BranchError::Locked(branch_name)); } - // 1. Check if branch exists - let branch = Branch::find_branch(&branch_name, None) - .await - .ok_or_else(|| { - CliError::fatal(format!("branch '{}' not found", branch_name)) - .with_stable_code(StableErrorCode::CliInvalidTarget) - .with_hint("use 'libra branch -l' to list branches") - })?; - - // 2. Check if trying to delete current branch + let branch = require_existing_local_branch(&branch_name).await?; let head = Head::current().await; if let Head::Branch(name) = &head && name == &branch_name { - return Err(CliError::fatal(format!( - "Cannot delete the branch '{}' which you are currently on", - branch_name - )) - .with_stable_code(StableErrorCode::RepoStateInvalid)); + return Err(BranchError::DeleteCurrent(branch_name)); } - // 3. Check if the branch is fully merged into HEAD - // Get current HEAD commit - let head_commit = match head { - Head::Branch(_) => Head::current_commit() - .await - .ok_or_else(|| CliError::fatal("cannot get HEAD commit"))?, - Head::Detached(commit_hash) => commit_hash, - }; + if !force { + let head_commit = match head { + Head::Branch(_) => Head::current_commit().await.ok_or_else(|| { + BranchError::DelegatedCli( + CliError::fatal("cannot get HEAD commit") + .with_stable_code(StableErrorCode::RepoStateInvalid), + ) + })?, + Head::Detached(commit_hash) => commit_hash, + }; - // Get all commits reachable from HEAD - let head_reachable = - crate::command::log::get_reachable_commits(head_commit.to_string(), None).await?; - - // Build HashSet for efficient lookup using ObjectHash directly (avoid string allocations) - let head_commit_ids: std::collections::HashSet<_> = - head_reachable.iter().map(|c| c.id).collect(); - - // Check if the branch's HEAD commit is reachable from current HEAD - // If the branch commit is in HEAD's history, the branch is fully merged - if !head_commit_ids.contains(&branch.commit) { - // Branch is not fully merged - return Err(CliError::failure(format!( - "The branch '{}' is not fully merged.", - branch_name - )) - .with_stable_code(StableErrorCode::RepoStateInvalid) - .with_hint(format!( - "If you are sure you want to delete it, run 'libra branch -D {}'.", - branch_name - ))); - } - - // All checks passed, safe to delete - let commit = branch.commit.to_string(); - Branch::delete_branch(&branch_name, None).await; - if !output.quiet && !output.is_json() { - println!("Deleted branch {} (was {}).", branch_name, commit); + let head_reachable = + crate::command::log::get_reachable_commits(head_commit.to_string(), None) + .await + .map_err(BranchError::DelegatedCli)?; + let head_commit_ids: std::collections::HashSet<_> = + head_reachable.iter().map(|c| c.id).collect(); + if !head_commit_ids.contains(&branch.commit) { + return Err(BranchError::NotFullyMerged(branch_name)); + } } - Ok(()) + + Branch::delete_branch_result(&branch_name, None) + .await + .map_err(map_branch_store_error)?; + + Ok(BranchOutput::Delete { + name: branch_name, + commit: branch.commit.to_string(), + force, + }) } -async fn rename_branch(args: Vec, output: &OutputConfig) -> CliResult<()> { +async fn rename_branch_impl(args: &[String]) -> Result { let (old_name, new_name) = match args.len() { - 1 => { - // rename current branch - let head = Head::current().await; - match head { - Head::Branch(name) => (name, args[0].clone()), - Head::Detached(_) => return Err(detached_head_branch_error()), - } - } + 1 => match Head::current().await { + Head::Branch(name) => (name, args[0].clone()), + Head::Detached(_) => return Err(detached_head_branch_error()), + }, 2 => (args[0].clone(), args[1].clone()), - _ => { - return Err(CliError::command_usage("too many arguments") - .with_stable_code(StableErrorCode::CliInvalidArguments) - .with_hint("usage: libra branch -m [old-name] new-name")); - } + _ => return Err(BranchError::RenameTooManyArgs), }; if !is_valid_git_branch_name(&new_name) { - return Err( - CliError::fatal(format!("invalid branch name: {}", new_name)) - .with_stable_code(StableErrorCode::CliInvalidArguments), - ); + return Err(BranchError::InvalidName(new_name)); } - if branch::is_locked_branch(&new_name) { - return Err(CliError::fatal(format!( - "the '{}' branch is locked and cannot be overwritten", - new_name - )) - .with_stable_code(StableErrorCode::ConflictOperationBlocked)); + return Err(BranchError::Locked(new_name)); } - if branch::is_locked_branch(&old_name) { - return Err(CliError::fatal(format!( - "the '{}' branch is locked and cannot be renamed", - old_name - )) - .with_stable_code(StableErrorCode::ConflictOperationBlocked)); + return Err(BranchError::Locked(old_name)); } - // check if old branch exists - let old_branch = Branch::find_branch(&old_name, None).await.ok_or_else(|| { - CliError::fatal(format!("branch '{}' not found", old_name)) - .with_stable_code(StableErrorCode::CliInvalidTarget) - })?; - - // check if new branch name already exists - let new_branch_exists = Branch::find_branch(&new_name, None).await; - if new_branch_exists.is_some() { - return Err( - CliError::fatal(format!("A branch named '{}' already exists.", new_name)) - .with_stable_code(StableErrorCode::ConflictOperationBlocked), - ); + let old_branch = require_existing_local_branch(&old_name).await?; + if Branch::find_branch_result(&new_name, None) + .await + .map_err(map_branch_store_error)? + .is_some() + { + return Err(BranchError::AlreadyExists(new_name)); } let commit_hash = old_branch.commit.to_string(); - - // create new branch with the same commit Branch::update_branch(&new_name, &commit_hash, None) .await - .map_err(|e| { - CliError::fatal(format!("failed to create branch '{}': {e}", new_name)) - .with_stable_code(StableErrorCode::IoWriteFailed) + .map_err(|e| BranchError::CreateFailed { + branch: new_name.clone(), + detail: e.to_string(), })?; - // update HEAD if renaming current branch - let head = Head::current().await; - if let Head::Branch(name) = head + if let Head::Branch(name) = Head::current().await && name == old_name { - let new_head = Head::Branch(new_name.clone()); - Head::update(new_head, None).await; + Head::update(Head::Branch(new_name.clone()), None).await; } - // delete old branch - Branch::delete_branch(&old_name, None).await; + Branch::delete_branch_result(&old_name, None) + .await + .map_err(map_branch_store_error)?; + + Ok(BranchOutput::Rename { old_name, new_name }) +} + +async fn collect_branch_output(args: &BranchArgs) -> Result { + let list_mode = if args.all { + BranchListMode::All + } else if args.remotes { + BranchListMode::Remote + } else { + BranchListMode::Local + }; + let has_commit_filters = !args.contains.is_empty() || !args.no_contains.is_empty(); + let (head_name, detached_head) = match Head::current().await { + Head::Branch(name) => (Some(name), None), + Head::Detached(commit_hash) => (None, Some(commit_hash.to_string())), + }; - if !output.quiet && !output.is_json() { - println!("Renamed branch '{old_name}' to '{new_name}'"); + let mut local_branches = match list_mode { + BranchListMode::Local | BranchListMode::All => Branch::list_branches_result(None) + .await + .map_err(map_branch_store_error)?, + BranchListMode::Remote => vec![], + }; + let mut remote_branches = if matches!(list_mode, BranchListMode::Remote | BranchListMode::All) { + load_remote_branches().await? + } else { + vec![] + }; + + let contains_set = resolve_commits(&args.contains) + .await + .map_err(BranchError::DelegatedCli)?; + let no_contains_set = resolve_commits(&args.no_contains) + .await + .map_err(BranchError::DelegatedCli)?; + for branches in [&mut local_branches, &mut remote_branches] { + filter_branches(branches, &contains_set, &no_contains_set) + .map_err(BranchError::DelegatedCli)?; } - Ok(()) + + let current_name = head_name.as_deref(); + let mut entries = Vec::new(); + for branch in local_branches { + entries.push(BranchListEntry { + current: current_name == Some(branch.name.as_str()), + commit: branch.commit.to_string(), + display_name: branch.name.clone(), + name: branch.name, + }); + } + for branch in remote_branches { + entries.push(BranchListEntry { + current: false, + commit: branch.commit.to_string(), + display_name: format_branch_name(&branch), + name: branch.name, + }); + } + + let show_unborn_head = entries.is_empty() + && detached_head.is_none() + && !has_commit_filters + && matches!(list_mode, BranchListMode::Local | BranchListMode::All) + && head_name.is_some(); + + Ok(BranchOutput::List { + branches: entries, + head_name, + detached_head, + show_unborn_head, + }) } -async fn show_current_branch() { - // let head = reference::Model::current_head(&db).await.unwrap(); - let head = Head::current().await; - match head { - Head::Detached(commit_hash) => { - println!("HEAD detached at {}", &commit_hash.to_string()[..8]); +async fn run_branch(args: &BranchArgs) -> Result { + crate::utils::util::require_repo().map_err(|_| BranchError::NotInRepo)?; + + if let Some(new_branch) = args.new_branch.clone() { + create_branch_impl(new_branch, args.commit_hash.clone()).await + } else if let Some(branch_to_delete) = args.delete.clone() { + delete_branch_impl(branch_to_delete, true).await + } else if let Some(branch_to_delete) = args.delete_safe.clone() { + delete_branch_impl(branch_to_delete, false).await + } else if args.show_current { + Ok(match Head::current().await { + Head::Branch(name) => BranchOutput::ShowCurrent { + name: Some(name), + detached: false, + commit: Head::current_commit().await.map(|hash| hash.to_string()), + }, + Head::Detached(hash) => BranchOutput::ShowCurrent { + name: None, + detached: true, + commit: Some(hash.to_string()), + }, + }) + } else if let Some(upstream) = args.set_upstream_to.as_deref() { + let branch = match Head::current().await { + Head::Branch(name) => name, + Head::Detached(_) => return Err(detached_head_branch_error()), + }; + set_upstream_impl(&branch, upstream).await?; + Ok(BranchOutput::SetUpstream { + branch, + upstream: upstream.to_string(), + }) + } else if !args.rename.is_empty() { + rename_branch_impl(&args.rename).await + } else { + collect_branch_output(args).await + } +} + +fn render_branch_output(result: &BranchOutput, output: &OutputConfig) -> CliResult<()> { + if output.is_json() { + return emit_json_data("branch", result, output); + } + if output.quiet { + return Ok(()); + } + + match result { + BranchOutput::List { + branches, + head_name, + detached_head, + show_unborn_head, + } => { + if let Some(detached_head) = detached_head { + println!("HEAD detached at {}", short_hash(detached_head).green()); + } + if branches.is_empty() { + if *show_unborn_head && let Some(head_name) = head_name { + println!("* {}", head_name.green()); + } + return Ok(()); + } + + let mut sorted = branches.clone(); + sorted.sort_by(|a, b| { + if a.current { + std::cmp::Ordering::Less + } else if b.current { + std::cmp::Ordering::Greater + } else { + a.name.cmp(&b.name) + } + }); + + for branch in sorted { + if branch.current { + println!("* {}", branch.display_name.green()); + } else { + println!(" {}", branch.display_name); + } + } + } + BranchOutput::Create { name, commit } => { + println!("Created branch '{name}' at {}", short_hash(commit)); + } + BranchOutput::Delete { + name, + commit, + force: _, + } => { + println!("Deleted branch {name} (was {}).", short_hash(commit)); } - Head::Branch(name) => { - println!("{name}"); + BranchOutput::Rename { old_name, new_name } => { + println!("Renamed branch '{old_name}' to '{new_name}'"); + } + BranchOutput::SetUpstream { branch, upstream } => { + println!("Branch '{branch}' set up to track remote branch '{upstream}'"); + } + BranchOutput::ShowCurrent { + name, + detached, + commit, + } => { + if *detached { + if let Some(commit) = commit { + println!("HEAD detached at {}", short_hash(commit)); + } + } else if let Some(name) = name { + println!("{name}"); + } } } + + Ok(()) } -/// Return the current HEAD name and optionally print detached-HEAD info. -/// -/// When `print` is `true`, a "HEAD detached at ..." line is written to stdout -/// (the traditional human-visible behavior). Pass `false` for machine-readable -/// paths that must not leak human text. -async fn head_branch_name(print: bool) -> String { - let head = Head::current().await; - if print && let Head::Detached(commit) = head { - let s = "HEAD detached at ".to_string() + &commit.to_string()[..8]; - let s = s.green(); - println!("{s}"); + +pub async fn set_upstream(branch: &str, upstream: &str) { + if let Err(err) = set_upstream_safe(branch, upstream).await { + err.print_stderr(); } - match head { - Head::Branch(name) => name, - Head::Detached(_) => "".to_string(), +} + +pub async fn set_upstream_safe(branch: &str, upstream: &str) -> CliResult<()> { + set_upstream_safe_with_output(branch, upstream, &OutputConfig::default()).await +} + +pub async fn set_upstream_safe_with_output( + branch: &str, + upstream: &str, + output: &OutputConfig, +) -> CliResult<()> { + set_upstream_impl(branch, upstream) + .await + .map_err(CliError::from)?; + crate::info_println!( + output, + "Branch '{branch}' set up to track remote branch '{upstream}'" + ); + Ok(()) +} + +pub async fn create_branch(new_branch: String, branch_or_commit: Option) { + if let Err(err) = create_branch_safe(new_branch, branch_or_commit).await { + err.print_stderr(); } } -async fn display_head_state() -> String { - head_branch_name(true).await +pub async fn create_branch_safe( + new_branch: String, + branch_or_commit: Option, +) -> CliResult<()> { + create_branch_impl(new_branch, branch_or_commit) + .await + .map(|_| ()) + .map_err(CliError::from)?; + Ok(()) } fn format_branch_name(branch: &Branch) -> String { @@ -545,128 +876,32 @@ fn format_branch_name(branch: &Branch) -> String { display_name.red().to_string() } -fn display_branches(branches: Vec, head_name: &str, is_remote: bool) { - let branches_sorted = { - let mut sorted_branches = branches; - sorted_branches.sort_by(|a, b| { - if a.name == head_name { - std::cmp::Ordering::Less - } else if b.name == head_name { - std::cmp::Ordering::Greater - } else { - a.name.cmp(&b.name) - } - }); - sorted_branches - }; - - for branch in branches_sorted { - let name = if is_remote { - format_branch_name(&branch) - } else { - branch.name.clone() - }; - - if head_name == branch.name { - println!("* {}", name.green()); - } else { - println!(" {}", name); - } - } -} - -/// Collect structured branch list entries for JSON output. -async fn collect_branch_names( - list_mode: BranchListMode, - commits_contains: &[String], - commits_no_contains: &[String], -) -> CliResult> { - // Use the quiet variant: do NOT print "HEAD detached at ..." to stdout. - let head_name = head_branch_name(false).await; - - let mut local_branches = match &list_mode { - BranchListMode::Local | BranchListMode::All => Branch::list_branches(None).await, - _ => vec![], - }; - let mut remote_branches = vec![]; - match list_mode { - BranchListMode::Remote | BranchListMode::All => { - let remote_configs = ConfigKv::all_remote_configs().await.unwrap_or_default(); - for remote in remote_configs { - remote_branches.extend(Branch::list_branches(Some(&remote.name)).await); - } - } - _ => {} - }; - - let contains_set = resolve_commits(commits_contains).await?; - let no_contains_set = resolve_commits(commits_no_contains).await?; - for branches in [&mut local_branches, &mut remote_branches] { - filter_branches(branches, &contains_set, &no_contains_set)?; - } - - let mut result = Vec::new(); - for branch in local_branches.iter().chain(remote_branches.iter()) { - result.push(BranchListEntry { - name: branch.name.clone(), - current: branch.name == head_name, - commit: branch.commit.to_string(), - }); - } - Ok(result) -} - +/// List branches with the given mode and commit filters, rendering directly to stdout. +/// +/// This is a convenience wrapper around the structured `run_branch` path, +/// kept for backward compatibility with callers that need a simple +/// "print branches" operation. pub async fn list_branches( list_mode: BranchListMode, commits_contains: &[String], commits_no_contains: &[String], ) -> CliResult<()> { - let head_name = display_head_state().await; - let has_commit_filters = !commits_contains.is_empty() || !commits_no_contains.is_empty(); - - // filter branches by `list_mode` - let mut local_branches = match &list_mode { - BranchListMode::Local | BranchListMode::All => Branch::list_branches(None).await, - _ => vec![], - }; - let mut remote_branches = vec![]; - match list_mode { - BranchListMode::Remote | BranchListMode::All => { - let remote_configs = ConfigKv::all_remote_configs().await.unwrap_or_default(); - for remote in remote_configs { - remote_branches.extend(Branch::list_branches(Some(&remote.name)).await); - } - } - _ => {} + let args = BranchArgs { + new_branch: None, + commit_hash: None, + list: true, + delete: None, + delete_safe: None, + set_upstream_to: None, + show_current: false, + rename: vec![], + remotes: matches!(list_mode, BranchListMode::Remote), + all: matches!(list_mode, BranchListMode::All), + contains: commits_contains.to_vec(), + no_contains: commits_no_contains.to_vec(), }; - - // apply the filter to `local_branches` and `remote_branches` - // When a list is empty the corresponding constraint is vacuously satisfied: - // - empty `commits_contains` → every branch passes the "contains" check - // - empty `commits_no_contains` → every branch passes the "no-contains" check - // Pre-resolve target commits once to avoid repeated string parsing - let contains_set = resolve_commits(commits_contains).await?; - let no_contains_set = resolve_commits(commits_no_contains).await?; - for branches in [&mut local_branches, &mut remote_branches] { - filter_branches(branches, &contains_set, &no_contains_set)?; - } - - // display `local_branches` and `remote_branches` if not empty - if !local_branches.is_empty() { - display_branches(local_branches, &head_name, false); - } else if matches!(list_mode, BranchListMode::Local | BranchListMode::All) - && !has_commit_filters - { - // Fix: If there are no branches but we are on a valid HEAD (unborn branch), show it. - // This happens on fresh init where HEAD points to 'main' but 'main' record doesn't exist yet. - if !head_name.is_empty() { - println!("* {}", head_name.green()); - } - } - if !remote_branches.is_empty() { - display_branches(remote_branches, &head_name, true); - } - Ok(()) + let result = collect_branch_output(&args).await.map_err(CliError::from)?; + render_branch_output(&result, &OutputConfig::default()) } /// Filter given branches by whether they contain or don't contain certain commits. @@ -759,98 +994,6 @@ fn commit_contains( Ok(false) } -async fn run_branch_json(args: BranchArgs, output: &OutputConfig) -> CliResult { - if let Some(new_branch) = args.new_branch { - create_branch_safe(new_branch.clone(), args.commit_hash).await?; - let branch = Branch::find_branch(&new_branch, None) - .await - .ok_or_else(|| { - CliError::fatal(format!("branch '{}' not found", new_branch)) - .with_stable_code(StableErrorCode::InternalInvariant) - })?; - Ok(BranchOutput::Create { - name: new_branch, - commit: branch.commit.to_string(), - }) - } else if let Some(branch_to_delete) = args.delete { - let branch = Branch::find_branch(&branch_to_delete, None) - .await - .ok_or_else(|| { - CliError::fatal(format!("branch '{}' not found", branch_to_delete)) - .with_stable_code(StableErrorCode::CliInvalidTarget) - .with_hint("use 'libra branch -l' to list branches") - })?; - delete_branch(branch_to_delete.clone()).await?; - Ok(BranchOutput::Delete { - name: branch_to_delete, - commit: branch.commit.to_string(), - force: true, - }) - } else if let Some(branch_to_delete) = args.delete_safe { - let branch = Branch::find_branch(&branch_to_delete, None) - .await - .ok_or_else(|| { - CliError::fatal(format!("branch '{}' not found", branch_to_delete)) - .with_stable_code(StableErrorCode::CliInvalidTarget) - .with_hint("use 'libra branch -l' to list branches") - })?; - delete_branch_safe(branch_to_delete.clone(), output).await?; - Ok(BranchOutput::Delete { - name: branch_to_delete, - commit: branch.commit.to_string(), - force: false, - }) - } else if args.show_current { - Ok(match Head::current().await { - Head::Branch(name) => BranchOutput::ShowCurrent { - name: Some(name), - detached: false, - commit: Head::current_commit().await.map(|hash| hash.to_string()), - }, - Head::Detached(hash) => BranchOutput::ShowCurrent { - name: None, - detached: true, - commit: Some(hash.to_string()), - }, - }) - } else if let Some(upstream) = args.set_upstream_to { - let branch = match Head::current().await { - Head::Branch(name) => name, - Head::Detached(_) => { - return Err(detached_head_branch_error()); - } - }; - let mut quiet_output = output.clone(); - quiet_output.quiet = true; - set_upstream_safe_with_output(&branch, &upstream, &quiet_output).await?; - Ok(BranchOutput::SetUpstream { branch, upstream }) - } else if !args.rename.is_empty() { - let old_name = if args.rename.len() == 1 { - match Head::current().await { - Head::Branch(name) => name, - Head::Detached(_) => { - return Err(detached_head_branch_error()); - } - } - } else { - args.rename[0].clone() - }; - let new_name = args.rename.last().cloned().unwrap_or_default(); - rename_branch(args.rename, output).await?; - Ok(BranchOutput::Rename { old_name, new_name }) - } else { - let list_mode = if args.all { - BranchListMode::All - } else if args.remotes { - BranchListMode::Remote - } else { - BranchListMode::Local - }; - let branches = collect_branch_names(list_mode, &args.contains, &args.no_contains).await?; - Ok(BranchOutput::List { branches }) - } -} - pub fn is_valid_git_branch_name(name: &str) -> bool { // Validate branch name // Not contain spaces, control characters or special characters @@ -889,8 +1032,9 @@ mod tests { use std::str::FromStr; use git_internal::hash::{ObjectHash, get_hash_kind}; + use sea_orm::Database; - use super::{Branch, format_branch_name}; + use super::{Branch, BranchError, format_branch_name, load_remote_branches_with_conn}; fn any_hash() -> ObjectHash { ObjectHash::from_str(&ObjectHash::zero_str(get_hash_kind())).unwrap() @@ -919,4 +1063,18 @@ mod tests { assert_eq!(format_branch_name(&branch), "origin/main"); } + + #[tokio::test] + async fn test_load_remote_branches_with_conn_surfaces_config_read_failure() { + let db = Database::connect("sqlite::memory:").await.unwrap(); + db.clone().close().await.unwrap(); + + let error = load_remote_branches_with_conn(&db).await.unwrap_err(); + match error { + BranchError::ConfigReadFailed(detail) => { + assert!(detail.contains("failed to read remote configuration")); + } + other => panic!("expected config read failure, got {other:?}"), + } + } } diff --git a/src/command/reset.rs b/src/command/reset.rs index d5bed5bb..140effcb 100644 --- a/src/command/reset.rs +++ b/src/command/reset.rs @@ -26,14 +26,23 @@ use crate::{ reflog::{ReflogAction, ReflogContext, with_reflog}, }, utils::{ - error::{CliError, CliResult, StableErrorCode}, + error::{CliError, CliResult, StableErrorCode, emit_warning}, object_ext::{BlobExt, TreeExt}, output::{OutputConfig, emit_json_data}, path, util, }, }; +const RESET_EXAMPLES: &str = "\ +EXAMPLES: + libra reset HEAD~1 Move HEAD and reset index to the previous commit + libra reset --soft HEAD~2 Move HEAD only, keep index and worktree + libra reset --hard main Reset HEAD, index, and worktree to branch 'main' + libra reset HEAD -- src/lib.rs Unstage a path back to HEAD + libra reset --json --hard HEAD~1 Structured JSON output for agents"; + #[derive(Parser, Debug)] +#[command(after_help = RESET_EXAMPLES)] pub struct ResetArgs { /// The commit to reset to (default: HEAD) #[clap(default_value = "HEAD")] @@ -73,9 +82,16 @@ impl ResetMode { } } -#[derive(Debug, Default, Clone, Copy)] +#[derive(Debug, Default, Clone)] struct ResetStats { files_restored: usize, + warnings: Vec, +} + +#[derive(Debug, Clone)] +struct ResetExecution { + output: ResetOutput, + warnings: Vec, } #[derive(Debug, Clone, Serialize)] @@ -105,12 +121,132 @@ pub async fn execute(args: ResetArgs) { /// errors and exiting. Moves HEAD (and optionally the index/worktree) to a /// target commit using soft, mixed, or hard mode. pub async fn execute_safe(args: ResetArgs, output: &OutputConfig) -> CliResult<()> { - let result = run_reset(args).await?; - render_reset_output(&result, output) + let result = run_reset(args).await.map_err(CliError::from)?; + render_reset_output(&result.output, output)?; + for warning in result.warnings { + emit_warning(warning); + } + Ok(()) } -async fn run_reset(args: ResetArgs) -> CliResult { - util::require_repo().map_err(|_| CliError::repo_not_found())?; +#[derive(Debug, thiserror::Error)] +enum ResetError { + #[error("not a libra repository")] + NotInRepo, + + #[error("{0}")] + InvalidRevision(String), + + #[error("Cannot reset: HEAD is unborn and points to no commit.")] + HeadUnborn, + + #[error("failed to load {kind} '{object_id}': {detail}")] + ObjectLoad { + kind: &'static str, + object_id: String, + detail: String, + }, + + #[error("failed to load index: {0}")] + IndexLoad(String), + + #[error("failed to save index: {0}")] + IndexSave(String), + + #[error("failed to update HEAD: {0}")] + HeadUpdate(String), + + #[error("failed to read working tree: {0}")] + WorktreeRead(String), + + #[error("failed to restore working tree: {0}")] + WorktreeRestore(String), + + #[error("path contains invalid UTF-8: {0}")] + InvalidPathspecEncoding(String), + + #[error("pathspec '{0}' is not compatible with --soft reset")] + PathspecWithSoft(String), + + #[error("Cannot do hard reset with paths.")] + PathspecWithHard, + + #[error("pathspec '{0}' did not match any file(s) known to libra")] + PathspecNotMatched(String), +} + +impl From for CliError { + fn from(error: ResetError) -> Self { + match error { + ResetError::NotInRepo => CliError::repo_not_found(), + ResetError::InvalidRevision(message) => CliError::fatal(message) + .with_stable_code(StableErrorCode::CliInvalidTarget) + .with_hint("check the revision name and try again."), + ResetError::HeadUnborn => CliError::fatal(error.to_string()) + .with_stable_code(StableErrorCode::RepoStateInvalid) + .with_hint("create a commit first before resetting HEAD."), + ResetError::ObjectLoad { .. } => CliError::fatal(error.to_string()) + .with_stable_code(StableErrorCode::RepoCorrupt) + .with_hint("the object store may be corrupted."), + ResetError::IndexLoad(_) => CliError::fatal(error.to_string()) + .with_stable_code(StableErrorCode::RepoCorrupt) + .with_hint("the index file may be corrupted."), + ResetError::IndexSave(_) => { + CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::IoWriteFailed) + } + ResetError::HeadUpdate(_) => { + CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::IoWriteFailed) + } + ResetError::WorktreeRead(_) => { + CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::IoReadFailed) + } + ResetError::WorktreeRestore(_) => { + CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::IoWriteFailed) + } + ResetError::InvalidPathspecEncoding(path) => CliError::fatal(format!( + "path contains invalid UTF-8: {path}" + )) + .with_stable_code(StableErrorCode::CliInvalidArguments) + .with_hint( + "rename the path or invoke libra from a path representable as UTF-8.", + ), + ResetError::PathspecWithSoft(pathspec) => CliError::command_usage(format!( + "pathspec '{pathspec}' is not compatible with --soft reset" + )) + .with_stable_code(StableErrorCode::CliInvalidArguments) + .with_hint("--soft only moves HEAD; use --mixed to reset index for specific paths."), + ResetError::PathspecWithHard => { + CliError::command_usage("Cannot do hard reset with paths.") + .with_stable_code(StableErrorCode::CliInvalidArguments) + .with_hint( + "--hard updates the working tree; omit pathspecs or use --mixed for specific paths.", + ) + } + ResetError::PathspecNotMatched(pathspec) => { + CliError::fatal(format!( + "pathspec '{pathspec}' did not match any file(s) known to libra" + )) + .with_stable_code(StableErrorCode::CliInvalidTarget) + .with_hint("check the path and try again.") + } + } + } +} + +fn object_load_error( + kind: &'static str, + object_id: impl Into, + detail: impl Into, +) -> ResetError { + ResetError::ObjectLoad { + kind, + object_id: object_id.into(), + detail: detail.into(), + } +} + +async fn run_reset(args: ResetArgs) -> Result { + util::require_repo().map_err(|_| ResetError::NotInRepo)?; let mode = if args.soft { ResetMode::Soft @@ -123,57 +259,47 @@ async fn run_reset(args: ResetArgs) -> CliResult { if !args.pathspecs.is_empty() { if matches!(mode, ResetMode::Soft) { - return Err(CliError::command_usage(format!( - "pathspec '{}' is not compatible with --soft reset", - args.pathspecs.join(" ") - )) - .with_stable_code(StableErrorCode::CliInvalidArguments) - .with_hint("--soft only moves HEAD; use --mixed to reset index for specific paths.")); + return Err(ResetError::PathspecWithSoft(args.pathspecs.join(" "))); } if matches!(mode, ResetMode::Hard) { - return Err(CliError::command_usage("Cannot do hard reset with paths.") - .with_stable_code(StableErrorCode::CliInvalidArguments) - .with_hint( - "--hard updates the working tree; omit pathspecs or use --mixed for specific paths.", - )); + return Err(ResetError::PathspecWithHard); } - let target_commit_id = resolve_commit(&args.target) - .await - .map_err(map_reset_invalid_revision)?; + let target_commit_id = resolve_commit(&args.target).await?; let changed_paths = reset_pathspecs(&args.pathspecs, &target_commit_id).await?; let subject = get_commit_summary(&target_commit_id).unwrap_or_default(); - return Ok(ResetOutput { - mode: mode.as_str().to_string(), - commit: target_commit_id.to_string(), - short_commit: target_commit_id.to_string()[..7].to_string(), - subject, - previous_commit, - files_unstaged: changed_paths.len(), - files_restored: 0, - pathspecs: changed_paths, + return Ok(ResetExecution { + output: ResetOutput { + mode: mode.as_str().to_string(), + commit: target_commit_id.to_string(), + short_commit: target_commit_id.to_string()[..7].to_string(), + subject, + previous_commit, + files_unstaged: changed_paths.len(), + files_restored: 0, + pathspecs: changed_paths, + }, + warnings: Vec::new(), }); } - let target_commit_id = resolve_commit(&args.target) - .await - .map_err(map_reset_invalid_revision)?; - - let reset_stats = perform_reset(target_commit_id, mode, &args.target) - .await - .map_err(map_reset_runtime_error)?; + let target_commit_id = resolve_commit(&args.target).await?; + let reset_stats = perform_reset(target_commit_id, mode, &args.target).await?; let subject = get_commit_summary(&target_commit_id).unwrap_or_default(); - Ok(ResetOutput { - mode: mode.as_str().to_string(), - commit: target_commit_id.to_string(), - short_commit: target_commit_id.to_string()[..7].to_string(), - subject, - previous_commit, - files_unstaged: 0, - files_restored: reset_stats.files_restored, - pathspecs: Vec::new(), + Ok(ResetExecution { + output: ResetOutput { + mode: mode.as_str().to_string(), + commit: target_commit_id.to_string(), + short_commit: target_commit_id.to_string()[..7].to_string(), + subject, + previous_commit, + files_unstaged: 0, + files_restored: reset_stats.files_restored, + pathspecs: Vec::new(), + }, + warnings: reset_stats.warnings, }) } @@ -182,42 +308,28 @@ async fn run_reset(args: ResetArgs) -> CliResult { async fn reset_pathspecs( pathspecs: &[String], target_commit_id: &ObjectHash, -) -> CliResult> { - let commit: Commit = load_object(target_commit_id).map_err(|e| { - CliError::fatal(format!("failed to load commit: {e}")) - .with_stable_code(StableErrorCode::RepoCorrupt) - })?; +) -> Result, ResetError> { + let commit: Commit = load_object(target_commit_id) + .map_err(|e| object_load_error("commit", target_commit_id.to_string(), e.to_string()))?; - let tree: Tree = load_object(&commit.tree_id).map_err(|e| { - CliError::fatal(format!("failed to load tree: {e}")) - .with_stable_code(StableErrorCode::RepoCorrupt) - })?; + let tree: Tree = load_object(&commit.tree_id) + .map_err(|e| object_load_error("tree", commit.tree_id.to_string(), e.to_string()))?; let index_file = path::index(); - let mut index = Index::load(&index_file).map_err(|e| { - CliError::fatal(format!("failed to load index: {e}")) - .with_stable_code(StableErrorCode::RepoCorrupt) - })?; + let mut index = Index::load(&index_file).map_err(|e| ResetError::IndexLoad(e.to_string()))?; let mut changed = false; let mut changed_paths = Vec::new(); for pathspec in pathspecs { let relative_path = util::workdir_to_current(PathBuf::from(pathspec)); let path_str = relative_path.to_str().ok_or_else(|| { - CliError::fatal(format!( - "path contains invalid UTF-8: {}", - relative_path.display() - )) - .with_stable_code(StableErrorCode::CliInvalidArguments) + ResetError::InvalidPathspecEncoding(relative_path.display().to_string()) })?; - match find_tree_item(&tree, path_str) { + match find_tree_item(&tree, path_str)? { Some(item) => { let blob: git_internal::internal::object::blob::Blob = load_object(&item.id) - .map_err(|e| { - CliError::fatal(format!("failed to load blob '{}': {e}", item.id)) - .with_stable_code(StableErrorCode::RepoCorrupt) - })?; + .map_err(|e| object_load_error("blob", item.id.to_string(), e.to_string()))?; let entry = IndexEntry::new_from_blob( path_str.to_string(), item.id, @@ -233,21 +345,16 @@ async fn reset_pathspecs( changed = true; changed_paths.push(pathspec.clone()); } else { - return Err(CliError::fatal(format!( - "pathspec '{}' did not match any file(s) known to libra", - pathspec - )) - .with_stable_code(StableErrorCode::CliInvalidTarget)); + return Err(ResetError::PathspecNotMatched(pathspec.clone())); } } } } if changed { - index.save(&index_file).map_err(|e| { - CliError::fatal(format!("failed to save index: {e}")) - .with_stable_code(StableErrorCode::IoWriteFailed) - })?; + index + .save(&index_file) + .map_err(|e| ResetError::IndexSave(e.to_string()))?; } Ok(changed_paths) } @@ -258,12 +365,12 @@ async fn perform_reset( target_commit_id: ObjectHash, mode: ResetMode, target_ref_str: &str, // e.g, "HEAD~2" -) -> Result { +) -> Result { // avoids holding the transaction open while doing read-only preparations. let db = get_db_conn_instance().await; let old_oid = Head::current_commit_with_conn(&db) .await - .ok_or_else(|| "Cannot reset: HEAD is unborn and points to no commit.".to_string())?; + .ok_or(ResetError::HeadUnborn)?; let current_head_state = if old_oid != target_commit_id { Some(Head::current_with_conn(&db).await) } else { @@ -303,18 +410,20 @@ async fn apply_reset_side_effects( mode: ResetMode, target_commit_id: &ObjectHash, previously_tracked_paths: &HashSet, -) -> Result { +) -> Result { let mut stats = ResetStats::default(); match mode { ResetMode::Soft => {} ResetMode::Mixed => { - reset_index_to_commit(target_commit_id)?; + reset_index_to_commit_typed(target_commit_id)?; } ResetMode::Hard => { - reset_index_to_commit(target_commit_id)?; - stats.files_restored = + reset_index_to_commit_typed(target_commit_id)?; + let worktree_stats = reset_working_directory_to_commit(target_commit_id, previously_tracked_paths) .await?; + stats.files_restored = worktree_stats.files_restored; + stats.warnings = worktree_stats.warnings; } } Ok(stats) @@ -324,14 +433,14 @@ async fn rollback_reset_side_effects( mode: ResetMode, old_oid: &ObjectHash, target_commit_id: &ObjectHash, -) -> Result<(), String> { +) -> Result<(), ResetError> { match mode { ResetMode::Soft => Ok(()), - ResetMode::Mixed => reset_index_to_commit(old_oid), + ResetMode::Mixed => reset_index_to_commit_typed(old_oid), ResetMode::Hard => { - reset_index_to_commit(old_oid)?; + reset_index_to_commit_typed(old_oid)?; let rollback_paths = tracked_paths_for_hard_reset(target_commit_id)?; - reset_working_directory_to_commit(old_oid, &rollback_paths).await?; + let _ = reset_working_directory_to_commit(old_oid, &rollback_paths).await?; Ok(()) } } @@ -342,7 +451,7 @@ async fn update_reset_reference( old_oid: ObjectHash, target_commit_id: ObjectHash, target_ref_str: &str, -) -> Result<(), String> { +) -> Result<(), ResetError> { let action = ReflogAction::Reset { target: target_ref_str.to_string(), }; @@ -377,50 +486,50 @@ async fn update_reset_reference( true, ) .await - .map_err(|e| e.to_string()) + .map_err(|e| ResetError::HeadUpdate(e.to_string())) } -fn merge_reset_failure(error: String, rollback: Result<(), String>) -> String { +fn merge_reset_failure(error: ResetError, rollback: Result<(), ResetError>) -> ResetError { match rollback { Ok(()) => error, - Err(rollback_error) => format!("{error}; rollback failed: {rollback_error}"), + Err(rollback_error) => match error { + ResetError::IndexSave(detail) => { + ResetError::IndexSave(format!("{detail}; rollback failed: {rollback_error}")) + } + ResetError::HeadUpdate(detail) => { + ResetError::HeadUpdate(format!("{detail}; rollback failed: {rollback_error}")) + } + ResetError::WorktreeRead(detail) => { + ResetError::WorktreeRead(format!("{detail}; rollback failed: {rollback_error}")) + } + ResetError::WorktreeRestore(detail) => { + ResetError::WorktreeRestore(format!("{detail}; rollback failed: {rollback_error}")) + } + other => { + ResetError::WorktreeRestore(format!("{other}; rollback failed: {rollback_error}")) + } + }, } } /// Reset the index to match the specified commit's tree. /// Clears the current index and rebuilds it from the commit's tree structure. pub(crate) fn reset_index_to_commit(commit_id: &ObjectHash) -> Result<(), String> { - let commit: Commit = - load_object(commit_id).map_err(|e| format!("failed to load commit: {e}"))?; - - let tree: Tree = - load_object(&commit.tree_id).map_err(|e| format!("failed to load tree: {e}"))?; - - let index_file = path::index(); - let mut index = Index::new(); - - // Rebuild index from tree - rebuild_index_from_tree(&tree, &mut index, "")?; - - index - .save(&index_file) - .map_err(|e| format!("failed to save index: {e}"))?; - - Ok(()) + reset_index_to_commit_typed(commit_id).map_err(|e| e.to_string()) } /// Reset the working directory to match the specified commit. /// Removes files that exist in the original commit but not in the target commit, /// and restores files from the target commit's tree. -pub(crate) async fn reset_working_directory_to_commit( +async fn reset_working_directory_to_commit( commit_id: &ObjectHash, previously_tracked_paths: &HashSet, -) -> Result { - let commit: Commit = - load_object(commit_id).map_err(|e| format!("failed to load commit: {e}"))?; +) -> Result { + let commit: Commit = load_object(commit_id) + .map_err(|e| object_load_error("commit", commit_id.to_string(), e.to_string()))?; - let tree: Tree = - load_object(&commit.tree_id).map_err(|e| format!("failed to load tree: {e}"))?; + let tree: Tree = load_object(&commit.tree_id) + .map_err(|e| object_load_error("tree", commit.tree_id.to_string(), e.to_string()))?; let workdir = util::working_dir(); let target_files = tree.get_plain_items(); @@ -432,20 +541,28 @@ pub(crate) async fn reset_working_directory_to_commit( if !target_files_set.contains(file_path) { let full_path = workdir.join(file_path); if full_path.exists() { - fs::remove_file(&full_path) - .map_err(|e| format!("failed to remove file {}: {}", full_path.display(), e))?; + fs::remove_file(&full_path).map_err(|e| { + ResetError::WorktreeRestore(format!( + "failed to remove file {}: {}", + full_path.display(), + e + )) + })?; files_restored += 1; } } } // Remove empty directories - remove_empty_directories(&workdir)?; + let warnings = remove_empty_directories_with_warnings(&workdir)?; // Restore files from target tree - files_restored += restore_working_directory_from_tree_counted(&tree, &workdir, "")?; + files_restored += restore_working_directory_from_tree_counted_typed(&tree, &workdir, "")?; - Ok(files_restored) + Ok(ResetStats { + files_restored, + warnings, + }) } /// Recursively rebuild the index from a tree structure. @@ -455,6 +572,33 @@ pub(crate) fn rebuild_index_from_tree( index: &mut Index, prefix: &str, ) -> Result<(), String> { + rebuild_index_from_tree_typed(tree, index, prefix).map_err(|e| e.to_string()) +} + +fn reset_index_to_commit_typed(commit_id: &ObjectHash) -> Result<(), ResetError> { + let commit: Commit = load_object(commit_id) + .map_err(|e| object_load_error("commit", commit_id.to_string(), e.to_string()))?; + + let tree: Tree = load_object(&commit.tree_id) + .map_err(|e| object_load_error("tree", commit.tree_id.to_string(), e.to_string()))?; + + let index_file = path::index(); + let mut index = Index::new(); + + rebuild_index_from_tree_typed(&tree, &mut index, "")?; + + index + .save(&index_file) + .map_err(|e| ResetError::IndexSave(e.to_string()))?; + + Ok(()) +} + +fn rebuild_index_from_tree_typed( + tree: &Tree, + index: &mut Index, + prefix: &str, +) -> Result<(), ResetError> { for item in &tree.tree_items { let full_path = if prefix.is_empty() { item.name.clone() @@ -464,9 +608,9 @@ pub(crate) fn rebuild_index_from_tree( match item.mode { git_internal::internal::object::tree::TreeItemMode::Tree => { - let subtree: Tree = - load_object(&item.id).map_err(|e| format!("failed to load subtree: {e}"))?; - rebuild_index_from_tree(&subtree, index, &full_path)?; + let subtree: Tree = load_object(&item.id) + .map_err(|e| object_load_error("tree", item.id.to_string(), e.to_string()))?; + rebuild_index_from_tree_typed(&subtree, index, &full_path)?; } _ => { // Add file to index - but don't modify working directory files @@ -490,14 +634,16 @@ pub(crate) fn restore_working_directory_from_tree( workdir: &Path, prefix: &str, ) -> Result<(), String> { - restore_working_directory_from_tree_counted(tree, workdir, prefix).map(|_| ()) + restore_working_directory_from_tree_counted_typed(tree, workdir, prefix) + .map(|_| ()) + .map_err(|e| e.to_string()) } -fn restore_working_directory_from_tree_counted( +fn restore_working_directory_from_tree_counted_typed( tree: &Tree, workdir: &Path, prefix: &str, -) -> Result { +) -> Result { let mut files_restored = 0; for item in &tree.tree_items { let full_path = if prefix.is_empty() { @@ -512,23 +658,32 @@ fn restore_working_directory_from_tree_counted( git_internal::internal::object::tree::TreeItemMode::Tree => { // Create directory fs::create_dir_all(&file_path).map_err(|e| { - format!("failed to create directory {}: {}", file_path.display(), e) + ResetError::WorktreeRestore(format!( + "failed to create directory {}: {}", + file_path.display(), + e + )) })?; - let subtree: Tree = - load_object(&item.id).map_err(|e| format!("failed to load subtree: {e}"))?; - files_restored += - restore_working_directory_from_tree_counted(&subtree, workdir, &full_path)?; + let subtree: Tree = load_object(&item.id) + .map_err(|e| object_load_error("tree", item.id.to_string(), e.to_string()))?; + files_restored += restore_working_directory_from_tree_counted_typed( + &subtree, workdir, &full_path, + )?; } _ => { // Restore file let blob = load_object::(&item.id) - .map_err(|e| format!("failed to load blob: {e}"))?; + .map_err(|e| object_load_error("blob", item.id.to_string(), e.to_string()))?; // Create parent directory if needed if let Some(parent) = file_path.parent() { fs::create_dir_all(parent).map_err(|e| { - format!("failed to create directory {}: {}", parent.display(), e) + ResetError::WorktreeRestore(format!( + "failed to create directory {}: {}", + parent.display(), + e + )) })?; } @@ -536,17 +691,21 @@ fn restore_working_directory_from_tree_counted( Ok(existing) => existing != blob.data, Err(err) if err.kind() == io::ErrorKind::NotFound => true, Err(err) => { - return Err(format!( + return Err(ResetError::WorktreeRead(format!( "failed to read file {}: {}", file_path.display(), err - )); + ))); } }; if needs_write { fs::write(&file_path, blob.data).map_err(|e| { - format!("failed to write file {}: {}", file_path.display(), e) + ResetError::WorktreeRestore(format!( + "failed to write file {}: {}", + file_path.display(), + e + )) })?; files_restored += 1; } @@ -560,19 +719,31 @@ fn restore_working_directory_from_tree_counted( /// Recursively traverses the directory tree and removes any empty directories, /// except for the .libra directory and the working directory root. pub(crate) fn remove_empty_directories(workdir: &Path) -> Result<(), String> { - fn remove_empty_dirs_recursive(dir: &Path, workdir: &Path) -> Result<(), String> { + remove_empty_directories_with_warnings(workdir) + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +fn remove_empty_directories_with_warnings(workdir: &Path) -> Result, ResetError> { + fn remove_empty_dirs_recursive( + dir: &Path, + workdir: &Path, + warnings: &mut Vec, + ) -> Result { if !dir.is_dir() || dir == workdir { - return Ok(()); + return Ok(true); } - let entries = fs::read_dir(dir) - .map_err(|e| format!("failed to read directory {}: {}", dir.display(), e))?; + let entries = fs::read_dir(dir).map_err(|e| { + ResetError::WorktreeRead(format!("failed to read directory {}: {}", dir.display(), e)) + })?; let mut has_files = false; - let mut subdirs = Vec::new(); for entry in entries { - let entry = entry.map_err(|e| format!("failed to read directory entry: {e}"))?; + let entry = entry.map_err(|e| { + ResetError::WorktreeRead(format!("failed to read directory entry: {e}")) + })?; let path = entry.path(); if path.is_dir() { @@ -580,29 +751,7 @@ pub(crate) fn remove_empty_directories(workdir: &Path) -> Result<(), String> { if path.file_name().and_then(|n| n.to_str()) == Some(".libra") { has_files = true; } else { - subdirs.push(path); - } - } else { - has_files = true; - } - } - - // Recursively process subdirectories - for subdir in subdirs { - remove_empty_dirs_recursive(&subdir, workdir)?; - - // Check if subdir is now empty - if subdir - .read_dir() - .map(|mut d| d.next().is_none()) - .unwrap_or(false) - { - if let Err(e) = fs::remove_dir(&subdir) { - eprintln!( - "warning: failed to remove empty directory {}: {}", - subdir.display(), - e - ); + has_files |= remove_empty_dirs_recursive(&path, workdir, warnings)?; } } else { has_files = true; @@ -610,48 +759,52 @@ pub(crate) fn remove_empty_directories(workdir: &Path) -> Result<(), String> { } // Remove this directory if it's empty and not the working directory - if !has_files - && dir != workdir - && let Err(e) = fs::remove_dir(dir) - { - eprintln!( - "warning: failed to remove empty directory {}: {}", - dir.display(), - e - ); + if !has_files && dir != workdir { + if let Err(e) = fs::remove_dir(dir) { + warnings.push(format!( + "failed to remove empty directory {}: {}", + dir.display(), + e + )); + return Ok(true); + } + return Ok(false); } - Ok(()) + Ok(has_files) } // Start from working directory and process all subdirectories - let entries = - fs::read_dir(workdir).map_err(|e| format!("failed to read working directory: {e}"))?; + let entries = fs::read_dir(workdir) + .map_err(|e| ResetError::WorktreeRead(format!("failed to read working directory: {e}")))?; + let mut warnings = Vec::new(); for entry in entries { - let entry = entry.map_err(|e| format!("failed to read directory entry: {e}"))?; + let entry = entry.map_err(|e| { + ResetError::WorktreeRead(format!("failed to read directory entry: {e}")) + })?; let path = entry.path(); if path.is_dir() && path.file_name().and_then(|n| n.to_str()) != Some(".libra") { - remove_empty_dirs_recursive(&path, workdir)?; + let _ = remove_empty_dirs_recursive(&path, workdir, &mut warnings)?; } } - Ok(()) + Ok(warnings) } /// Resolve a reference string to a commit ObjectHash. /// Accepts commit hashes, branch names, or HEAD references. -async fn resolve_commit(reference: &str) -> Result { +async fn resolve_commit(reference: &str) -> Result { get_target_commit(reference) .await - .map_err(|e| e.to_string()) + .map_err(|e| ResetError::InvalidRevision(e.to_string())) } /// Get the first line of a commit's message for display purposes. -fn get_commit_summary(commit_id: &ObjectHash) -> Result { - let commit: Commit = - load_object(commit_id).map_err(|e| format!("failed to load commit: {e}"))?; +fn get_commit_summary(commit_id: &ObjectHash) -> Result { + let commit: Commit = load_object(commit_id) + .map_err(|e| object_load_error("commit", commit_id.to_string(), e.to_string()))?; let first_line = parse_commit_msg(&commit.message) .0 @@ -662,16 +815,16 @@ fn get_commit_summary(commit_id: &ObjectHash) -> Result { Ok(first_line) } -fn tracked_paths_from_index() -> Result, String> { - let index = Index::load(path::index()).map_err(|e| format!("failed to load index: {e}"))?; +fn tracked_paths_from_index() -> Result, ResetError> { + let index = Index::load(path::index()).map_err(|e| ResetError::IndexLoad(e.to_string()))?; Ok(index.tracked_files().into_iter().collect()) } -fn tracked_paths_from_commit(commit_id: &ObjectHash) -> Result, String> { - let commit: Commit = - load_object(commit_id).map_err(|e| format!("failed to load commit: {e}"))?; - let tree: Tree = - load_object(&commit.tree_id).map_err(|e| format!("failed to load tree: {e}"))?; +fn tracked_paths_from_commit(commit_id: &ObjectHash) -> Result, ResetError> { + let commit: Commit = load_object(commit_id) + .map_err(|e| object_load_error("commit", commit_id.to_string(), e.to_string()))?; + let tree: Tree = load_object(&commit.tree_id) + .map_err(|e| object_load_error("tree", commit.tree_id.to_string(), e.to_string()))?; Ok(tree .get_plain_items() .into_iter() @@ -681,7 +834,7 @@ fn tracked_paths_from_commit(commit_id: &ObjectHash) -> Result, fn tracked_paths_for_hard_reset( current_commit_id: &ObjectHash, -) -> Result, String> { +) -> Result, ResetError> { // `reset --hard` must remove paths that are tracked either by the current HEAD // tree or by the staged index, otherwise cached removals can leave stale files // behind when the target commit does not contain them. @@ -715,54 +868,12 @@ fn render_reset_output(result: &ResetOutput, output: &OutputConfig) -> CliResult Ok(()) } -fn map_reset_invalid_revision(message: String) -> CliError { - CliError::fatal(message) - .with_stable_code(StableErrorCode::CliInvalidTarget) - .with_hint("check the revision name and try again.") -} - -/// Classify a runtime error message from `perform_reset()` into a -/// [`StableErrorCode`]. -/// -/// **Coupling note:** This function relies on keyword matching against error -/// messages produced by `perform_reset()`, `rebuild_index_from_tree()`, and -/// `restore_working_directory_from_tree()`. If those messages change, the -/// classification may silently fall through to the default `IoWriteFailed`. -/// A future refactor should replace these string-based errors with a typed -/// `ResetError` enum (see docs/improvement/reset.md). -fn map_reset_runtime_error(message: String) -> CliError { - let stable_code = if message.contains("save index") - || message.contains("write file") - || message.contains("update HEAD") - || message.contains("create directory") - { - StableErrorCode::IoWriteFailed - } else if message.contains("HEAD is unborn") || message.contains("points to no commit") { - StableErrorCode::RepoStateInvalid - } else if message.contains("load commit") - || message.contains("load tree") - || message.contains("load current commit") - || message.contains("load current tree") - || message.contains("load index") - || message.contains("load subtree") - || message.contains("load blob") - { - StableErrorCode::RepoCorrupt - } else if message.contains("read file") || message.contains("read directory") { - StableErrorCode::IoReadFailed - } else { - StableErrorCode::IoWriteFailed - }; - - CliError::fatal(message).with_stable_code(stable_code) -} - /// Find a specific file or directory in a tree by path. /// Returns the tree item if found, None otherwise. fn find_tree_item( tree: &Tree, path: &str, -) -> Option { +) -> Result, ResetError> { let parts: Vec<&str> = path.split('/').collect(); find_tree_item_recursive(tree, &parts, 0) } @@ -773,27 +884,27 @@ fn find_tree_item_recursive( tree: &Tree, parts: &[&str], index: usize, -) -> Option { +) -> Result, ResetError> { if index >= parts.len() { - return None; + return Ok(None); } for item in &tree.tree_items { if item.name == parts[index] { if index == parts.len() - 1 { // Found the target - return Some(item.clone()); + return Ok(Some(item.clone())); } else if item.mode == git_internal::internal::object::tree::TreeItemMode::Tree { // Continue searching in subtree - if let Ok(subtree) = load_object::(&item.id) - && let Some(result) = find_tree_item_recursive(&subtree, parts, index + 1) - { - return Some(result); + let subtree = load_object::(&item.id) + .map_err(|e| object_load_error("tree", item.id.to_string(), e.to_string()))?; + if let Some(result) = find_tree_item_recursive(&subtree, parts, index + 1)? { + return Ok(Some(result)); } } } } - None + Ok(None) } #[cfg(test)] @@ -817,17 +928,16 @@ mod tests { } #[test] - fn test_map_reset_runtime_error_reports_unborn_head_as_repo_state() { - let error = - map_reset_runtime_error("Cannot reset: HEAD is unborn and points to no commit.".into()); + fn test_reset_error_maps_unborn_head_as_repo_state() { + let error = CliError::from(ResetError::HeadUnborn); assert_eq!(error.stable_code(), StableErrorCode::RepoStateInvalid); } #[test] - fn test_map_reset_runtime_error_reports_file_read_failures_as_io_read() { - let error = map_reset_runtime_error( + fn test_reset_error_maps_file_read_failures_as_io_read() { + let error = CliError::from(ResetError::WorktreeRead( "failed to read file /tmp/repo/tracked.txt: Permission denied".into(), - ); + )); assert_eq!(error.stable_code(), StableErrorCode::IoReadFailed); } } diff --git a/src/command/tag.rs b/src/command/tag.rs index f26efca7..db9fa8e2 100644 --- a/src/command/tag.rs +++ b/src/command/tag.rs @@ -1,7 +1,6 @@ //! Manages tags by resolving target commits, creating lightweight or annotated tag objects, storing refs, and listing existing tags. use clap::Parser; -use sea_orm::sqlx::types::chrono; use serde::Serialize; use crate::{ @@ -9,11 +8,21 @@ use crate::{ utils::{ error::{CliError, CliResult, StableErrorCode}, output::{OutputConfig, emit_json_data}, + util, }, }; +const TAG_EXAMPLES: &str = "\ +EXAMPLES: + libra tag v1.0 Create a lightweight tag at HEAD + libra tag -m \"Release v1.1\" v1.1 Create an annotated tag + libra tag -l -n 2 List tags with up to 2 annotation lines + libra tag -d v1.0 Delete a tag + libra tag --json v1.0 Structured JSON output for agents"; + #[derive(Parser, Debug)] #[command(about = "Create, list, delete, or verify a tag object")] +#[command(after_help = TAG_EXAMPLES)] pub struct TagArgs { /// The name of the tag to create, show, or delete #[clap(required = false)] @@ -61,6 +70,8 @@ pub struct TagListEntry { pub hash: String, pub tag_type: String, pub message: Option, + #[serde(skip_serializing)] + pub display_message: Option, } pub async fn execute(args: TagArgs) { @@ -73,47 +84,94 @@ pub async fn execute(args: TagArgs) { /// errors and exiting. Lists, creates, or deletes tags depending on the /// provided arguments. pub async fn execute_safe(args: TagArgs, output: &OutputConfig) -> CliResult<()> { - validate_named_tag_action(&args)?; + let result = run_tag(&args).await.map_err(CliError::from)?; + render_tag_output(&result, output) +} - if output.is_json() { - let result = run_tag_json(&args).await?; - return emit_json_data("tag", &result, output); - } +#[derive(Debug, thiserror::Error)] +enum TagError { + #[error("not a libra repository")] + NotInRepo, - if args.list || args.n_lines.is_some() { - let show_lines = args.n_lines.unwrap_or(0); - if output.quiet { - return Ok(()); - } - let rendered = render_tags(show_lines) - .await - .map_err(|e| CliError::fatal(e.to_string()))?; - print!("{}", rendered); - return Ok(()); - } + #[error("tag '{0}' already exists")] + AlreadyExists(String), - if let Some(name) = args.name { - if args.delete { - delete_tag_safe(&name, output).await?; - } else if args.message.is_some() { - create_tag_safe(&name, args.message, args.force).await?; - } else { - create_tag_safe(&name, None, args.force).await?; - show_tag_safe(&name, output).await?; - } - } else { - if output.quiet { - return Ok(()); + #[error("tag '{0}' not found")] + NotFound(String), + + #[error("{0}")] + MissingName(String), + + #[error("Cannot create tag: HEAD does not point to a commit")] + HeadUnborn, + + #[error("failed to read existing tags before creating '{name}': {detail}")] + CheckExistingFailed { name: String, detail: String }, + + #[error("failed to serialize annotated tag object: {0}")] + SerializeAnnotatedTag(String), + + #[error("failed to store annotated tag object: {0}")] + StoreObjectFailed(String), + + #[error("failed to persist tag reference '{name}': {detail}")] + PersistReferenceFailed { name: String, detail: String }, + + #[error("failed to delete tag '{name}': {detail}")] + DeleteFailed { name: String, detail: String }, + + #[error("failed to load tag '{name}': {detail}")] + LoadFailed { name: String, detail: String }, + + #[error("failed to list tags: {0}")] + ListFailed(String), +} + +impl From for CliError { + fn from(error: TagError) -> Self { + match error { + TagError::NotInRepo => CliError::repo_not_found(), + TagError::AlreadyExists(name) => { + CliError::fatal(format!("tag '{name}' already exists")) + .with_stable_code(StableErrorCode::ConflictOperationBlocked) + .with_hint(format!("delete it first with 'libra tag -d {name}'.")) + .with_hint("or choose a different tag name.") + } + TagError::NotFound(name) => CliError::fatal(format!("tag '{name}' not found")) + .with_stable_code(StableErrorCode::CliInvalidTarget) + .with_hint("use 'libra tag -l' to list available tags."), + TagError::MissingName(message) => CliError::command_usage(message) + .with_stable_code(StableErrorCode::CliInvalidArguments) + .with_hint("use 'libra tag ' to create or update a tag") + .with_hint("use 'libra tag -l' to list existing tags"), + TagError::HeadUnborn => CliError::fatal(error.to_string()) + .with_stable_code(StableErrorCode::RepoStateInvalid) + .with_hint("create a commit first before tagging HEAD."), + TagError::CheckExistingFailed { .. } => { + CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::RepoCorrupt) + } + TagError::SerializeAnnotatedTag(_) => CliError::fatal(error.to_string()) + .with_stable_code(StableErrorCode::InternalInvariant), + TagError::StoreObjectFailed(_) => { + CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::IoWriteFailed) + } + TagError::PersistReferenceFailed { .. } => { + CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::IoWriteFailed) + } + TagError::DeleteFailed { .. } => { + CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::IoWriteFailed) + } + TagError::LoadFailed { .. } => { + CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::RepoCorrupt) + } + TagError::ListFailed(_) => { + CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::RepoCorrupt) + } } - let rendered = render_tags(0) - .await - .map_err(|e| CliError::fatal(e.to_string()))?; - print!("{}", rendered); } - Ok(()) } -fn validate_named_tag_action(args: &TagArgs) -> CliResult<()> { +fn validate_named_tag_action(args: &TagArgs) -> Result<(), TagError> { if args.name.is_some() { return Ok(()); } @@ -129,9 +187,7 @@ fn validate_named_tag_action(args: &TagArgs) -> CliResult<()> { }; if let Some(message) = message { - return Err(CliError::command_usage(message) - .with_hint("use 'libra tag ' to create or update a tag") - .with_hint("use 'libra tag -l' to list existing tags")); + return Err(TagError::MissingName(message.to_string())); } Ok(()) @@ -144,91 +200,101 @@ async fn create_tag(tag_name: &str, message: Option, force: bool) { } } +#[cfg(test)] async fn create_tag_safe(tag_name: &str, message: Option, force: bool) -> CliResult<()> { - tag::create(tag_name, message, force) + run_create_tag(tag_name, message, force) .await - .map_err(|error| map_create_tag_error(tag_name, error))?; + .map(|_| ()) + .map_err(CliError::from)?; Ok(()) } -fn map_create_tag_error(tag_name: &str, error: tag::CreateTagError) -> CliError { +fn map_create_tag_error(tag_name: &str, error: tag::CreateTagError) -> TagError { match error { tag::CreateTagError::AlreadyExists(existing_tag_name) => { - CliError::fatal(format!("tag '{existing_tag_name}' already exists")) - .with_stable_code(StableErrorCode::ConflictOperationBlocked) - .with_hint(format!("delete it first with 'libra tag -d {}'.", tag_name)) - .with_hint("or choose a different tag name.") - } - tag::CreateTagError::HeadUnborn => { - CliError::fatal("Cannot create tag: HEAD does not point to a commit") - .with_stable_code(StableErrorCode::RepoStateInvalid) - .with_hint("create a commit first before tagging HEAD.") + TagError::AlreadyExists(existing_tag_name) } - tag::CreateTagError::CheckExisting(source) => CliError::fatal(format!( - "failed to read existing tags before creating '{}': {source}", - tag_name - )) - .with_stable_code(StableErrorCode::RepoCorrupt), - tag::CreateTagError::SerializeTag(source) => CliError::fatal(format!( - "failed to serialize annotated tag object: {source}" - )) - .with_stable_code(StableErrorCode::InternalInvariant), - tag::CreateTagError::StoreObject(source) => { - CliError::fatal(format!("failed to store annotated tag object: {source}")) - .with_stable_code(StableErrorCode::IoWriteFailed) + tag::CreateTagError::HeadUnborn => TagError::HeadUnborn, + tag::CreateTagError::CheckExisting(source) => TagError::CheckExistingFailed { + name: tag_name.to_string(), + detail: source.to_string(), + }, + tag::CreateTagError::SerializeTag(source) => { + TagError::SerializeAnnotatedTag(source.to_string()) } - tag::CreateTagError::PersistReference(source) => CliError::fatal(format!( - "failed to persist tag reference '{}': {source}", - tag_name - )) - .with_stable_code(StableErrorCode::IoWriteFailed), + tag::CreateTagError::StoreObject(source) => TagError::StoreObjectFailed(source.to_string()), + tag::CreateTagError::PersistReference(source) => TagError::PersistReferenceFailed { + name: tag_name.to_string(), + detail: source.to_string(), + }, } } -pub async fn render_tags(show_lines: usize) -> Result { - let tags = tag::list().await?; - let mut output = String::new(); - let extract_message = |msg: &str| { - msg.trim() - .lines() - .map(|line| line.trim()) - .filter(|line| !line.is_empty()) - .take(show_lines) - .collect::>() - .join("\n") - }; +async fn run_tag(args: &TagArgs) -> Result { + util::require_repo().map_err(|_| TagError::NotInRepo)?; + validate_named_tag_action(args)?; - for tag in tags { - if show_lines == 0 { - output.push_str(&format!("{}\n", tag.name)); - continue; - } + if args.list || args.n_lines.is_some() || args.name.is_none() { + return Ok(TagOutput::List { + tags: collect_tags(args.n_lines.unwrap_or(0)).await?, + }); + } + + let name = args.name.as_deref().unwrap_or_default(); + if args.delete { + return run_delete_tag(name).await; + } - let show_message = match &tag.object { - TagObject::Tag(git_internal) => extract_message(&git_internal.message), - TagObject::Commit(commit) => extract_message(&commit.message), - _ => String::new(), - }; + run_create_tag(name, args.message.clone(), args.force).await +} - let lines: Vec<&str> = show_message.lines().collect(); +fn render_tag_output(result: &TagOutput, output: &OutputConfig) -> CliResult<()> { + if output.is_json() { + return emit_json_data("tag", result, output); + } - if lines.is_empty() { - // lightweight tag - output.push_str(&format!("{:<20}\n", tag.name)); - } else { - for (i, line) in lines.iter().enumerate() { - if i == 0 { - // print first line - output.push_str(&format!("{:<20} {}\n", tag.name, line)); - } else { - // print subsequent lines: use empty string with 20 characters width alignment to match the indentation - output.push_str(&format!("{:<20} {}\n", "", line)); - } + if output.quiet { + return Ok(()); + } + + match result { + TagOutput::List { tags } => { + print!("{}", format_tag_entries(tags)); + } + TagOutput::Create { + name, + hash, + tag_type, + .. + } => { + println!( + "Created {tag_type} tag '{name}' at {}", + abbreviate_hash(hash) + ); + } + TagOutput::Delete { name, hash } => { + if let Some(hash) = hash { + println!("Deleted tag '{name}' (was {})", abbreviate_hash(hash)); + } else { + println!("Deleted tag '{name}'"); } } } - Ok(output) + Ok(()) +} + +fn abbreviate_hash(hash: &str) -> &str { + const SHORT_HASH_LEN: usize = 7; + let end = hash.len().min(SHORT_HASH_LEN); + &hash[..end] +} + +pub async fn render_tags(show_lines: usize) -> Result { + let tags = collect_tags(show_lines) + .await + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + Ok(format_tag_entries(&tags)) } #[cfg(test)] @@ -238,71 +304,23 @@ async fn delete_tag(tag_name: &str) { } } +#[cfg(test)] async fn delete_tag_safe(tag_name: &str, output: &OutputConfig) -> CliResult<()> { - resolve_tag_ref_for_delete(tag_name).await?; - - tag::delete(tag_name).await.map_err(|e| { - CliError::fatal(e.to_string()).with_stable_code(StableErrorCode::IoWriteFailed) - })?; - if !output.quiet { - println!("Deleted tag '{}'", tag_name); - } + let result = run_delete_tag(tag_name).await.map_err(CliError::from)?; + render_tag_output(&result, output)?; Ok(()) } -async fn show_tag_safe(tag_name: &str, output: &OutputConfig) -> CliResult<()> { - match tag::find_tag_and_commit(tag_name).await { - Ok(Some((object, commit))) => { - if output.quiet { - return Ok(()); - } - if let tag::TagObject::Tag(tag_object) = &object { - println!("tag {}", tag_object.tag_name); - println!("Tagger: {}", tag_object.tagger.to_string().trim()); - println!("\n{}", tag_object.message); - } - - println!("\ncommit {}", commit.id); - println!("Author: {}", commit.author.to_string().trim()); - let commit_date = - chrono::DateTime::from_timestamp(commit.committer.timestamp as i64, 0) - .unwrap_or(chrono::DateTime::UNIX_EPOCH); - println!("Date: {}", commit_date.to_rfc2822()); - println!("\n {}", commit.message.trim()); - Ok(()) - } - Ok(None) => Err(CliError::fatal(format!("tag '{}' not found", tag_name)) - .with_stable_code(StableErrorCode::CliInvalidTarget) - .with_hint("use 'libra tag -l' to list available tags.")), - Err(e) => { - Err(CliError::fatal(e.to_string()).with_stable_code(StableErrorCode::RepoCorrupt)) - } - } -} - -async fn run_tag_json(args: &TagArgs) -> CliResult { - validate_named_tag_action(args)?; - - if args.list || args.n_lines.is_some() || args.name.is_none() { - return Ok(TagOutput::List { - tags: collect_tags(args.n_lines.unwrap_or(0)).await?, - }); - } - - let name = args.name.as_deref().unwrap_or_default(); - if args.delete { - let snapshot = resolve_tag_ref_for_delete(name).await?; - tag::delete(name).await.map_err(|e| { - CliError::fatal(e.to_string()).with_stable_code(StableErrorCode::IoWriteFailed) - })?; - return Ok(TagOutput::Delete { - name: name.to_string(), - hash: snapshot.target, - }); - } +async fn run_create_tag( + tag_name: &str, + message: Option, + force: bool, +) -> Result { + tag::create(tag_name, message, force) + .await + .map_err(|error| map_create_tag_error(tag_name, error))?; - create_tag_safe(name, args.message.clone(), args.force).await?; - let snapshot = lookup_tag(name, usize::MAX).await?; + let snapshot = lookup_tag(tag_name, usize::MAX).await?; Ok(TagOutput::Create { name: snapshot.name, hash: snapshot.hash, @@ -311,10 +329,24 @@ async fn run_tag_json(args: &TagArgs) -> CliResult { }) } -async fn collect_tags(show_lines: usize) -> CliResult> { - let tags = tag::list().await.map_err(|e| { - CliError::fatal(e.to_string()).with_stable_code(StableErrorCode::RepoCorrupt) - })?; +async fn run_delete_tag(tag_name: &str) -> Result { + let snapshot = resolve_tag_ref_for_delete(tag_name).await?; + tag::delete(tag_name) + .await + .map_err(|e| TagError::DeleteFailed { + name: tag_name.to_string(), + detail: e.to_string(), + })?; + Ok(TagOutput::Delete { + name: tag_name.to_string(), + hash: snapshot.target, + }) +} + +async fn collect_tags(show_lines: usize) -> Result, TagError> { + let tags = tag::list() + .await + .map_err(|e| TagError::ListFailed(e.to_string()))?; let mut entries = Vec::with_capacity(tags.len()); for tag in tags { entries.push(tag_to_list_entry(tag, show_lines)); @@ -322,24 +354,24 @@ async fn collect_tags(show_lines: usize) -> CliResult> { Ok(entries) } -async fn lookup_tag(tag_name: &str, show_lines: usize) -> CliResult { +async fn lookup_tag(tag_name: &str, show_lines: usize) -> Result { match tag::find_tag_and_commit(tag_name).await { Ok(Some((object, _commit))) => Ok(tag_object_to_list_entry( tag_name.to_string(), object, show_lines, )), - Ok(None) => Err(CliError::fatal(format!("tag '{}' not found", tag_name)) - .with_stable_code(StableErrorCode::CliInvalidTarget) - .with_hint("use 'libra tag -l' to list available tags.")), - Err(e) => { - Err(CliError::fatal(e.to_string()).with_stable_code(StableErrorCode::RepoCorrupt)) - } + Ok(None) => Err(TagError::NotFound(tag_name.to_string())), + Err(e) => Err(TagError::LoadFailed { + name: tag_name.to_string(), + detail: e.to_string(), + }), } } fn tag_to_list_entry(tag: tag::Tag, show_lines: usize) -> TagListEntry { - tag_object_to_list_entry(tag.name, tag.object, show_lines) + let tag::Tag { name, object } = tag; + tag_object_to_list_entry(name, object, show_lines) } fn tag_object_to_list_entry( @@ -353,13 +385,17 @@ fn tag_object_to_list_entry( TagObject::Tree(tree) => tree.id.to_string(), TagObject::Blob(blob) => blob.id.to_string(), }; - let (tag_type, message) = match &object { - TagObject::Tag(tag_object) => ( - "annotated".to_string(), - trim_tag_message(&tag_object.message, show_lines), + let (tag_type, message, display_message) = match &object { + TagObject::Tag(tag_object) => { + let message = trim_tag_message(&tag_object.message, show_lines); + ("annotated".to_string(), message.clone(), message) + } + TagObject::Commit(commit) => ( + "lightweight".to_string(), + None, + trim_tag_message(&commit.message, show_lines), ), - TagObject::Commit(_) => ("lightweight".to_string(), None), - _ => ("lightweight".to_string(), None), + _ => ("lightweight".to_string(), None, None), }; TagListEntry { @@ -367,6 +403,7 @@ fn tag_object_to_list_entry( hash, tag_type, message, + display_message, } } @@ -387,14 +424,33 @@ fn trim_tag_message(message: &str, show_lines: usize) -> Option { if value.is_empty() { None } else { Some(value) } } -async fn resolve_tag_ref_for_delete(tag_name: &str) -> CliResult { +fn format_tag_entries(tags: &[TagListEntry]) -> String { + let mut output = String::new(); + for tag in tags { + match tag.display_message.as_ref().or(tag.message.as_ref()) { + Some(message) => { + for (index, line) in message.lines().enumerate() { + if index == 0 { + output.push_str(&format!("{:<20} {}\n", tag.name, line)); + } else { + output.push_str(&format!("{:<20} {}\n", "", line)); + } + } + } + None => output.push_str(&format!("{}\n", tag.name)), + } + } + output +} + +async fn resolve_tag_ref_for_delete(tag_name: &str) -> Result { match tag::find_tag_ref(tag_name).await { Ok(Some(reference)) => Ok(reference), - Ok(None) => Err(CliError::fatal(format!("tag '{}' not found", tag_name)) - .with_stable_code(StableErrorCode::CliInvalidTarget) - .with_hint("use 'libra tag -l' to list available tags.")), - Err(e) => Err(CliError::fatal(format!("failed to query tag ref: {e}")) - .with_stable_code(StableErrorCode::RepoCorrupt)), + Ok(None) => Err(TagError::NotFound(tag_name.to_string())), + Err(e) => Err(TagError::LoadFailed { + name: tag_name.to_string(), + detail: format!("failed to query tag ref: {e}"), + }), } } diff --git a/src/internal/branch.rs b/src/internal/branch.rs index 80c9b48c..2cc29111 100644 --- a/src/internal/branch.rs +++ b/src/internal/branch.rs @@ -25,6 +25,39 @@ pub struct Branch { pub remote: Option, } +#[derive(Debug, thiserror::Error)] +pub enum BranchStoreError { + #[error("failed to query branch storage: {0}")] + Query(String), + #[error("stored branch reference '{name}' is corrupt: {detail}")] + Corrupt { name: String, detail: String }, + #[error("branch '{0}' not found")] + NotFound(String), + #[error("failed to delete branch '{name}': {detail}")] + Delete { name: String, detail: String }, +} + +fn branch_from_model(model: reference::Model) -> Result, BranchStoreError> { + let Some(name) = model.name.clone() else { + return Err(BranchStoreError::Corrupt { + name: "".to_string(), + detail: "missing name field".to_string(), + }); + }; + let Some(commit_str) = model.commit.as_ref() else { + return Ok(None); + }; + let commit = ObjectHash::from_str(commit_str).map_err(|e| BranchStoreError::Corrupt { + name: name.clone(), + detail: e.to_string(), + })?; + Ok(Some(Branch { + name, + commit, + remote: model.remote.clone(), + })) +} + // `_with_conn` version of the helper function async fn query_reference_with_conn( db: &C, @@ -76,8 +109,10 @@ fn is_sqlite_busy(err: &DbErr) -> bool { * Incorrect Usage (in a transaction): `Branch::update_branch(...).await;` // DEADLOCK! */ impl Branch { - // `_with_conn` version for `list_branches` - pub async fn list_branches_with_conn(db: &C, remote: Option<&str>) -> Vec + pub async fn list_branches_result_with_conn( + db: &C, + remote: Option<&str>, + ) -> Result, BranchStoreError> where C: ConnectionTrait, { @@ -89,20 +124,25 @@ impl Branch { }) .all(db) .await - .unwrap(); + .map_err(|err| BranchStoreError::Query(err.to_string()))?; - branches - .iter() - .filter_map(|branch| { - // Skip branches with no commit (unborn/placeholder) - let commit_str = branch.commit.as_ref()?; - Some(Branch { - name: branch.name.as_ref().unwrap().clone(), - commit: ObjectHash::from_str(commit_str).unwrap(), - remote: branch.remote.clone(), - }) - }) - .collect() + let mut resolved = Vec::new(); + for branch in branches { + if let Some(branch) = branch_from_model(branch)? { + resolved.push(branch); + } + } + Ok(resolved) + } + + // `_with_conn` version for `list_branches` + pub async fn list_branches_with_conn(db: &C, remote: Option<&str>) -> Vec + where + C: ConnectionTrait, + { + Self::list_branches_result_with_conn(db, remote) + .await + .unwrap_or_default() } /// list all remote branches @@ -111,6 +151,11 @@ impl Branch { Self::list_branches_with_conn(&db_conn, remote).await } + pub async fn list_branches_result(remote: Option<&str>) -> Result, BranchStoreError> { + let db_conn = get_db_conn_instance().await; + Self::list_branches_result_with_conn(&db_conn, remote).await + } + // `_with_conn` version for `exists` pub async fn exists_with_conn(db: &C, branch_name: &str) -> bool where @@ -135,25 +180,10 @@ impl Branch { where C: ConnectionTrait, { - let branch = match query_reference_with_conn(db, branch_name, remote).await { - Ok(branch) => branch, - Err(err) => { - eprintln!("fatal: failed to query branch '{branch_name}': {err}"); - return None; - } - }; - match branch { - Some(branch) => { - // Return None if commit is None (unborn/placeholder) - let commit_str = branch.commit.as_ref()?; - Some(Branch { - name: branch.name.as_ref().unwrap().clone(), - commit: ObjectHash::from_str(commit_str).unwrap(), - remote: branch.remote.clone(), - }) - } - None => None, - } + Self::find_branch_result_with_conn(db, branch_name, remote) + .await + .ok() + .flatten() } /// get the branch by name @@ -162,6 +192,31 @@ impl Branch { Self::find_branch_with_conn(&db_conn, branch_name, remote).await } + pub async fn find_branch_result_with_conn( + db: &C, + branch_name: &str, + remote: Option<&str>, + ) -> Result, BranchStoreError> + where + C: ConnectionTrait, + { + let branch = query_reference_with_conn(db, branch_name, remote) + .await + .map_err(|err| BranchStoreError::Query(err.to_string()))?; + match branch { + Some(branch) => branch_from_model(branch), + None => Ok(None), + } + } + + pub async fn find_branch_result( + branch_name: &str, + remote: Option<&str>, + ) -> Result, BranchStoreError> { + let db_conn = get_db_conn_instance().await; + Self::find_branch_result_with_conn(&db_conn, branch_name, remote).await + } + // `_with_conn` version for `search_branch` pub async fn search_branch_with_conn(db: &C, branch_name: &str) -> Vec where @@ -267,26 +322,45 @@ impl Branch { where C: ConnectionTrait, { - let branch = match query_reference_with_conn(db, branch_name, remote).await { - Ok(branch) => branch, - Err(err) => { - eprintln!("fatal: failed to query branch '{branch_name}': {err}"); - return; - } - }; + let _ = Self::delete_branch_result_with_conn(db, branch_name, remote).await; + } + + pub async fn delete_branch(branch_name: &str, remote: Option<&str>) { + let db_conn = get_db_conn_instance().await; + Self::delete_branch_with_conn(&db_conn, branch_name, remote).await + } + + pub async fn delete_branch_result_with_conn( + db: &C, + branch_name: &str, + remote: Option<&str>, + ) -> Result<(), BranchStoreError> + where + C: ConnectionTrait, + { + let branch = query_reference_with_conn(db, branch_name, remote) + .await + .map_err(|err| BranchStoreError::Query(err.to_string()))?; let Some(branch) = branch else { - eprintln!("fatal: branch '{branch_name}' not found"); - return; + return Err(BranchStoreError::NotFound(branch_name.to_string())); }; let branch: reference::ActiveModel = branch.into(); - if let Err(err) = branch.delete(db).await { - eprintln!("fatal: failed to delete branch '{branch_name}': {err}"); - } + branch + .delete(db) + .await + .map(|_| ()) + .map_err(|err| BranchStoreError::Delete { + name: branch_name.to_string(), + detail: err.to_string(), + }) } - pub async fn delete_branch(branch_name: &str, remote: Option<&str>) { + pub async fn delete_branch_result( + branch_name: &str, + remote: Option<&str>, + ) -> Result<(), BranchStoreError> { let db_conn = get_db_conn_instance().await; - Self::delete_branch_with_conn(&db_conn, branch_name, remote).await + Self::delete_branch_result_with_conn(&db_conn, branch_name, remote).await } } diff --git a/tests/command/branch_test.rs b/tests/command/branch_test.rs index c7f3ac74..fbd8aa54 100644 --- a/tests/command/branch_test.rs +++ b/tests/command/branch_test.rs @@ -4,7 +4,9 @@ #![cfg(test)] -use std::collections::HashSet; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::{collections::HashSet, fs}; use libra::internal::config::ConfigKv; use serial_test::serial; @@ -42,6 +44,38 @@ fn test_branch_json_create_output_reports_branch() { assert!(json["data"]["commit"].as_str().is_some()); } +#[test] +fn test_branch_create_outputs_confirmation() { + let repo = create_committed_repo_via_cli(); + + let output = run_libra_command(&["branch", "feature"], repo.path()); + assert_cli_success(&output, "branch feature"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Created branch 'feature' at "), + "unexpected stdout: {stdout}" + ); +} + +#[test] +fn test_branch_not_found_suggests_similar_name() { + let repo = create_committed_repo_via_cli(); + + let create = run_libra_command(&["branch", "featur"], repo.path()); + assert_cli_success(&create, "branch featur"); + + let output = run_libra_command(&["branch", "-d", "feature"], repo.path()); + let (stderr, report) = parse_cli_error_stderr(&output.stderr); + + assert_eq!(output.status.code(), Some(129)); + assert_eq!(report.error_code, "LBR-CLI-003"); + assert!( + stderr.contains("did you mean 'featur'?"), + "expected suggestion in stderr, got: {stderr}" + ); +} + #[test] fn test_branch_set_upstream_detached_head_returns_repo_state_error() { let repo = create_committed_repo_via_cli(); @@ -62,6 +96,43 @@ fn test_branch_set_upstream_detached_head_returns_repo_state_error() { assert!(stderr.contains("checkout a branch first")); } +#[cfg(unix)] +#[test] +fn test_branch_set_upstream_surfaces_config_write_failure() { + let repo = create_committed_repo_via_cli(); + let db_path = repo.path().join(".libra").join("libra.db"); + let original_mode = fs::metadata(&db_path).unwrap().permissions().mode(); + + fs::set_permissions(&db_path, std::fs::Permissions::from_mode(0o444)).unwrap(); + let output = run_libra_command(&["branch", "--set-upstream-to", "origin/main"], repo.path()); + fs::set_permissions(&db_path, std::fs::Permissions::from_mode(original_mode)).unwrap(); + + let (stderr, report) = parse_cli_error_stderr(&output.stderr); + assert_eq!(output.status.code(), Some(128)); + assert_eq!(report.error_code, "LBR-IO-002"); + assert!( + stderr.contains("failed to persist branch config 'branch.main.remote'"), + "unexpected stderr: {stderr}" + ); +} + +#[test] +fn test_branch_force_delete_outputs_confirmation() { + let repo = create_committed_repo_via_cli(); + + let create = run_libra_command(&["branch", "topic"], repo.path()); + assert_cli_success(&create, "branch topic"); + + let output = run_libra_command(&["branch", "-D", "topic"], repo.path()); + assert_cli_success(&output, "branch -D topic"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Deleted branch topic (was "), + "unexpected stdout: {stdout}" + ); +} + #[tokio::test] #[serial] /// Tests core branch management functionality including creation and listing. diff --git a/tests/command/output_flags_test.rs b/tests/command/output_flags_test.rs index 78be2d32..456da3af 100644 --- a/tests/command/output_flags_test.rs +++ b/tests/command/output_flags_test.rs @@ -1142,4 +1142,40 @@ fn branch_help_documents_quiet_listing_deviation() { stdout.contains("This differs from `git branch --quiet`"), "branch help should document quiet-mode deviation, got: {stdout}" ); + assert!( + stdout.contains("EXAMPLES:"), + "branch help should include examples, got: {stdout}" + ); +} + +#[test] +fn reset_help_includes_examples_section() { + let temp = tempdir().unwrap(); + let repo = temp.path().join("repo"); + init_repo_via_cli(&repo); + + let output = run(&["reset", "--help"], &repo); + assert_cli_success(&output, "reset --help"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("EXAMPLES:"), + "reset help should include examples, got: {stdout}" + ); +} + +#[test] +fn tag_help_includes_examples_section() { + let temp = tempdir().unwrap(); + let repo = temp.path().join("repo"); + init_repo_via_cli(&repo); + + let output = run(&["tag", "--help"], &repo); + assert_cli_success(&output, "tag --help"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("EXAMPLES:"), + "tag help should include examples, got: {stdout}" + ); } diff --git a/tests/command/reset_test.rs b/tests/command/reset_test.rs index bd379b04..5885c35e 100644 --- a/tests/command/reset_test.rs +++ b/tests/command/reset_test.rs @@ -13,7 +13,7 @@ use libra::{ reset::{self, ResetArgs}, status::{changes_to_be_committed, changes_to_be_staged}, }, - internal::config::ConfigKv, + internal::{branch::Branch as InternalBranch, config::ConfigKv}, utils::{error::StableErrorCode, test::setup_with_new_libra_in}, }; @@ -201,6 +201,53 @@ fn test_reset_json_hard_with_pathspec_returns_usage_error() { ); } +#[tokio::test] +#[serial] +async fn test_reset_pathspec_surfaces_subtree_corruption_as_repo_corrupt() { + let repo = create_committed_repo_via_cli(); + fs::create_dir_all(repo.path().join("dir")).unwrap(); + fs::write(repo.path().join("dir").join("nested.txt"), "nested\n").unwrap(); + + let add = run_libra_command(&["add", "dir/nested.txt"], repo.path()); + assert_cli_success(&add, "add dir/nested.txt"); + + let commit = run_libra_command(&["commit", "-m", "nested", "--no-verify"], repo.path()); + assert_cli_success(&commit, "commit nested"); + + { + let _guard = ChangeDirGuard::new(repo.path()); + let head = InternalBranch::find_branch("main", None) + .await + .expect("main branch should exist") + .commit; + let commit: Commit = load_object(&head).expect("load HEAD commit"); + let tree: Tree = load_object(&commit.tree_id).expect("load root tree"); + let dir_item = tree + .tree_items + .iter() + .find(|item| item.name == "dir") + .expect("expected dir subtree"); + let dir_hash = dir_item.id.to_string(); + let object_path = repo + .path() + .join(".libra") + .join("objects") + .join(&dir_hash[..2]) + .join(&dir_hash[2..]); + fs::write(object_path, b"corrupt subtree").unwrap(); + } + + let output = run_libra_command(&["reset", "HEAD", "--", "dir/nested.txt"], repo.path()); + let (stderr, report) = parse_cli_error_stderr(&output.stderr); + + assert_eq!(output.status.code(), Some(128)); + assert_eq!(report.error_code, "LBR-REPO-002"); + assert!( + stderr.contains("failed to load tree"), + "unexpected stderr: {stderr}" + ); +} + #[cfg(unix)] #[tokio::test] #[serial] diff --git a/tests/command/tag_test.rs b/tests/command/tag_test.rs index e1dfafa1..c4e08417 100644 --- a/tests/command/tag_test.rs +++ b/tests/command/tag_test.rs @@ -35,7 +35,7 @@ async fn setup_user_identity() { } #[test] -fn test_tag_json_create_output_includes_hash() { +fn test_tag_json_create_output_keeps_lightweight_message_null() { let repo = create_committed_repo_via_cli(); let output = run_libra_command(&["--json", "tag", "v1.0"], repo.path()); @@ -50,6 +50,71 @@ fn test_tag_json_create_output_includes_hash() { assert_eq!(json["data"]["action"], "create"); assert_eq!(json["data"]["name"], "v1.0"); assert!(json["data"]["hash"].as_str().is_some()); + assert!( + json["data"]["message"].is_null(), + "expected lightweight create message to stay null, got: {json}" + ); +} + +#[test] +fn test_tag_json_list_keeps_lightweight_message_null() { + let repo = create_committed_repo_via_cli(); + + let create_lightweight = run_libra_command(&["tag", "v1.0"], repo.path()); + assert_cli_success(&create_lightweight, "tag v1.0"); + + let create_annotated = run_libra_command(&["tag", "-m", "Release v1.1", "v1.1"], repo.path()); + assert_cli_success(&create_annotated, "tag -m Release v1.1 v1.1"); + + let output = run_libra_command(&["--json", "tag", "-l", "-n", "1"], repo.path()); + assert_cli_success(&output, "tag --json -l -n 1"); + + let json = parse_json_stdout(&output); + let tags = json["data"]["tags"] + .as_array() + .expect("expected tags array"); + let lightweight = tags + .iter() + .find(|entry| entry["name"] == "v1.0") + .expect("expected lightweight tag entry"); + let annotated = tags + .iter() + .find(|entry| entry["name"] == "v1.1") + .expect("expected annotated tag entry"); + + assert!( + lightweight["message"].is_null(), + "unexpected lightweight tag: {lightweight}" + ); + assert_eq!(annotated["message"], "Release v1.1"); +} + +#[test] +fn test_tag_create_outputs_concise_confirmation() { + let repo = create_committed_repo_via_cli(); + + let output = run_libra_command(&["tag", "v1.0"], repo.path()); + assert_cli_success(&output, "tag v1.0"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Created lightweight tag 'v1.0' at "), + "unexpected stdout: {stdout}" + ); +} + +#[test] +fn test_annotated_tag_create_outputs_concise_confirmation() { + let repo = create_committed_repo_via_cli(); + + let output = run_libra_command(&["tag", "-m", "Release v1.1", "v1.1"], repo.path()); + assert_cli_success(&output, "tag -m Release v1.1 v1.1"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Created annotated tag 'v1.1' at "), + "unexpected stdout: {stdout}" + ); } #[test] @@ -203,6 +268,10 @@ async fn test_tag_delete_allows_invalid_target_hash() { stdout.contains("Deleted tag 'broken'"), "unexpected stdout: {stdout}" ); + assert!( + stdout.contains("(was not-a-v"), + "delete output should include abbreviated target hash, got: {stdout}" + ); assert!( internal_tag::find_tag_ref("broken") .await @@ -748,13 +817,13 @@ async fn test_annotation_lines_tag() { .filter(|line| !line.is_empty()) .collect(); - // v1.0.0(lightweight tag) + // v1.0.0 (lightweight tag) assert!(output_lines1.contains(&"v1.0.0 First")); - // v1.0.1(single line tag) + // v1.0.1 (single line tag) assert!(output_lines1.contains(&"v1.0.1 Single line annotation message")); - // v1.0.3(multi line tag) + // v1.0.3 (multi line tag) assert!(output_lines1.contains(&"v1.0.3 multi")); assert!(output_lines1.contains(&"line")); assert!(output_lines1.contains(&"annotation")); @@ -769,13 +838,13 @@ async fn test_annotation_lines_tag() { .filter(|line| !line.is_empty()) .collect(); - // v1.0.0(lightweight tag) + // v1.0.0 (lightweight tag) assert!(output_lines2.contains(&"v1.0.0 First")); - // v1.0.1(single line tag) + // v1.0.1 (single line tag) assert!(output_lines2.contains(&"v1.0.1 Single line annotation message")); - // v1.0.3(multi line tag) + // v1.0.3 (multi line tag) assert!(output_lines2.contains(&"v1.0.3 multi")); assert!(output_lines2.contains(&"line")); assert!(!output_lines2.contains(&"annotation")); From b48a0947ed030a79d7a530d319e94581c4bfc897 Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Wed, 1 Apr 2026 23:48:52 +0800 Subject: [PATCH 02/14] fix(branch-reset-docs): address review follow-ups Signed-off-by: Quanyi Ma --- docs/improvement/README.md | 6 ++--- docs/improvement/branch.md | 50 +++++++++++++++++--------------------- docs/improvement/reset.md | 27 ++++++++++---------- docs/improvement/tag.md | 50 ++++++++++++++++++-------------------- src/command/reset.rs | 25 ++++++++++++++++++- src/internal/branch.rs | 47 +++++++++++++++++++++++++++++------ 6 files changed, 124 insertions(+), 81 deletions(-) diff --git a/docs/improvement/README.md b/docs/improvement/README.md index 59b3ecbe..ecfe2782 100644 --- a/docs/improvement/README.md +++ b/docs/improvement/README.md @@ -75,9 +75,9 @@ |------|------|--------|--------| | **9** | `switch` | ✅ 已落地 | 第二批主改造已落地;后续仅维护回归测试、文档同步与大仓库切换性能观察(详见 [switch.md](switch.md)) | | **9a** | `checkout`(兼容收口) | ✅ 第二批兼容收口已落地 | 已完成 `SwitchError` 变体匹配适配与 `--help` EXAMPLES;**不是完整现代化**——`CheckoutError` / JSON / render split 仍留第六批(详见 [checkout.md](checkout.md)) | -| **10** | `reset` | 部分已落地:已有确认消息、JSON/machine、显式 `StableErrorCode`、`run_reset()` / `render_reset_output()` | 补齐 `ResetError` typed enum;移除 string-based runtime 错误分类与直写 warning;补齐 `--help` EXAMPLES(详见 [reset.md](reset.md)) | -| **11** | `tag` | 部分已落地:已有 JSON/machine、显式 `StableErrorCode`、重复创建 hint | 补齐 `TagError` typed enum;统一 run/render 分层;收口 list/show 路径的显式错误码与 human 确认消息;补齐 `--help` EXAMPLES(详见 [tag.md](tag.md)) | -| **12** | `branch` | 部分已落地:JSON 已覆盖 list/create/delete/rename/set-upstream/show-current,`StableErrorCode` 已大体补齐 | 补齐 `BranchError` typed enum;统一 run/render 分层;补齐 create/force-delete 确认消息、fuzzy suggestion 与 `--help` EXAMPLES(详见 [branch.md](branch.md)) | +| **10** | `reset` | ✅ 主改造已落地:已有确认消息、JSON/machine、显式 `StableErrorCode`、`ResetError`、warning 管线、`run_reset()` / `render_reset_output()` | 后续仅维护 rollback / warning / pathspec corruption 边界回归与文档示例(详见 [reset.md](reset.md)) | +| **11** | `tag` | ✅ 主改造已落地:已有 JSON/machine、显式 `StableErrorCode`、`TagError`、run/render 分层、重复创建 hint 与统一 human 确认消息 | 后续仅维护 lightweight tag 的 human / machine 双契约、边界回归与文档同步(详见 [tag.md](tag.md)) | +| **12** | `branch` | 主改造已落地:JSON 已覆盖 list/create/delete/rename/set-upstream/show-current,`BranchError` typed enum、run/render 分层、确认消息、fuzzy suggestion 与 `--help` EXAMPLES 已就绪 | 继续把旧调用点迁移到 `internal::branch::*_result` fallible API,减少 legacy best-effort 查询路径(详见 [branch.md](branch.md)) | **理由:** 这些命令改变仓库状态,必须告知用户发生了什么。`checkout` 的兼容收口随 `switch` 一起落地,因为 `switch` 的 `ensure_clean_status()` 签名变更强制要求 `checkout` 同步适配。 diff --git a/docs/improvement/branch.md b/docs/improvement/branch.md index 214edde3..1083cc22 100644 --- a/docs/improvement/branch.md +++ b/docs/improvement/branch.md @@ -17,48 +17,42 @@ - `StableErrorCode` 体系已有 18 个错误码 - `CliError` 支持 `.with_hint()`、`.with_stable_code()`、`.with_detail()` - `execute()` / `execute_safe(args, output)` 双入口已存在 -- `BranchOutput` + `run_branch_json()` 已覆盖 list / create / delete / rename / set-upstream / show-current 的 JSON 输出 +- `BranchOutput` + `run_branch()` 已覆盖 list / create / delete / rename / set-upstream / show-current 的 JSON 输出 - `--list` / `--delete` / `--delete-force` / `--set-upstream-to` / `--show-current` / `--move` / `--remotes` / `--all` / `--contains` / `--no-contains` 已实现 - create / delete / rename / set-upstream / show-current 路径都已补上命令层 `StableErrorCode` - `is_valid_git_branch_name()` 分支名验证已实现(`branch.rs:694-725`) - `delete_branch_safe()` 已有 merge 检查和 `.with_hint()`(`branch.rs:342-349`) -- human 路径已至少覆盖 delete-safe、rename、set-upstream、show-current 的确认输出 -- `after_help` 已有 compatibility notes,但尚未补 EXAMPLES +- human 路径已覆盖 create / delete-safe / force-delete / rename / set-upstream / show-current 的确认输出 +- `after_help` 已同时包含 compatibility notes 和 EXAMPLES **基于当前代码的 Review 结论(已改进部分 vs 仍需改进部分):** 已改进(当前代码已具备): -- **JSON 已覆盖主要操作**:`BranchOutput` + `run_branch_json()` 已支持 list / create / delete / rename / set-upstream / show-current,list schema 也已保持向后兼容 +- **JSON 已覆盖主要操作**:`BranchOutput` + `run_branch()` 已支持 list / create / delete / rename / set-upstream / show-current,list schema 也已保持向后兼容 - **大部分命令层错误已带显式 `StableErrorCode`**:invalid name、already exists、invalid commit、branch not found、detached HEAD、I/O 写失败等主要路径已显式映射 - **退出码对齐已部分落地**:删除不存在分支已走 `CliInvalidTarget`(exit `129`),删除当前分支走 `RepoStateInvalid`(exit `128`) -- **部分成功确认消息已落地**:safe delete、rename、set-upstream、show-current 均已有 human 输出 +- **`BranchError` typed enum 已落地**:create / delete / rename / list / set-upstream 等路径已统一收口到命令层 typed error +- **统一 `run_branch()` / `render_branch_output()` 分层已落地**:human / JSON / machine 已走同一执行层和渲染层 +- **成功确认消息已落地**:create、safe delete、force-delete、rename、set-upstream、show-current 均已有 human 输出 +- **fuzzy suggestion 已落地**:分支不存在时会返回 Levenshtein 近似提示 +- **`--help` EXAMPLES 已落地**:帮助文本已同时保留 compatibility notes 和示例 - **现有测试已验证关键契约**:`branch_test.rs` 已覆盖 invalid start point error code、detached HEAD set-upstream 和 JSON create schema 仍需改进: -- **无 `BranchError` typed enum**:错误仍散落在 create/delete/rename/list 各函数中 -- **无统一 `run_branch()` / `render_branch_output()` 分层**:当前只有 JSON 路径走 `run_branch_json()`,human 路径仍按分支直接执行 -- **create / force-delete 仍缺确认消息**:`create_branch_safe()` 和 `delete_branch()` 成功后仍然沉默,不符合第二批“状态变更必须确认”的目标 -- **缺少 fuzzy suggestion**:分支不存在时还没有 Levenshtein 类 `did you mean ...` 提示 -- **`--help` 仍缺 EXAMPLES**:当前 `after_help` 只有 compatibility notes -- **仍有零散错误未显式赋码**:例如 `cannot get HEAD commit` 等路径还依赖默认推断 -- **`delete_branch()` / `delete_branch_safe()` 仍有重复前置检查**:locked/current/not-found 检查可继续抽取复用 -- **`internal::branch` 仍吞掉底层失败**:`list_branches_with_conn()` 里存在 `unwrap()`,`find_branch_with_conn()` / `delete_branch_with_conn()` 仍用 `eprintln!()` 吞掉查询/删除失败;不先把这些 API 改成 fallible,命令层无法真正收口为 `BranchError` +- **`internal::branch` 兼容 wrapper 仍保留 lossy 返回类型**:`list_branches_with_conn()` / `find_branch_with_conn()` / `delete_branch_with_conn()` 仍为了兼容旧调用点返回 `Vec` / `Option` / `()`;虽然现在会显式记录错误日志,但后续仍建议继续迁移旧调用方到 `*_result` API +- **仍有少量旧调用点未直接使用 fallible API**:例如非第二批范围内的旧命令和工具模块,后续可继续把分支查询失败从“best effort”迁移到显式传播 +- **`DelegatedCli` 仍是兼容边界**:`switch` / `checkout` 相关委托路径当前仍通过 `DelegatedCli` 透传,后续如需更细粒度 typed error 可再拆分 ### 目标与非目标 -**本批目标:** -- 引入 `BranchError` typed error enum,覆盖 branch 层面的错误场景 -- 所有 `BranchError → CliError` 映射使用显式 `StableErrorCode` -- 在保留既有 `BranchOutput` JSON schema 的前提下,补齐统一的 `run_branch()` / `render_branch_output()` 分层 -- 先把 `internal::branch` 的 `list/find/delete` 改成 fallible API(去掉 `unwrap()` / `eprintln!()`),让命令层能接住真实失败 -- 抽取 delete 共享前置检查,减少 `delete_branch()` / `delete_branch_safe()` 重复逻辑 -- 补齐 create / force-delete 的 human 确认消息 -- 收口剩余未显式赋码的错误路径 -- 消除 `delete_branch()` 和 `delete_branch_safe()` 的重复代码 -- 补齐 `--help` EXAMPLES 段 -- 为分支不存在路径补齐 fuzzy suggestion +**已完成目标:** +- `BranchError` typed error enum、显式 `StableErrorCode`、统一 `run_branch()` / `render_branch_output()`、create / force-delete 确认消息、fuzzy suggestion 与 `--help` EXAMPLES 已落地 + +**后续收口目标:** +- 继续把 `internal::branch` 的旧兼容 wrapper 调用点迁移到 `list/find/delete *_result` fallible API,逐步消除 best-effort 查询路径 +- 继续收口少量 `DelegatedCli` 兼容透传边界,让跨命令委托也能保留更细粒度的 branch 语义 **本批非目标:** - **不改变 `--contains` / `--no-contains` 过滤逻辑**。BFS 可达性检查保持现有算法 @@ -138,7 +132,7 @@ pub enum BranchError { > **`NotFound` 携带 `similar` 列表**:与 `SwitchError::BranchNotFound` 模式一致,在错误构造点预计算 Levenshtein ≤ 2 近似分支名列表,`impl From for CliError` 只负责渲染 hint。Levenshtein 距离计算复用 switch 批次落地的共享工具函数(~10 行,位于 `src/utils/` 或 `src/command/mod.rs`)。 -> **关于底层 branch store 的前置收口**:在引入 `BranchError` 之前,需要先把 `src/internal/branch.rs` 中的 `list_branches_with_conn()`、`find_branch_with_conn()`、`delete_branch_with_conn()` 改成 `Result` 风格,去掉当前的 `unwrap()` / `eprintln!()`。否则命令层根本拿不到真实失败,只能把查询失败误判成 `NotFound` 或直接 panic。 +> **关于底层 branch store 的后续收口**:当前命令层已经引入 `BranchError`,但 `src/internal/branch.rs` 仍保留 `list_branches_with_conn()`、`find_branch_with_conn()`、`delete_branch_with_conn()` 这组兼容 wrapper,旧调用点还可能走 best-effort 路径。后续应继续把这些旧调用方迁移到 `*_result` API,最终彻底消除 lossy 查询。 **`BranchError → CliError` 显式映射:** @@ -200,7 +194,7 @@ pub enum BranchError { **本批变更:统一 `run_branch()` / `render_branch_output()` 分层** -当前架构问题:human 路径在 `execute_safe()` 内按分支直接执行(create/delete/rename/set-upstream/list 各自独立),JSON 路径单独走 `run_branch_json()`。两条路径各自拼装逻辑,create 和 force-delete 成功后在 human 路径沉默。 +当前架构已经统一:`execute_safe()` 调用 `run_branch()` 收集结构化结果,再由 `render_branch_output()` 统一渲染 human / JSON / machine。create、delete、rename、set-upstream 等状态变更路径都已走同一输出框架。 目标架构: @@ -218,7 +212,7 @@ pub async fn execute_safe(args: BranchArgs, output: &OutputConfig) -> CliResult< } ``` -现有 `run_branch_json()` 将被合并入 `run_branch()`。`list_branches()`、`display_branches()` 的渲染逻辑将移入 `render_branch_output()` 的 human list 分支。 +历史上的 `run_branch_json()` 已合并入 `run_branch()`;list 渲染逻辑也已集中到 `render_branch_output()` 的 human list 分支。 **渲染规则:** @@ -397,6 +391,6 @@ EXAMPLES: | 文件 | 改动类型 | 说明 | |------|---------|------| | `src/internal/branch.rs` | **收口** | 把 `list_branches_with_conn()` / `find_branch_with_conn()` / `delete_branch_with_conn()` 从 `unwrap()` / `eprintln!()` 风格改为 `Result` 风格,暴露真实查询/删除失败与存储损坏,并扩展该文件内现有单元测试覆盖这些失败面 | -| `src/command/branch.rs` | **收口** | 保持已落地的 `BranchOutput` / JSON schema / 主要 `StableErrorCode` 不回退;后续补齐 `BranchError` typed enum(含 `StorageQueryFailed`/`StoredReferenceCorrupt`/`DeleteFailed` 新变体和 `DelegatedCli` 透传)、统一 `run_branch()` / `render_branch_output()`(替代 `run_branch_json()`)、抽取 delete 共享前置检查、补齐 create/force-delete 确认消息、fuzzy suggestion 和 `--help` EXAMPLES | +| `src/command/branch.rs` | **维护** | 保持已落地的 `BranchOutput` / `BranchError` / `run_branch()` / `render_branch_output()` / human 确认消息 / fuzzy suggestion / `--help` EXAMPLES 不回退;后续仅继续清理兼容边界与旧调用点 | | `tests/command/branch_test.rs` | **扩展** | 在现有错误码和 JSON create 回归基础上,补齐 `BranchError` 变体覆盖、create/force-delete 确认消息和 fuzzy suggestion | | `tests/command/branch_json_test.rs` | **可选拆分** | 若 `branch_test.rs` 中的 JSON 覆盖继续膨胀,可再拆出独立 schema 稳定性文件;当前不是阻断项 | diff --git a/docs/improvement/reset.md b/docs/improvement/reset.md index 718458d1..6c94e3c7 100644 --- a/docs/improvement/reset.md +++ b/docs/improvement/reset.md @@ -36,26 +36,25 @@ - **执行层与渲染层已拆分**:`execute_safe()` 调用 `run_reset()` 收集结构化结果,再统一渲染 - **成功确认消息已落地**:全量 reset 会输出 `HEAD is now at ...`,pathspec reset 会输出 unstaged 文件列表 - **主要错误已带显式 `StableErrorCode`**:invalid revision、pathspec/mode 冲突、repo corrupt、I/O 失败等路径都已显式映射 +- **`ResetError` typed enum 已落地**:object load、index、HEAD、worktree、pathspec 等路径都已统一收口到 typed error +- **运行时 helper 已完成 typed 化**:`perform_reset()`、pathspec 查找、目录清理等路径已移除关键词匹配分类 +- **warning 管线已落地**:目录清理 warning 已改走共享 `emit_warning()` / warning tracker,不再直写 stderr +- **`--help` EXAMPLES 已落地** - **JSON 回归测试已存在**:`tests/command/reset_test.rs` 已覆盖 `--json` schema、`--hard HEAD` restore 计数和 pathspec usage error 仍需改进: -- **无 `ResetError` typed enum**:`run_reset()` 仍返回 `CliResult`,typed error 收口尚未完成 -- **运行时错误仍是 stringly typed**:`perform_reset()` / `remove_empty_directories()` 等内部 helper 仍返回 `Result`,`map_reset_runtime_error()` 依赖关键词匹配分类,较脆弱 -- **pathspec 错误仍未纳入 typed enum**:如 `path contains invalid UTF-8`、`pathspec ... did not match any file(s) known to libra` 仍是 inline `CliError` -- **非致命 warning 仍直写 stderr**:目录清理失败仍通过 `eprintln!()` 输出,尚未接入共享 warning/output 管线 -- **缺少 `--help` EXAMPLES 段**:Cross-Cutting **B** 在 `reset` 上仍未落地 -- **Cross-Cutting `G` 尚未接入**:意外内部错误还未统一附带 Issues URL +- **rollback 边界需要继续回归保护**:reset 失败后 rollback 再失败时,必须保持主错误分类不变,避免把 repo corruption 误报成 worktree/I/O 错误 +- **Cross-Cutting `G` 仍可继续增强**:如果后续引入真正的 internal-invariant 类兜底错误,可再统一附带 Issues URL ### 目标与非目标 -**本批目标:** -- 引入 `ResetError` typed error enum,收口剩余的 string-based runtime 错误路径 -- 将 pathspec 相关的用户输入错误一并纳入 typed enum,避免残留 inline `CliError` -- 将 `perform_reset()` / `remove_empty_directories()` 等 helper 从 `Result` 升级到 typed error -- 将非致命 cleanup warning 接入共享 `emit_warning()` / warning tracker,避免直写 stderr,同时不改变现有 `ResetOutput` JSON schema -- 保持已落地的 `ResetOutput` / JSON / human 确认消息契约不回退 -- 补齐 `--help` EXAMPLES 段,并为异常内部错误预留 Issues URL 接入点 +**已完成目标:** +- `ResetError` typed error enum、typed helper、pathspec typed error、warning 管线、`run_reset()` / `render_reset_output()` 分层与 `--help` EXAMPLES 已落地 + +**后续收口目标:** +- 继续用回归测试锁住 rollback / warning / pathspec corruption 这些边界行为 +- 如后续增加 internal invariant 兜底错误,再统一接入 Issues URL **本批非目标:** - **不改变 soft/mixed/hard reset 核心逻辑**。索引重建和工作树恢复行为不变 @@ -337,6 +336,6 @@ EXAMPLES: | 文件 | 改动类型 | 说明 | |------|---------|------| -| `src/command/reset.rs` | **收口** | 保持已落地的 `ResetOutput` / `run_reset()` / `render_reset_output()` / JSON / human 确认消息;后续补齐 `ResetError` typed enum、移除 `map_reset_runtime_error()` 的关键词分类、把目录清理 warning 接入共享输出、补齐 `--help` EXAMPLES | +| `src/command/reset.rs` | **维护** | 保持已落地的 `ResetOutput` / `ResetError` / `run_reset()` / `render_reset_output()` / warning 管线 / JSON / human 确认消息 / `--help` EXAMPLES 不回退;后续仅维护 rollback 与边界回归 | | `tests/command/reset_test.rs` | **扩展** | 在现有 JSON / human 输出回归基础上,补齐 typed error、warning 路径与 help EXAMPLES 回归 | | `tests/command/reset_json_test.rs` | **可选拆分** | 若 `reset_test.rs` 中的 JSON 覆盖继续膨胀,可再拆出独立 schema 稳定性文件;当前不是阻断项 | diff --git a/docs/improvement/tag.md b/docs/improvement/tag.md index ef937656..5726a509 100644 --- a/docs/improvement/tag.md +++ b/docs/improvement/tag.md @@ -17,9 +17,9 @@ - `StableErrorCode` 体系已有 18 个错误码 - `CliError` 支持 `.with_hint()`、`.with_stable_code()`、`.with_detail()` - `execute()` / `execute_safe(args, output)` 双入口已存在 -- `run_tag_json()` + `TagOutput` 已实现 list / create / delete 的 JSON / machine 输出 +- `run_tag()` + `TagOutput` 已实现 list / create / delete 的 JSON / machine 输出 - `-l` / `-d` / `-m` / `-f` / `-n` 短标志已实现 -- `create_tag_safe()` 已有 `.with_hint()` 提供重复创建时的 hint(`tag.rs:87-96`) +- 重复创建时的 hint 已在 `map_create_tag_error()` → `CliError` 映射中保留 - `render_tags()` 支持 `-n` 控制注释行数显示 - `internal::tag` 模块提供底层 tag API - create / delete / find major error path 已在命令层映射显式 `StableErrorCode` @@ -29,32 +29,28 @@ 已改进(当前代码已具备): -- **结构化输出已落地**:`run_tag_json()` + `TagOutput` 已覆盖 list / create / delete 三类操作,`--json` / `--machine` 可直接使用 +- **结构化输出已落地**:`run_tag()` + `TagOutput` 已覆盖 list / create / delete 三类操作,`--json` / `--machine` 可直接使用 - **主要命令层错误已带显式 `StableErrorCode`**:重复创建、HEAD unborn、tag not found、delete I/O 失败、repo read failure 等路径已有稳定错误码 +- **`TagError` typed enum 已落地**:create / list / delete / load 路径已统一收口到命令层 typed error +- **统一 `run_tag()` / `render_tag_output()` 分层已落地**:human / JSON / machine 已走同一执行层和渲染层 - **重复创建 hint 已落地**:`map_create_tag_error()` 已保留删除旧 tag 或更换 tag 名的提示 +- **human 成功反馈已统一**:lightweight / annotated create 与 delete 都已输出单行确认消息 +- **`--help` EXAMPLES 已落地** - **quiet / malformed ref delete 回归已覆盖**:当前测试已覆盖 quiet delete、删除损坏 tag ref、JSON delete `hash = null` 等边界 仍需改进: -- **无 `TagError` typed enum**:错误仍散落在 `execute_safe()`、`create_tag_safe()`、`delete_tag_safe()`、`show_tag_safe()` 中 -- **无统一 `run_tag()` / `render_tag_output()` 分层**:human 路径仍在 `execute_safe()` 内分支拼装,JSON 路径单独走 `run_tag_json()` -- **list / show 路径仍有隐式错误码**:`render_tags()` 失败在 human 路径仍通过 `CliError::fatal(e.to_string())` 返回,缺少显式 `StableErrorCode` -- **human 成功反馈仍不完全一致**:lightweight create 复用 `show_tag_safe()` 输出对象详情,annotated create 没有单独确认消息,delete 也未回显被删 tag 的 hash -- **create 失败来源尚未结构化区分**:`CheckExisting` / `SerializeTag` / `StoreObject` / `PersistReference` 需要映射到不同稳定错误码,不能继续折叠成一个泛化的 create 失败 -- **缺少 `--help` EXAMPLES 段** -- **测试注释仍有全角括号**:`tag_test.rs` 中 `(lightweight tag)` 等注释尚未清理 +- **legacy 说明尚未完全清理**:本文后文仍有少量历史设计叙述,需要继续收口为“现状 + follow-up”格式 +- **human / JSON 双契约需持续维护**:lightweight tag 当前保持 `message: null` 的 machine 契约,同时 human `-n` 列表仍显示 commit message;后续需要继续用回归测试锁住这一行为 ### 目标与非目标 -**本批目标:** -- 引入 `TagError` typed error enum,覆盖 tag 层面的错误场景 -- 所有 `TagError → CliError` 映射使用显式 `StableErrorCode` -- 在保留既有 `TagOutput` JSON schema 的前提下,补齐统一的 `run_tag()` / `render_tag_output()` 分层 -- 补齐 list / show 路径的显式 `StableErrorCode` -- 统一 human create / delete 成功反馈为简短确认消息,不再沿用当前 lightweight/annotated 两套不同输出习惯 -- 让 create 失败来源在 `TagError` 中显式编码,避免同一变体对应多个 `StableErrorCode` -- 修复测试注释中的全角括号 -- 补齐 `--help` EXAMPLES 段 +**已完成目标:** +- `TagError` typed error enum、显式 `StableErrorCode`、统一 `run_tag()` / `render_tag_output()` 分层、human 成功确认消息、create 失败来源结构化映射和 `--help` EXAMPLES 已落地 + +**后续收口目标:** +- 继续维护 lightweight tag 的 human / machine 双契约和边界回归测试 +- 持续清理本文残留的历史设计说明,使计划文档完全反映当前实现 **本批非目标:** - **不重写 `internal::tag` 底层业务语义**。允许做类型收紧和错误建模调整(例如 `create()` 返回 `CreateTagError`),但不改变 tag 创建/删除/查询的语义行为 @@ -115,7 +111,7 @@ pub enum TagError { } ``` -> **与 `internal::tag::CreateTagError` 的关系**:`CreateTagError` 是底层业务模块定义的错误类型(含 `AlreadyExists`、`HeadUnborn`、`CheckExisting`、`SerializeTag`、`StoreObject`、`PersistReference`)。`TagError` 是命令层 typed enum,通过 `impl From for TagError` 收口映射(现有 `map_create_tag_error()` 将被替代):`CheckExisting` → `CheckExistingFailed`,`SerializeTag` → `SerializeAnnotatedTag`,`StoreObject` → `StoreObjectFailed`,`PersistReference` → `PersistReferenceFailed`。 +> **与 `internal::tag::CreateTagError` 的关系**:`CreateTagError` 是底层业务模块定义的错误类型(含 `AlreadyExists`、`HeadUnborn`、`CheckExisting`、`SerializeTag`、`StoreObject`、`PersistReference`)。`TagError` 是命令层 typed enum,当前代码通过 `map_create_tag_error()` 完成收口映射:`CheckExisting` → `CheckExistingFailed`,`SerializeTag` → `SerializeAnnotatedTag`,`StoreObject` → `StoreObjectFailed`,`PersistReference` → `PersistReferenceFailed`。 **`TagError → CliError` 显式映射:** @@ -147,9 +143,9 @@ pub enum TagError { | `map_create_tag_error:176-178` | `CreateTagError::StoreObject` | `StoreObjectFailed` | | `map_create_tag_error:180-184` | `CreateTagError::PersistReference` | `PersistReferenceFailed` | | `delete_tag_safe:242-246` | `tag::delete().map_err(...)` | `DeleteFailed` | -| `show_tag_safe:274-276` | `Ok(None)` tag not found | `NotFound` | -| `show_tag_safe:277-279` | `Err(e)` repo corrupt | `LoadFailed` | -| `run_tag_json:315-317` | `tag::list().map_err(...)` | `ListFailed` | +| `lookup_tag:357-365` | `Ok(None)` tag not found | `NotFound` | +| `lookup_tag:357-365` | `Err(e)` repo corrupt | `LoadFailed` | +| `collect_tags:346-350` | `tag::list().map_err(...)` | `ListFailed` | | `lookup_tag:332-334` | `Ok(None)` tag not found | `NotFound` | | `lookup_tag:335-337` | `Err(e)` repo corrupt | `LoadFailed` | @@ -159,7 +155,7 @@ pub enum TagError { **本批变更:统一 `run_tag()` / `render_tag_output()` 分层** -当前架构问题:human 路径在 `execute_safe()` 内分支拼装(list 走 `render_tags()`,create 走 `create_tag_safe()` + `show_tag_safe()`,delete 走 `delete_tag_safe()`),JSON 路径单独走 `run_tag_json()`。两条路径各自拼装逻辑,违反执行/渲染分离原则。 +当前架构已经统一:`execute_safe()` 调用 `run_tag()` 收集结构化结果,再由 `render_tag_output()` 统一渲染 human / JSON / machine。list / create / delete 三类路径已经合流。 目标架构: @@ -177,7 +173,7 @@ pub async fn execute_safe(args: TagArgs, output: &OutputConfig) -> CliResult<()> } ``` -现有 `run_tag_json()` 将被合并入 `run_tag()`,`render_tags()` 的渲染逻辑将移入 `render_tag_output()` 的 human list 分支。 +历史上的 `run_tag_json()` 已合并入 `run_tag()`;`render_tags()` 当前主要保留给测试和辅助调用,实际 CLI human 列表渲染已在 `render_tag_output()` 中统一处理。 **渲染规则:** @@ -314,5 +310,5 @@ EXAMPLES: | 文件 | 改动类型 | 说明 | |------|---------|------| -| `src/command/tag.rs` | **收口** | 保持已落地的 `TagOutput` / `run_tag_json()` / JSON schema / create hint 不回退;后续补齐 `TagError` typed enum、统一 `run_tag()` / `render_tag_output()`、收口 list/show 路径的显式错误码、统一 human 确认消息、补齐 `--help` EXAMPLES | -| `tests/command/tag_test.rs` | **扩展** | 在现有 JSON / quiet / malformed ref delete 回归基础上,补齐 `TagError` 变体覆盖、human 成功反馈一致性校验和全角括号清理 | +| `src/command/tag.rs` | **维护** | 保持已落地的 `TagOutput` / `TagError` / `run_tag()` / `render_tag_output()` / create hint / human 确认消息 / `--help` EXAMPLES 不回退;后续仅维护双契约与边界回归 | +| `tests/command/tag_test.rs` | **维护** | 在现有 JSON / quiet / malformed ref delete / lightweight-human-vs-machine 契约回归基础上,继续维护 `TagError` 变体覆盖与成功反馈一致性校验 | diff --git a/src/command/reset.rs b/src/command/reset.rs index 140effcb..f4a9043d 100644 --- a/src/command/reset.rs +++ b/src/command/reset.rs @@ -506,7 +506,12 @@ fn merge_reset_failure(error: ResetError, rollback: Result<(), ResetError>) -> R ResetError::WorktreeRestore(format!("{detail}; rollback failed: {rollback_error}")) } other => { - ResetError::WorktreeRestore(format!("{other}; rollback failed: {rollback_error}")) + tracing::error!( + "rollback after reset failed: {} (primary error: {})", + rollback_error, + other + ); + other } }, } @@ -940,4 +945,22 @@ mod tests { )); assert_eq!(error.stable_code(), StableErrorCode::IoReadFailed); } + + #[test] + fn test_merge_reset_failure_preserves_primary_error_category() { + let merged = merge_reset_failure( + ResetError::ObjectLoad { + kind: "tree", + object_id: "deadbeef".into(), + detail: "corrupt object".into(), + }, + Err(ResetError::WorktreeRestore( + "failed to restore working tree".into(), + )), + ); + + assert!(matches!(merged, ResetError::ObjectLoad { .. })); + let cli_error = CliError::from(merged); + assert_eq!(cli_error.stable_code(), StableErrorCode::RepoCorrupt); + } } diff --git a/src/internal/branch.rs b/src/internal/branch.rs index 2cc29111..d24269f2 100644 --- a/src/internal/branch.rs +++ b/src/internal/branch.rs @@ -37,6 +37,10 @@ pub enum BranchStoreError { Delete { name: String, detail: String }, } +fn log_branch_store_error(context: &str, error: &BranchStoreError) { + tracing::error!("{context}: {error}"); +} + fn branch_from_model(model: reference::Model) -> Result, BranchStoreError> { let Some(name) = model.name.clone() else { return Err(BranchStoreError::Corrupt { @@ -140,9 +144,13 @@ impl Branch { where C: ConnectionTrait, { - Self::list_branches_result_with_conn(db, remote) - .await - .unwrap_or_default() + match Self::list_branches_result_with_conn(db, remote).await { + Ok(branches) => branches, + Err(error) => { + log_branch_store_error("failed to list branches", &error); + Vec::new() + } + } } /// list all remote branches @@ -180,10 +188,22 @@ impl Branch { where C: ConnectionTrait, { - Self::find_branch_result_with_conn(db, branch_name, remote) - .await - .ok() - .flatten() + match Self::find_branch_result_with_conn(db, branch_name, remote).await { + Ok(branch) => branch, + Err(error) => { + log_branch_store_error( + &format!( + "failed to resolve branch lookup for '{}'{}", + branch_name, + remote + .map(|name| format!(" on remote '{name}'")) + .unwrap_or_default() + ), + &error, + ); + None + } + } } /// get the branch by name @@ -322,7 +342,18 @@ impl Branch { where C: ConnectionTrait, { - let _ = Self::delete_branch_result_with_conn(db, branch_name, remote).await; + if let Err(error) = Self::delete_branch_result_with_conn(db, branch_name, remote).await { + log_branch_store_error( + &format!( + "failed to delete branch '{}'{}", + branch_name, + remote + .map(|name| format!(" on remote '{name}'")) + .unwrap_or_default() + ), + &error, + ); + } } pub async fn delete_branch(branch_name: &str, remote: Option<&str>) { From 5d9781f32828cd916a08c613b534fd1bccbff0cc Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Thu, 2 Apr 2026 01:31:41 +0800 Subject: [PATCH 03/14] fix(branch-remote-review): address follow-up review gaps Signed-off-by: Quanyi Ma --- src/command/branch.rs | 53 ++++------- src/command/checkout.rs | 31 ++++-- src/command/clone.rs | 25 +++-- src/command/cloud.rs | 8 +- src/command/config.rs | 23 +---- src/command/fetch.rs | 17 +++- src/command/log.rs | 59 ++++++++---- src/command/merge.rs | 6 +- src/command/push.rs | 27 +----- src/command/remote.rs | 33 ++++++- src/command/reset.rs | 20 +++- src/command/restore.rs | 39 +++++--- src/command/shortlog.rs | 16 +++- src/command/show.rs | 43 +++++++-- src/command/show_ref.rs | 39 ++++++-- src/command/status.rs | 43 ++++++--- src/command/switch.rs | 115 ++++++++++++++-------- src/command/tag.rs | 11 +-- src/internal/branch.rs | 35 ++++++- src/internal/head.rs | 35 +++++-- src/internal/protocol/local_client.rs | 46 +++++---- src/utils/convert.rs | 8 +- src/utils/mod.rs | 1 + src/utils/text.rs | 65 +++++++++++++ src/utils/util.rs | 16 +++- tests/command/branch_test.rs | 18 ++++ tests/command/remote_test.rs | 132 ++++++++++++++++++++++++++ tests/command/show_ref_test.rs | 36 ++++++- 28 files changed, 746 insertions(+), 254 deletions(-) create mode 100644 src/utils/text.rs diff --git a/src/command/branch.rs b/src/command/branch.rs index b9ffb762..408d45ca 100644 --- a/src/command/branch.rs +++ b/src/command/branch.rs @@ -19,6 +19,7 @@ use crate::{ utils::{ error::{CliError, CliResult, StableErrorCode}, output::{OutputConfig, emit_json_data}, + text::{levenshtein, short_display_hash}, }, }; @@ -327,32 +328,6 @@ fn map_branch_store_error(error: branch::BranchStoreError) -> BranchError { } } -fn short_hash(hash: &str) -> &str { - let end = hash.len().min(7); - &hash[..end] -} - -fn levenshtein(a: &str, b: &str) -> usize { - let a: Vec = a.chars().collect(); - let b: Vec = b.chars().collect(); - let (a, b) = if a.len() > b.len() { - (&b, &a) - } else { - (&a, &b) - }; - let mut prev: Vec = (0..=a.len()).collect(); - let mut curr = vec![0; a.len() + 1]; - for (i, cb) in b.iter().enumerate() { - curr[0] = i + 1; - for (j, ca) in a.iter().enumerate() { - let cost = usize::from(ca != cb); - curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1); - } - std::mem::swap(&mut prev, &mut curr); - } - prev[a.len()] -} - fn find_similar_branch_names(branch_name: &str, branches: &[Branch]) -> Vec { let target_len = branch_name.chars().count(); let mut best: Option<(usize, String)> = None; @@ -383,15 +358,15 @@ fn find_similar_branch_names(branch_name: &str, branches: &[Branch]) -> Vec BranchError { +async fn branch_not_found_error(branch_name: &str) -> Result { let similar = Branch::list_branches_result(None) .await .map(|branches| find_similar_branch_names(branch_name, &branches)) - .unwrap_or_default(); - BranchError::NotFound { + .map_err(map_branch_store_error)?; + Ok(BranchError::NotFound { name: branch_name.to_string(), similar, - } + }) } async fn require_existing_local_branch(branch_name: &str) -> Result { @@ -400,7 +375,7 @@ async fn require_existing_local_branch(branch_name: &str) -> Result Ok(branch), - None => Err(branch_not_found_error(branch_name).await), + None => Err(branch_not_found_error(branch_name).await?), } } @@ -430,6 +405,8 @@ async fn set_upstream_with_conn( branch_config_read_error(format!("upstream config for branch '{branch}'"), e) })?; let merge_ref = format!("refs/heads/{remote_branch}"); + // `branch_config_with_conn()` normalizes `refs/heads/` to ``, + // so the idempotency check must compare against the short branch name. let should_write = branch_config .as_ref() .map(|config| config.remote != remote || config.merge != remote_branch) @@ -759,7 +736,10 @@ fn render_branch_output(result: &BranchOutput, output: &OutputConfig) -> CliResu show_unborn_head, } => { if let Some(detached_head) = detached_head { - println!("HEAD detached at {}", short_hash(detached_head).green()); + println!( + "HEAD detached at {}", + short_display_hash(detached_head).green() + ); } if branches.is_empty() { if *show_unborn_head && let Some(head_name) = head_name { @@ -788,14 +768,17 @@ fn render_branch_output(result: &BranchOutput, output: &OutputConfig) -> CliResu } } BranchOutput::Create { name, commit } => { - println!("Created branch '{name}' at {}", short_hash(commit)); + println!("Created branch '{name}' at {}", short_display_hash(commit)); } BranchOutput::Delete { name, commit, force: _, } => { - println!("Deleted branch {name} (was {}).", short_hash(commit)); + println!( + "Deleted branch {name} (was {}).", + short_display_hash(commit) + ); } BranchOutput::Rename { old_name, new_name } => { println!("Renamed branch '{old_name}' to '{new_name}'"); @@ -810,7 +793,7 @@ fn render_branch_output(result: &BranchOutput, output: &OutputConfig) -> CliResu } => { if *detached { if let Some(commit) = commit { - println!("HEAD detached at {}", short_hash(commit)); + println!("HEAD detached at {}", short_display_hash(commit)); } } else if let Some(name) = name { println!("{name}"); diff --git a/src/command/checkout.rs b/src/command/checkout.rs index dba6b5ab..1d01cd98 100644 --- a/src/command/checkout.rs +++ b/src/command/checkout.rs @@ -10,11 +10,11 @@ use crate::{ switch, }, internal::{ - branch::{Branch, INTENT_BRANCH}, + branch::{Branch, BranchStoreError, INTENT_BRANCH}, head::Head, }, utils::{ - error::{CliError, CliResult}, + error::{CliError, CliResult, StableErrorCode}, output::OutputConfig, util, }, @@ -76,8 +76,9 @@ pub async fn execute_safe(args: CheckoutArgs, output: &OutputConfig) -> CliResul } let target_commit = if let Some(ref branch_name) = args.branch { - Branch::find_branch(branch_name, None) + Branch::find_branch_result(branch_name, None) .await + .map_err(|error| checkout_branch_store_error("resolve checkout target", error))? .map(|branch| branch.commit) } else { None @@ -110,6 +111,17 @@ pub async fn execute_safe(args: CheckoutArgs, output: &OutputConfig) -> CliResul Ok(()) } +fn checkout_branch_store_error(context: &str, error: BranchStoreError) -> CliError { + match error { + BranchStoreError::Query(detail) => { + CliError::fatal(format!("failed to {context}: {detail}")) + .with_stable_code(StableErrorCode::IoReadFailed) + } + other => CliError::fatal(format!("failed to {context}: {other}")) + .with_stable_code(StableErrorCode::RepoCorrupt), + } +} + pub async fn get_current_branch() -> Option { match Head::current().await { Head::Detached(_) => None, @@ -139,8 +151,9 @@ async fn switch_branch_with_output(branch_name: &str, output: &OutputConfig) -> INTENT_BRANCH ))); } - let target_branch = Branch::find_branch(branch_name, None) + let target_branch = Branch::find_branch_result(branch_name, None) .await + .map_err(|error| checkout_branch_store_error("resolve branch", error))? .ok_or_else(|| CliError::fatal(format!("branch '{}' not found", branch_name)))?; restore_to_commit(target_branch.commit, output).await?; let head = Head::Branch(branch_name.to_string()); @@ -182,10 +195,16 @@ async fn check_branch_with_output( return Ok(None); } - let target_branch: Option = Branch::find_branch(branch_name, None).await; + let target_branch: Option = Branch::find_branch_result(branch_name, None) + .await + .map_err(|error| checkout_branch_store_error("resolve branch", error))?; if target_branch.is_none() { let remote_branch_name: String = format!("origin/{branch_name}"); - if !Branch::search_branch(&remote_branch_name).await.is_empty() { + if !Branch::search_branch_result(&remote_branch_name) + .await + .map_err(|error| checkout_branch_store_error("search remote tracking branches", error))? + .is_empty() + { crate::info_println!( output, "branch '{branch_name}' set up to track '{remote_branch_name}'." diff --git a/src/command/clone.rs b/src/command/clone.rs index a7417a10..e2668096 100644 --- a/src/command/clone.rs +++ b/src/command/clone.rs @@ -142,6 +142,8 @@ pub enum CloneError { InitializeRepository { source: InitError }, #[error("remote branch {branch} not found in upstream origin")] RemoteBranchNotFound { branch: String }, + #[error("failed to inspect local branch state after fetch: {message}")] + LocalBranchState { message: String }, #[error("fetch failed: {source}")] FetchFailed { source: fetch::FetchError }, #[error("failed to checkout working tree")] @@ -194,6 +196,11 @@ impl From for CliError { .with_hint( "use `-b ` to specify an existing branch, or omit to use remote HEAD", ), + CloneError::LocalBranchState { message } => CliError::fatal(format!( + "failed to inspect local branch state after fetch: {message}" + )) + .with_stable_code(StableErrorCode::RepoCorrupt) + .with_hint("run 'libra status' to verify the local repository state"), CloneError::FetchFailed { source } => map_fetch_error(source), CloneError::CheckoutFailed { source } => map_checkout_error(source), CloneError::SetupFailed { .. } => CliError::fatal(error.to_string()) @@ -742,12 +749,18 @@ pub(crate) async fn setup_repository( if let Some(branch_name) = branch_to_checkout { let remote_tracking_ref = format!("refs/remotes/{}/{}", remote_config.name, branch_name); - let origin_branch = - Branch::find_branch_with_conn(&db, &remote_tracking_ref, Some(&remote_config.name)) - .await - .ok_or_else(|| CloneError::RemoteBranchNotFound { - branch: branch_name.clone(), - })?; + let origin_branch = Branch::find_branch_result_with_conn( + &db, + &remote_tracking_ref, + Some(&remote_config.name), + ) + .await + .map_err(|error| CloneError::LocalBranchState { + message: error.to_string(), + })? + .ok_or_else(|| CloneError::RemoteBranchNotFound { + branch: branch_name.clone(), + })?; let action = ReflogAction::Clone { from: remote_config.url.clone(), diff --git a/src/command/cloud.rs b/src/command/cloud.rs index 0dd78dda..3222c1d2 100644 --- a/src/command/cloud.rs +++ b/src/command/cloud.rs @@ -473,7 +473,9 @@ async fn execute_restore(args: RestoreArgs) -> Result<(), String> { // We try to find the latest commit and checkout to it // Check if HEAD has a commit (either restored or existing) - let head_commit = crate::internal::head::Head::current_commit().await; + let head_commit = crate::internal::head::Head::current_commit_result() + .await + .map_err(|error| format!("failed to resolve HEAD commit: {error}"))?; if let Some(commit) = head_commit { println!("Restoring working directory to HEAD ({})", commit); @@ -483,7 +485,9 @@ async fn execute_restore(args: RestoreArgs) -> Result<(), String> { // Try to find 'main' branch in references // We look for 'main' branch in the reference table as a fallback - let main_branch = crate::internal::branch::Branch::find_branch("main", None).await; + let main_branch = crate::internal::branch::Branch::find_branch_result("main", None) + .await + .map_err(|error| format!("failed to resolve main branch: {error}"))?; if let Some(branch) = main_branch { println!("Found main branch: {}", branch.commit); diff --git a/src/command/config.rs b/src/command/config.rs index 73f7ae78..d7182211 100644 --- a/src/command/config.rs +++ b/src/command/config.rs @@ -16,6 +16,7 @@ use crate::{ utils::{ error::{CliError, CliResult, StableErrorCode}, output::OutputConfig, + text::levenshtein, }, }; @@ -2121,28 +2122,6 @@ fn has_explicit_scope(args: &ConfigArgs) -> bool { args.local || args.global || args.system } -/// Simple Levenshtein edit distance for spell correction suggestions. -fn levenshtein(a: &str, b: &str) -> usize { - let m = a.len(); - let n = b.len(); - let mut dp = vec![vec![0usize; n + 1]; m + 1]; - for (i, row) in dp.iter_mut().enumerate() { - row[0] = i; - } - for (j, val) in dp[0].iter_mut().enumerate() { - *val = j; - } - for (i, ca) in a.chars().enumerate() { - for (j, cb) in b.chars().enumerate() { - let cost = if ca == cb { 0 } else { 1 }; - dp[i + 1][j + 1] = (dp[i][j + 1] + 1) - .min(dp[i + 1][j] + 1) - .min(dp[i][j] + cost); - } - } - dp[m][n] -} - fn emit_set_ack( action: &str, scope: ConfigScope, diff --git a/src/command/fetch.rs b/src/command/fetch.rs index 260f6dc8..7b6740e9 100644 --- a/src/command/fetch.rs +++ b/src/command/fetch.rs @@ -40,7 +40,7 @@ use crate::{ reflog::{HEAD, Reflog, ReflogAction, ReflogContext, ReflogError}, }, utils::{ - error::{CliError, CliResult}, + error::{CliError, CliResult, StableErrorCode}, output::OutputConfig, path, util, }, @@ -543,7 +543,10 @@ pub async fn execute_safe(args: FetchArgs, output: &OutputConfig) -> CliResult<( tracing::debug!("`fetch` args: {:?}", args); if args.all { - for remote in ConfigKv::all_remote_configs().await.unwrap_or_default() { + for remote in ConfigKv::all_remote_configs().await.map_err(|error| { + CliError::fatal(format!("failed to read remote configuration: {error}")) + .with_stable_code(StableErrorCode::IoReadFailed) + })? { fetch_repository_safe(remote, None, false, None, output) .await .map_err(CliError::from)?; @@ -1070,14 +1073,20 @@ async fn current_have_safe() -> Result, FetchError> { let mut remotes = ConfigKv::all_remote_configs() .await - .unwrap_or_default() + .map_err(|source| FetchError::LocalState { + message: format!("failed to read remote configuration: {source}"), + })? .iter() .map(|remote| Some(remote.name.to_owned())) .collect::>(); remotes.push(None); for remote in remotes { - let branches = Branch::list_branches(remote.as_deref()).await; + let branches = Branch::list_branches_result(remote.as_deref()) + .await + .map_err(|source| FetchError::LocalState { + message: format!("failed to list local branches: {source}"), + })?; for branch in branches { let commit: Commit = load_object(&branch.commit).map_err(|source| FetchError::LocalState { diff --git a/src/command/log.rs b/src/command/log.rs index bdaea311..5e811065 100644 --- a/src/command/log.rs +++ b/src/command/log.rs @@ -20,7 +20,7 @@ use crate::{ command::load_object, common_utils::parse_commit_msg, internal::{ - branch::Branch, + branch::{Branch, BranchStoreError}, config::ConfigKv, head::Head, log::{ @@ -37,6 +37,17 @@ use crate::{ }, }; +fn log_branch_store_error(context: &str, error: BranchStoreError) -> CliError { + match error { + BranchStoreError::Query(detail) => { + CliError::fatal(format!("failed to {context}: {detail}")) + .with_stable_code(StableErrorCode::IoReadFailed) + } + other => CliError::fatal(format!("failed to {context}: {other}")) + .with_stable_code(StableErrorCode::RepoCorrupt), + } +} + #[derive(Parser, Debug)] pub struct LogArgs { /// Limit the number of output @@ -394,7 +405,9 @@ pub async fn execute_safe(args: LogArgs, output: &OutputConfig) -> CliResult<()> None }; if let Some(n) = &branch_name { - let branch = Branch::find_branch(n, None).await; + let branch = Branch::find_branch_result(n, None) + .await + .map_err(|error| log_branch_store_error("inspect the current branch", error))?; if branch.is_none() { return Err(CliError::fatal(format!( "your current branch '{n}' does not have any commits yet" @@ -402,8 +415,9 @@ pub async fn execute_safe(args: LogArgs, output: &OutputConfig) -> CliResult<()> } } - let commit_hash = Head::current_commit() + let commit_hash = Head::current_commit_result() .await + .map_err(|error| log_branch_store_error("resolve HEAD commit", error))? .ok_or_else(|| match branch_name.as_deref() { Some(name) => CliError::fatal(format!( "your current branch '{name}' does not have any commits yet" @@ -420,7 +434,7 @@ pub async fn execute_safe(args: LogArgs, output: &OutputConfig) -> CliResult<()> return Ok(()); } - let ref_commits = create_reference_commit_map().await; + let ref_commits = create_reference_commit_map().await?; let full_hash_len = commit_hash.len(); let format_type = if args.oneline { @@ -606,7 +620,10 @@ async fn run_log(args: &LogArgs) -> CliResult { None }; if let Some(name) = &branch_name - && Branch::find_branch(name, None).await.is_none() + && Branch::find_branch_result(name, None) + .await + .map_err(|error| log_branch_store_error("inspect the current branch", error))? + .is_none() { return Err(CliError::fatal(format!( "your current branch '{name}' does not have any commits yet" @@ -615,19 +632,19 @@ async fn run_log(args: &LogArgs) -> CliResult { .with_hint("create a commit first with 'libra commit'.")); } - let current_head_commit = - Head::current_commit() - .await - .ok_or_else(|| match branch_name.as_deref() { - Some(name) => CliError::fatal(format!( - "your current branch '{name}' does not have any commits yet" - )) + let current_head_commit = Head::current_commit_result() + .await + .map_err(|error| log_branch_store_error("resolve HEAD commit", error))? + .ok_or_else(|| match branch_name.as_deref() { + Some(name) => CliError::fatal(format!( + "your current branch '{name}' does not have any commits yet" + )) + .with_stable_code(StableErrorCode::RepoStateInvalid) + .with_hint("create a commit first with 'libra commit'."), + None => CliError::fatal("your current HEAD does not have any commits yet") .with_stable_code(StableErrorCode::RepoStateInvalid) .with_hint("create a commit first with 'libra commit'."), - None => CliError::fatal("your current HEAD does not have any commits yet") - .with_stable_code(StableErrorCode::RepoStateInvalid) - .with_hint("create a commit first with 'libra commit'."), - })?; + })?; let commit_hash = current_head_commit.to_string(); let mut reachable_commits = get_reachable_commits(commit_hash, None).await?; @@ -635,7 +652,7 @@ async fn run_log(args: &LogArgs) -> CliResult { let max_output_number = min(args.number.unwrap_or(usize::MAX), reachable_commits.len()); let include_total = args.number.is_none(); - let ref_commits = create_reference_commit_map().await; + let ref_commits = create_reference_commit_map().await?; let mut commits = Vec::new(); let mut total = 0usize; @@ -1060,10 +1077,12 @@ impl GraphState { } } -async fn create_reference_commit_map() -> HashMap> { +async fn create_reference_commit_map() -> CliResult>> { let mut commit_to_refs: HashMap> = HashMap::new(); - let all_branches = Branch::list_branches(None).await; + let all_branches = Branch::list_branches_result(None) + .await + .map_err(|error| log_branch_store_error("list branches for log decoration", error))?; for branch in all_branches { commit_to_refs .entry(branch.commit) @@ -1099,7 +1118,7 @@ async fn create_reference_commit_map() -> HashMap> { }); } - commit_to_refs + Ok(commit_to_refs) } /// Generate unified diff between commit and its first parent (or empty tree) diff --git a/src/command/merge.rs b/src/command/merge.rs index 83e7db66..fbf60542 100644 --- a/src/command/merge.rs +++ b/src/command/merge.rs @@ -17,7 +17,7 @@ use super::{ }; use crate::{ internal::{ - branch::Branch, + branch::{Branch, BranchStoreError}, db::get_db_conn_instance, head::Head, reflog::{ReflogAction, ReflogContext, with_reflog}, @@ -192,7 +192,9 @@ pub(crate) async fn run_merge_for_pull( async fn resolve_merge_target(target_ref: &str) -> Result> { if let Some(remote) = target_ref.strip_prefix("refs/remotes/") && let Some((remote_name, _)) = remote.split_once('/') - && let Some(branch) = Branch::find_branch(target_ref, Some(remote_name)).await + && let Some(branch) = Branch::find_branch_result(target_ref, Some(remote_name)) + .await + .map_err(|error: BranchStoreError| Box::new(error) as Box)? { return Ok(branch.commit); } diff --git a/src/command/push.rs b/src/command/push.rs index 668fa360..1fabe263 100644 --- a/src/command/push.rs +++ b/src/command/push.rs @@ -46,6 +46,7 @@ use crate::{ error::{CliError, CliResult, StableErrorCode, emit_warning}, object_ext::{BlobExt, CommitExt, TreeExt}, output::{OutputConfig, ProgressMode, ProgressReporter, emit_json_data}, + text::levenshtein, }, }; @@ -407,7 +408,10 @@ pub async fn run_push(args: PushArgs, output: &OutputConfig) -> Result (current_branch.clone(), current_branch.clone()), }; - let commit_hash = match Branch::find_branch(&local_branch, None).await { + let commit_hash = match Branch::find_branch_result(&local_branch, None) + .await + .map_err(|error| PushError::RepoState(error.to_string()))? + { Some(branch_info) => branch_info.commit.to_string(), None => return Err(PushError::SourceRefNotFound(local_branch.clone())), }; @@ -943,27 +947,6 @@ async fn suggest_remote_name(input: &str) -> Option { best.map(|(name, _)| name) } -fn levenshtein(a: &str, b: &str) -> usize { - let m = a.len(); - let n = b.len(); - let mut dp = vec![vec![0usize; n + 1]; m + 1]; - for (i, row) in dp.iter_mut().enumerate() { - row[0] = i; - } - for (j, val) in dp[0].iter_mut().enumerate() { - *val = j; - } - for (i, ca) in a.chars().enumerate() { - for (j, cb) in b.chars().enumerate() { - let cost = if ca == cb { 0 } else { 1 }; - dp[i + 1][j + 1] = (dp[i][j + 1] + 1) - .min(dp[i + 1][j] + 1) - .min(dp[i][j] + cost); - } - } - dp[m][n] -} - fn emit_push_warnings(warnings: &[String]) { for warning in warnings { emit_warning(warning); diff --git a/src/command/remote.rs b/src/command/remote.rs index 2ce1fe45..c5b19379 100644 --- a/src/command/remote.rs +++ b/src/command/remote.rs @@ -7,9 +7,13 @@ use git_internal::hash::get_hash_kind; use crate::{ command::fetch::RemoteClient, - internal::{branch::Branch, config::ConfigKv, protocol::set_wire_hash_kind}, + internal::{ + branch::{Branch, BranchStoreError}, + config::ConfigKv, + protocol::set_wire_hash_kind, + }, utils::{ - error::{CliError, CliResult}, + error::{CliError, CliResult, StableErrorCode}, output::OutputConfig, }, }; @@ -271,6 +275,21 @@ async fn show_remote_verbose(remote: &str) -> CliResult<()> { Ok(()) } +fn remote_branch_store_error(context: &str, error: BranchStoreError) -> CliError { + match error { + BranchStoreError::Query(detail) => { + CliError::fatal(format!("failed to {context}: {detail}")) + .with_stable_code(StableErrorCode::IoReadFailed) + } + BranchStoreError::Delete { name, detail } => CliError::fatal(format!( + "failed to delete branch '{name}' while {context}: {detail}" + )) + .with_stable_code(StableErrorCode::IoWriteFailed), + other => CliError::fatal(format!("failed to {context}: {other}")) + .with_stable_code(StableErrorCode::RepoCorrupt), + } +} + async fn prune_remote(name: &str, dry_run: bool) -> Result<(), CliError> { // Check if the remote exists let Some(remote_config) = ConfigKv::remote_config(name).await.ok().flatten() else { @@ -324,7 +343,9 @@ async fn prune_remote(name: &str, dry_run: bool) -> Result<(), CliError> { }) .collect(); // Get local remote-tracking branches (format: "refs/remotes/{remote}/branch_name") - let local_remote_branches = Branch::list_branches(Some(name)).await; + let local_remote_branches = Branch::list_branches_result(Some(name)) + .await + .map_err(|error| remote_branch_store_error("list remote-tracking branches", error))?; // Find and prune stale branches let mut pruned_count = 0; @@ -346,7 +367,11 @@ async fn prune_remote(name: &str, dry_run: bool) -> Result<(), CliError> { if dry_run { println!(" * [would prune] {}/{}", name, branch_name); } else { - Branch::delete_branch(&local_branch.name, Some(name)).await; + Branch::delete_branch_result(&local_branch.name, Some(name)) + .await + .map_err(|error| { + remote_branch_store_error("pruning remote-tracking branch", error) + })?; println!(" * [pruned] {}/{}", name, branch_name); } pruned_count += 1; diff --git a/src/command/reset.rs b/src/command/reset.rs index f4a9043d..9f6225ac 100644 --- a/src/command/reset.rs +++ b/src/command/reset.rs @@ -267,7 +267,7 @@ async fn run_reset(args: ResetArgs) -> Result { let target_commit_id = resolve_commit(&args.target).await?; let changed_paths = reset_pathspecs(&args.pathspecs, &target_commit_id).await?; - let subject = get_commit_summary(&target_commit_id).unwrap_or_default(); + let subject = load_commit_summary_or_warn(&target_commit_id); return Ok(ResetExecution { output: ResetOutput { @@ -287,7 +287,7 @@ async fn run_reset(args: ResetArgs) -> Result { let target_commit_id = resolve_commit(&args.target).await?; let reset_stats = perform_reset(target_commit_id, mode, &args.target).await?; - let subject = get_commit_summary(&target_commit_id).unwrap_or_default(); + let subject = load_commit_summary_or_warn(&target_commit_id); Ok(ResetExecution { output: ResetOutput { mode: mode.as_str().to_string(), @@ -440,12 +440,26 @@ async fn rollback_reset_side_effects( ResetMode::Hard => { reset_index_to_commit_typed(old_oid)?; let rollback_paths = tracked_paths_for_hard_reset(target_commit_id)?; - let _ = reset_working_directory_to_commit(old_oid, &rollback_paths).await?; + let rollback_stats = + reset_working_directory_to_commit(old_oid, &rollback_paths).await?; + if !rollback_stats.warnings.is_empty() { + tracing::warn!( + warnings = ?rollback_stats.warnings, + "rollback after reset completed with worktree warnings" + ); + } Ok(()) } } } +fn load_commit_summary_or_warn(commit_id: &ObjectHash) -> String { + get_commit_summary(commit_id).unwrap_or_else(|error| { + tracing::warn!("failed to load commit summary for {commit_id}: {error}"); + String::new() + }) +} + async fn update_reset_reference( current_head_state: Head, old_oid: ObjectHash, diff --git a/src/command/restore.rs b/src/command/restore.rs index 85ee29f6..94f84264 100644 --- a/src/command/restore.rs +++ b/src/command/restore.rs @@ -17,7 +17,11 @@ use git_internal::{ use crate::{ command::{calc_file_blob_hash, load_object}, - internal::{branch::Branch, head::Head, protocol::lfs_client::LFSClient}, + internal::{ + branch::{Branch, BranchStoreError}, + head::Head, + protocol::lfs_client::LFSClient, + }, utils::{ error::{CliError, CliResult}, lfs, @@ -117,12 +121,13 @@ pub async fn execute_checked(args: RestoreArgs) -> io::Result<()> { // ref: prevent moving `source` if src == HEAD { // Default Source - Head::current_commit().await - } else if Branch::exists(src).await { - // Branch Name, e.g. master - let branch = Branch::find_branch(src, None) + Head::current_commit_result() .await - .ok_or_else(|| io::Error::other(format!("could not resolve {src}")))?; + .map_err(|error| io::Error::other(error.to_string()))? + } else if let Some(branch) = Branch::find_branch_result(src, None) + .await + .map_err(|error| io::Error::other(error.to_string()))? + { Some(branch.commit) } else { // [Commit Hash, e.g. a1b2c3d4] || [Wrong Branch Name] @@ -223,14 +228,15 @@ pub async fn execute_checked_typed(args: RestoreArgs) -> Result<(), RestoreError } Some(src) => { let commit = if src == HEAD { - Head::current_commit() - .await - .ok_or(RestoreError::ResolveSource)? - } else if Branch::exists(src).await { - Branch::find_branch(src, None) + Head::current_commit_result() .await + .map_err(map_restore_branch_store_error)? .ok_or(RestoreError::ResolveSource)? - .commit + } else if let Some(branch) = Branch::find_branch_result(src, None) + .await + .map_err(map_restore_branch_store_error)? + { + branch.commit } else { let objs = storage.search(src).await; if objs.len() != 1 { @@ -261,6 +267,15 @@ pub async fn execute_checked_typed(args: RestoreArgs) -> Result<(), RestoreError Ok(()) } +fn map_restore_branch_store_error(error: BranchStoreError) -> RestoreError { + match error { + BranchStoreError::Query(_) => RestoreError::ReadObject, + BranchStoreError::Corrupt { .. } => RestoreError::ReadObject, + BranchStoreError::NotFound(_) => RestoreError::ResolveSource, + BranchStoreError::Delete { .. } => RestoreError::ReadObject, + } +} + /// to HashMap /// - `blobs`: to workdir fn preprocess_blobs(blobs: &[(PathBuf, ObjectHash)]) -> HashMap { diff --git a/src/command/shortlog.rs b/src/command/shortlog.rs index a22d21cd..b5e5f761 100644 --- a/src/command/shortlog.rs +++ b/src/command/shortlog.rs @@ -59,7 +59,7 @@ use clap::Parser; use git_internal::internal::object::commit::Commit; use crate::{ - internal::{head::Head, log::date_parser::parse_date}, + internal::{branch::BranchStoreError, head::Head, log::date_parser::parse_date}, utils::{ error::{CliError, CliResult, StableErrorCode}, output::OutputConfig, @@ -258,8 +258,9 @@ async fn get_commits_for_shortlog( let head = Head::current().await; let commit_hash = match head { Head::Branch(name) => { - let branch = crate::internal::branch::Branch::find_branch(&name, None) + let branch = crate::internal::branch::Branch::find_branch_result(&name, None) .await + .map_err(shortlog_branch_store_error)? .map(|b| b.commit.to_string()); match branch { Some(h) => h, @@ -282,6 +283,17 @@ async fn get_commits_for_shortlog( Ok(commits) } +fn shortlog_branch_store_error(error: BranchStoreError) -> CliError { + match error { + BranchStoreError::Query(detail) => { + CliError::fatal(format!("failed to read branch storage: {detail}")) + .with_stable_code(StableErrorCode::IoReadFailed) + } + other => CliError::fatal(format!("failed to resolve current branch: {other}")) + .with_stable_code(StableErrorCode::RepoCorrupt), + } +} + fn passes_filter(commit: &Commit, since_ts: Option, until_ts: Option) -> bool { let commit_ts = commit.committer.timestamp as i64; diff --git a/src/command/show.rs b/src/command/show.rs index 7db7b14b..286e7ab0 100644 --- a/src/command/show.rs +++ b/src/command/show.rs @@ -21,7 +21,11 @@ use crate::{ log::{ChangeType, generate_diff, get_changed_files_for_commit}, }, common_utils::parse_commit_msg, - internal::{branch::Branch, head::Head, tag}, + internal::{ + branch::{Branch, BranchStoreError}, + head::Head, + tag, + }, utils::{ client_storage::ClientStorage, error::{CliError, CliResult, StableErrorCode}, @@ -521,7 +525,7 @@ async fn collect_commit_output( .iter() .map(ToString::to_string) .collect(), - refs: collect_reference_names(commit.id).await, + refs: collect_reference_names(commit.id).await?, files: files .into_iter() .map(|file| ShowFileChange { @@ -656,14 +660,37 @@ fn tree_item_mode_to_object_type(mode: TreeItemMode) -> &'static str { } } -async fn collect_reference_names(commit_id: ObjectHash) -> Vec { +fn show_branch_store_error(context: &str, error: BranchStoreError) -> CliError { + match error { + BranchStoreError::Query(detail) => { + CliError::fatal(format!("failed to {context}: {detail}")) + .with_stable_code(StableErrorCode::IoReadFailed) + } + other => CliError::fatal(format!("failed to {context}: {other}")) + .with_stable_code(StableErrorCode::RepoCorrupt), + } +} + +async fn collect_reference_names(commit_id: ObjectHash) -> CliResult> { let mut refs = Vec::new(); - let head_branch = match (Head::current().await, Head::current_commit().await) { - (Head::Branch(name), Some(head_commit)) if head_commit == commit_id => Some(name), - _ => None, + let head_branch = match Head::current_commit_result().await { + Ok(Some(head_commit)) => match Head::current().await { + Head::Branch(name) if head_commit == commit_id => Some(name), + _ => None, + }, + Ok(None) => None, + Err(error) => { + return Err(show_branch_store_error( + "resolve HEAD for show output", + error, + )); + } }; - for branch in Branch::list_branches(None).await { + for branch in Branch::list_branches_result(None) + .await + .map_err(|error| show_branch_store_error("list branches for show output", error))? + { if branch.commit != commit_id { continue; } @@ -692,7 +719,7 @@ async fn collect_reference_names(commit_id: ObjectHash) -> Vec { } refs.sort(); - refs + Ok(refs) } #[cfg(test)] diff --git a/src/command/show_ref.rs b/src/command/show_ref.rs index 3b31a339..45da1b4d 100644 --- a/src/command/show_ref.rs +++ b/src/command/show_ref.rs @@ -6,9 +6,13 @@ use clap::Parser; use serde::Serialize; use crate::{ - internal::{branch::Branch, head::Head, tag}, + internal::{ + branch::{Branch, BranchStoreError}, + head::Head, + tag, + }, utils::{ - error::{CliError, CliResult}, + error::{CliError, CliResult, StableErrorCode}, output::{OutputConfig, emit_json_data}, }, }; @@ -51,9 +55,7 @@ pub async fn execute(args: ShowRefArgs) -> Result<(), String> { /// errors and exiting. Lists all refs (branches, tags) with their object IDs. pub async fn execute_safe(args: ShowRefArgs, output: &OutputConfig) -> CliResult<()> { let hash_only = args.hash; - let entries = collect_show_ref_entries(&args) - .await - .map_err(CliError::failure)?; + let entries = collect_show_ref_entries(&args).await?; if output.is_json() { emit_json_data( @@ -82,7 +84,18 @@ pub async fn execute_safe(args: ShowRefArgs, output: &OutputConfig) -> CliResult } } -async fn collect_show_ref_entries(args: &ShowRefArgs) -> Result, String> { +fn show_ref_branch_store_error(context: &str, error: BranchStoreError) -> CliError { + match error { + BranchStoreError::Query(detail) => { + CliError::fatal(format!("failed to {context}: {detail}")) + .with_stable_code(StableErrorCode::IoReadFailed) + } + other => CliError::fatal(format!("failed to {context}: {other}")) + .with_stable_code(StableErrorCode::RepoCorrupt), + } +} + +async fn collect_show_ref_entries(args: &ShowRefArgs) -> CliResult> { // When neither --heads nor --tags is specified, show both let show_heads = args.heads || !args.tags; let show_tags = args.tags || !args.heads; @@ -91,7 +104,9 @@ async fn collect_show_ref_entries(args: &ShowRefArgs) -> Result Result if show_heads { - let branches = Branch::list_branches(None).await; + let branches = Branch::list_branches_result(None) + .await + .map_err(|error| show_ref_branch_store_error("list branches", error))?; for branch in branches { entries.push(ShowRefEntry { hash: branch.commit.to_string(), @@ -114,7 +131,9 @@ async fn collect_show_ref_entries(args: &ShowRefArgs) -> Result if show_tags { - let tag_list = tag::list().await.map_err(|e| e.to_string())?; + let tag_list = tag::list() + .await + .map_err(|e| CliError::failure(e.to_string()))?; for t in tag_list { // For annotated tags use the tag object hash; for lightweight use the commit hash. let hash = match &t.object { @@ -142,7 +161,7 @@ async fn collect_show_ref_entries(args: &ShowRefArgs) -> Result CliResult { }; // Resolve upstream tracking info - let upstream = resolve_upstream_info(&head, head_oid.as_ref()).await; + let upstream = resolve_upstream_info(&head, head_oid.as_ref()).await?; let porcelain_v2 = if matches!(args.porcelain, Some(PorcelainVersion::V2)) { let index = maybe_index .take() @@ -1161,33 +1165,48 @@ fn write_branch_info_v2( // Upstream tracking resolution // --------------------------------------------------------------------------- +fn status_branch_store_error(context: &str, error: BranchStoreError) -> CliError { + match error { + BranchStoreError::Query(detail) => { + CliError::fatal(format!("failed to {context}: {detail}")) + .with_stable_code(StableErrorCode::IoReadFailed) + } + other => CliError::fatal(format!("failed to {context}: {other}")) + .with_stable_code(StableErrorCode::RepoCorrupt), + } +} + async fn resolve_upstream_info( head: &Head, local_commit: Option<&ObjectHash>, -) -> Option { +) -> CliResult> { let branch_name = match head { Head::Branch(name) => name.clone(), - Head::Detached(_) => return None, + Head::Detached(_) => return Ok(None), }; - let branch_config = ConfigKv::branch_config(&branch_name).await.ok().flatten()?; + let Some(branch_config) = ConfigKv::branch_config(&branch_name).await.ok().flatten() else { + return Ok(None); + }; let remote = &branch_config.remote; let merge_branch = &branch_config.merge; let remote_ref_display = format!("{remote}/{merge_branch}"); - let tracking_branch = Branch::find_branch(merge_branch, Some(remote)).await; + let tracking_branch = Branch::find_branch_result(merge_branch, Some(remote)) + .await + .map_err(|error| status_branch_store_error("resolve upstream branch", error))?; let tracking_commit = match tracking_branch { Some(b) => b.commit, None => { // Upstream configured but tracking ref doesn't exist → gone - return Some(UpstreamInfo { + return Ok(Some(UpstreamInfo { remote_ref: remote_ref_display, ahead: None, behind: None, gone: true, - }); + })); } }; @@ -1197,23 +1216,23 @@ async fn resolve_upstream_info( // Unborn branch: no local commit to compare against. // Return None for ahead/behind — numeric counts would imply // a comparison that never happened. - return Some(UpstreamInfo { + return Ok(Some(UpstreamInfo { remote_ref: remote_ref_display, ahead: None, behind: None, gone: false, - }); + })); } }; let (ahead, behind) = compute_ahead_behind(local_commit, &tracking_commit); - Some(UpstreamInfo { + Ok(Some(UpstreamInfo { remote_ref: remote_ref_display, ahead: Some(ahead), behind: Some(behind), gone: false, - }) + })) } /// Compute the number of commits ahead/behind between two refs. diff --git a/src/command/switch.rs b/src/command/switch.rs index adb48f5f..3283ad9c 100644 --- a/src/command/switch.rs +++ b/src/command/switch.rs @@ -23,7 +23,9 @@ use crate::{ utils::{ error::{CliError, CliResult, StableErrorCode}, output::{OutputConfig, emit_json_data}, - path, util, + path, + text::levenshtein, + util, util::get_commit_base, worktree, }, @@ -216,26 +218,24 @@ impl From for CliError { } } -/// Compute the Levenshtein edit distance between two strings. -fn levenshtein(a: &str, b: &str) -> usize { - let a: Vec = a.chars().collect(); - let b: Vec = b.chars().collect(); - let (a, b) = if a.len() > b.len() { - (&b, &a) - } else { - (&a, &b) - }; - let mut prev: Vec = (0..=a.len()).collect(); - let mut curr = vec![0; a.len() + 1]; - for (i, cb) in b.iter().enumerate() { - curr[0] = i + 1; - for (j, ca) in a.iter().enumerate() { - let cost = usize::from(ca != cb); - curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1); +fn map_branch_store_error(error: repo_branch::BranchStoreError) -> SwitchError { + match error { + repo_branch::BranchStoreError::Query(detail) => SwitchError::DelegatedCli( + CliError::fatal(format!("failed to read branch storage: {detail}")) + .with_stable_code(StableErrorCode::IoReadFailed), + ), + repo_branch::BranchStoreError::Corrupt { .. } => { + repo_corrupt_switch_error(error.to_string()) } - std::mem::swap(&mut prev, &mut curr); + repo_branch::BranchStoreError::NotFound(name) => SwitchError::DelegatedCli( + CliError::fatal(format!("branch '{name}' not found")) + .with_stable_code(StableErrorCode::CliInvalidTarget), + ), + repo_branch::BranchStoreError::Delete { name, detail } => SwitchError::DelegatedCli( + CliError::fatal(format!("failed to delete branch '{name}': {detail}")) + .with_stable_code(StableErrorCode::IoWriteFailed), + ), } - prev[a.len()] } fn invalid_branch_name_error(branch_name: &str) -> CliError { @@ -267,7 +267,11 @@ async fn validate_new_branch_request( new_branch_name.to_string(), )); } - if Branch::find_branch(new_branch_name, None).await.is_some() { + if Branch::find_branch_result(new_branch_name, None) + .await + .map_err(map_branch_store_error)? + .is_some() + { return Err(SwitchError::DelegatedCli(existing_branch_conflict_error( new_branch_name, ))); @@ -329,17 +333,26 @@ async fn resolve_switch_branch_target( if is_internal_switch_target(branch_name) { return Err(SwitchError::InternalBranchBlocked(branch_name.to_string())); } - if let Some(branch) = Branch::find_branch(branch_name, None).await { + if let Some(branch) = Branch::find_branch_result(branch_name, None) + .await + .map_err(map_branch_store_error)? + { return Ok(ResolvedSwitchBranch { name: branch.name, commit: branch.commit, }); } - if !Branch::search_branch(branch_name).await.is_empty() { + if !Branch::search_branch_result(branch_name) + .await + .map_err(map_branch_store_error)? + .is_empty() + { return Err(SwitchError::GotRemoteBranch(branch_name.to_string())); } - let all_branches = Branch::list_branches(None).await; + let all_branches = Branch::list_branches_result(None) + .await + .map_err(map_branch_store_error)?; let similar = find_similar_branch_names(branch_name, &all_branches); Err(SwitchError::BranchNotFound { name: branch_name.to_string(), @@ -372,20 +385,29 @@ async fn resolve_tracked_remote_target( } let remote_tracking_ref = format!("refs/remotes/{remote_name}/{remote_branch_name}"); - let remote_tracking_branch = - if let Some(branch) = Branch::find_branch(&remote_tracking_ref, Some(&remote_name)).await { - Some(branch) - } else if let Some(branch) = Branch::find_branch(&remote_tracking_ref, None).await { - Some(branch) - } else { - Branch::find_branch(&remote_branch_name, Some(&remote_name)).await - } - .ok_or_else(|| SwitchError::RemoteBranchNotFound { - remote: remote_name.clone(), - branch: remote_branch_name.clone(), - })?; - if Branch::find_branch(&remote_branch_name, None) + let remote_tracking_branch = if let Some(branch) = + Branch::find_branch_result(&remote_tracking_ref, Some(&remote_name)) + .await + .map_err(map_branch_store_error)? + { + Some(branch) + } else if let Some(branch) = Branch::find_branch_result(&remote_tracking_ref, None) + .await + .map_err(map_branch_store_error)? + { + Some(branch) + } else { + Branch::find_branch_result(&remote_branch_name, Some(&remote_name)) + .await + .map_err(map_branch_store_error)? + } + .ok_or_else(|| SwitchError::RemoteBranchNotFound { + remote: remote_name.clone(), + branch: remote_branch_name.clone(), + })?; + if Branch::find_branch_result(&remote_branch_name, None) .await + .map_err(map_branch_store_error)? .is_some() { return Err(SwitchError::BranchAlreadyExists(remote_branch_name)); @@ -404,8 +426,9 @@ fn internal_switch_invariant(message: impl Into) -> SwitchError { } async fn resolve_created_branch(branch_name: &str) -> Result { - let branch = Branch::find_branch(branch_name, None) + let branch = Branch::find_branch_result(branch_name, None) .await + .map_err(map_branch_store_error)? .ok_or_else(|| { internal_switch_invariant(format!( "failed to resolve newly created branch '{}'", @@ -427,7 +450,9 @@ async fn resolve_create_switch_target( .await .map(Some) .map_err(|_| SwitchError::DelegatedCli(invalid_branch_base_error(target))), - None => Ok(Head::current_commit().await), + None => Head::current_commit_result() + .await + .map_err(map_branch_store_error), } } @@ -697,8 +722,9 @@ async fn switch_to_commit( ) -> Result { let db = get_db_conn_instance().await; - let old_oid = Head::current_commit_with_conn(&db) + let old_oid = Head::current_commit_result_with_conn(&db) .await + .map_err(map_branch_store_error)? .map(|oid| oid.to_string()) .unwrap_or_else(|| ObjectHash::zero_str(get_hash_kind()).to_string()); @@ -748,8 +774,9 @@ async fn switch_to_resolved_branch( } = target_branch; let db = get_db_conn_instance().await; - let old_oid = Head::current_commit_with_conn(&db) + let old_oid = Head::current_commit_result_with_conn(&db) .await + .map_err(map_branch_store_error)? .map(|oid| oid.to_string()) .unwrap_or_else(|| ObjectHash::zero_str(get_hash_kind()).to_string()); @@ -842,7 +869,13 @@ async fn current_switch_state() -> (Option, Option) { Head::Branch(name) => Some(name), Head::Detached(_) => None, }; - let commit = Head::current_commit().await.map(|hash| hash.to_string()); + let commit = match Head::current_commit_result().await { + Ok(commit) => commit.map(|hash| hash.to_string()), + Err(error) => { + tracing::error!("failed to resolve current switch state: {error}"); + None + } + }; (branch, commit) } diff --git a/src/command/tag.rs b/src/command/tag.rs index db9fa8e2..166a3425 100644 --- a/src/command/tag.rs +++ b/src/command/tag.rs @@ -8,6 +8,7 @@ use crate::{ utils::{ error::{CliError, CliResult, StableErrorCode}, output::{OutputConfig, emit_json_data}, + text::short_display_hash, util, }, }; @@ -269,12 +270,12 @@ fn render_tag_output(result: &TagOutput, output: &OutputConfig) -> CliResult<()> } => { println!( "Created {tag_type} tag '{name}' at {}", - abbreviate_hash(hash) + short_display_hash(hash) ); } TagOutput::Delete { name, hash } => { if let Some(hash) = hash { - println!("Deleted tag '{name}' (was {})", abbreviate_hash(hash)); + println!("Deleted tag '{name}' (was {})", short_display_hash(hash)); } else { println!("Deleted tag '{name}'"); } @@ -284,12 +285,6 @@ fn render_tag_output(result: &TagOutput, output: &OutputConfig) -> CliResult<()> Ok(()) } -fn abbreviate_hash(hash: &str) -> &str { - const SHORT_HASH_LEN: usize = 7; - let end = hash.len().min(SHORT_HASH_LEN); - &hash[..end] -} - pub async fn render_tags(show_lines: usize) -> Result { let tags = collect_tags(show_lines) .await diff --git a/src/internal/branch.rs b/src/internal/branch.rs index d24269f2..a22de1a2 100644 --- a/src/internal/branch.rs +++ b/src/internal/branch.rs @@ -239,6 +239,25 @@ impl Branch { // `_with_conn` version for `search_branch` pub async fn search_branch_with_conn(db: &C, branch_name: &str) -> Vec + where + C: ConnectionTrait, + { + match Self::search_branch_result_with_conn(db, branch_name).await { + Ok(branches) => branches, + Err(error) => { + log_branch_store_error( + &format!("failed to search branches matching '{branch_name}'"), + &error, + ); + Vec::new() + } + } + } + + pub async fn search_branch_result_with_conn( + db: &C, + branch_name: &str, + ) -> Result, BranchStoreError> where C: ConnectionTrait, { @@ -246,7 +265,8 @@ impl Branch { let mut remote = String::new(); let mut branches = vec![]; - if let Some(branch) = Self::find_branch_with_conn(db, &branch_name_str, None).await { + if let Some(branch) = Self::find_branch_result_with_conn(db, &branch_name_str, None).await? + { branches.push(branch) } @@ -256,13 +276,13 @@ impl Branch { } remote += branch_name_str.get(..index).unwrap(); branch_name_str = branch_name_str.get(index + 1..).unwrap().to_string(); - // Important: Call the `_with_conn` variant inside the loop - let branch = Self::find_branch_with_conn(db, &branch_name_str, Some(&remote)).await; - if let Some(branch) = branch { + if let Some(branch) = + Self::find_branch_result_with_conn(db, &branch_name_str, Some(&remote)).await? + { branches.push(branch); } } - branches + Ok(branches) } /// search branch with full name, return vec of branches @@ -273,6 +293,11 @@ impl Branch { Self::search_branch_with_conn(&db_conn, branch_name).await } + pub async fn search_branch_result(branch_name: &str) -> Result, BranchStoreError> { + let db_conn = get_db_conn_instance().await; + Self::search_branch_result_with_conn(&db_conn, branch_name).await + } + // `_with_conn` version for `update_branch` pub async fn update_branch_with_conn( db: &C, diff --git a/src/internal/head.rs b/src/internal/head.rs index f199ffe6..f4dd8a2e 100644 --- a/src/internal/head.rs +++ b/src/internal/head.rs @@ -9,7 +9,11 @@ use sea_orm::{ }; use tokio::time::sleep; -use crate::internal::{branch::Branch, db::get_db_conn_instance, model::reference}; +use crate::internal::{ + branch::{Branch, BranchStoreError}, + db::get_db_conn_instance, + model::reference, +}; #[derive(Debug, Clone)] pub enum Head { @@ -151,15 +155,34 @@ impl Head { Self::remote_current_with_conn(&db_conn, remote).await } - pub async fn current_commit_with_conn(db: &C) -> Option + pub async fn current_commit_result_with_conn( + db: &C, + ) -> Result, BranchStoreError> where C: ConnectionTrait, { match Self::current_with_conn(db).await { - Head::Detached(commit_hash) => Some(commit_hash), - Head::Branch(name) => { - let branch = Branch::find_branch_with_conn(db, &name, None).await; - branch.map(|b| b.commit) + Head::Detached(commit_hash) => Ok(Some(commit_hash)), + Head::Branch(name) => Ok(Branch::find_branch_result_with_conn(db, &name, None) + .await? + .map(|branch| branch.commit)), + } + } + + pub async fn current_commit_result() -> Result, BranchStoreError> { + let db_conn = get_db_conn_instance().await; + Self::current_commit_result_with_conn(&db_conn).await + } + + pub async fn current_commit_with_conn(db: &C) -> Option + where + C: ConnectionTrait, + { + match Self::current_commit_result_with_conn(db).await { + Ok(commit) => commit, + Err(error) => { + tracing::error!("failed to resolve HEAD commit: {error}"); + None } } } diff --git a/src/internal/protocol/local_client.rs b/src/internal/protocol/local_client.rs index 011b1537..986d7ddb 100644 --- a/src/internal/protocol/local_client.rs +++ b/src/internal/protocol/local_client.rs @@ -159,29 +159,37 @@ impl LocalClient { RepoType::LibraRepo => { let original_dir = cur_dir(); env::set_current_dir(&self.repo_path)?; - let local_branches = Branch::list_branches(None).await; + let local_branches = Branch::list_branches_result(None) + .await + .map_err(|error| GitError::CustomError(error.to_string()))?; - let remote_configs = ConfigKv::all_remote_configs().await.unwrap_or_default(); + let remote_configs = ConfigKv::all_remote_configs() + .await + .map_err(|error| GitError::CustomError(error.to_string()))?; let mut remote_branches: Vec<_> = vec![]; for remote in remote_configs { - remote_branches.extend(Branch::list_branches(Some(&remote.name)).await); + remote_branches.extend( + Branch::list_branches_result(Some(&remote.name)) + .await + .map_err(|error| GitError::CustomError(error.to_string()))?, + ); } - let result = - DiscoveryResult { - refs: local_branches - .into_iter() - .chain(remote_branches) - .map(Into::into) - .chain(Head::current_commit().await.map(|x| x.to_string()).map( - |hash| DiscRef { - _hash: hash, - _ref: reflog::HEAD.to_string(), - }, - )) - .collect::>(), - capabilities: vec![], - hash_kind: get_hash_kind(), - }; + let head_commit = Head::current_commit_result() + .await + .map_err(|error| GitError::CustomError(error.to_string()))?; + let result = DiscoveryResult { + refs: local_branches + .into_iter() + .chain(remote_branches) + .map(Into::into) + .chain(head_commit.map(|x| x.to_string()).map(|hash| DiscRef { + _hash: hash, + _ref: reflog::HEAD.to_string(), + })) + .collect::>(), + capabilities: vec![], + hash_kind: get_hash_kind(), + }; env::set_current_dir(original_dir)?; Ok(result) } diff --git a/src/utils/convert.rs b/src/utils/convert.rs index dabc8cf8..43c0e4dd 100644 --- a/src/utils/convert.rs +++ b/src/utils/convert.rs @@ -55,7 +55,13 @@ pub async fn convert_from_git_repository( message: error.to_string(), })?; - let remote_branches = Branch::list_branches(Some(&remote.name)).await; + let remote_branches = Branch::list_branches_result(Some(&remote.name)) + .await + .map_err(|error| crate::command::init::InitError::ConversionFailed { + repo: git_dir.clone(), + stage: "setup", + message: format!("failed to inspect fetched branches: {error}"), + })?; if remote_branches.is_empty() { return Err(crate::command::init::InitError::ConversionFailed { repo: git_dir.clone(), diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 18305541..667cdb7e 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -16,6 +16,7 @@ pub mod path_ext; pub mod storage; pub mod storage_ext; pub mod test; +pub mod text; pub mod tree; pub mod util; pub mod worktree; diff --git a/src/utils/text.rs b/src/utils/text.rs new file mode 100644 index 00000000..4f48aab8 --- /dev/null +++ b/src/utils/text.rs @@ -0,0 +1,65 @@ +//! Shared text helpers for safe abbreviated display and fuzzy matching. + +/// Default short hash width used in human-readable confirmations. +pub const SHORT_HASH_LEN: usize = 7; + +/// Return a shortened display form of a hash-like string without assuming ASCII. +pub fn short_display_hash(hash: &str) -> &str { + if hash.chars().count() <= SHORT_HASH_LEN { + return hash; + } + + let byte_idx = hash + .char_indices() + .nth(SHORT_HASH_LEN) + .map(|(idx, _)| idx) + .unwrap_or(hash.len()); + + hash.get(..byte_idx).unwrap_or(hash) +} + +/// Compute the Levenshtein edit distance between two strings. +pub fn levenshtein(a: &str, b: &str) -> usize { + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + let (a, b) = if a.len() > b.len() { + (&b, &a) + } else { + (&a, &b) + }; + let mut prev: Vec = (0..=a.len()).collect(); + let mut curr = vec![0; a.len() + 1]; + for (i, cb) in b.iter().enumerate() { + curr[0] = i + 1; + for (j, ca) in a.iter().enumerate() { + let cost = usize::from(ca != cb); + curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1); + } + std::mem::swap(&mut prev, &mut curr); + } + prev[a.len()] +} + +#[cfg(test)] +mod tests { + use super::{levenshtein, short_display_hash}; + + #[test] + fn short_display_hash_keeps_ascii_prefix() { + assert_eq!(short_display_hash("1234567890"), "1234567"); + } + + #[test] + fn short_display_hash_respects_utf8_boundaries() { + assert_eq!(short_display_hash("éééééééé"), "ééééééé"); + } + + #[test] + fn levenshtein_handles_basic_edge_cases() { + assert_eq!(levenshtein("", ""), 0); + assert_eq!(levenshtein("", "abc"), 3); + assert_eq!(levenshtein("abc", ""), 3); + assert_eq!(levenshtein("main", "maim"), 1); + assert_eq!(levenshtein("feature", "featur"), 1); + } +} diff --git a/src/utils/util.rs b/src/utils/util.rs index 0585e80c..eb33bc81 100644 --- a/src/utils/util.rs +++ b/src/utils/util.rs @@ -465,7 +465,10 @@ pub fn path_to_string(path: &Path) -> String { pub async fn get_commit_base(name: &str) -> Result { // 1. Check for HEAD if name.to_uppercase() == "HEAD" { - if let Some(commit_id) = Head::current_commit().await { + if let Some(commit_id) = Head::current_commit_result() + .await + .map_err(|error| format!("fatal: failed to resolve HEAD: {error}"))? + { return Ok(commit_id); } else { return Err("fatal: HEAD does not point to a commit".to_string()); @@ -473,7 +476,10 @@ pub async fn get_commit_base(name: &str) -> Result { } // 2. Check for a local branch - if let Some(branch) = Branch::find_branch(name, None).await { + if let Some(branch) = Branch::find_branch_result(name, None) + .await + .map_err(|error| format!("fatal: failed to resolve branch '{name}': {error}"))? + { return Ok(branch.commit); } @@ -481,7 +487,11 @@ pub async fn get_commit_base(name: &str) -> Result { if let Some((remote, branch_name)) = name.split_once('/') && !remote.is_empty() && !branch_name.is_empty() - && let Some(branch) = Branch::find_branch(branch_name, Some(remote)).await + && let Some(branch) = Branch::find_branch_result(branch_name, Some(remote)) + .await + .map_err(|error| { + format!("fatal: failed to resolve remote branch '{remote}/{branch_name}': {error}") + })? { return Ok(branch.commit); } diff --git a/tests/command/branch_test.rs b/tests/command/branch_test.rs index fbd8aa54..85fafeef 100644 --- a/tests/command/branch_test.rs +++ b/tests/command/branch_test.rs @@ -116,6 +116,24 @@ fn test_branch_set_upstream_surfaces_config_write_failure() { ); } +#[cfg(unix)] +#[test] +fn test_branch_set_upstream_idempotent_path_skips_redundant_write() { + let repo = create_committed_repo_via_cli(); + + let first = run_libra_command(&["branch", "--set-upstream-to", "origin/main"], repo.path()); + assert_cli_success(&first, "initial set-upstream"); + + let db_path = repo.path().join(".libra").join("libra.db"); + let original_mode = fs::metadata(&db_path).unwrap().permissions().mode(); + + fs::set_permissions(&db_path, std::fs::Permissions::from_mode(0o444)).unwrap(); + let second = run_libra_command(&["branch", "--set-upstream-to", "origin/main"], repo.path()); + fs::set_permissions(&db_path, std::fs::Permissions::from_mode(original_mode)).unwrap(); + + assert_cli_success(&second, "idempotent set-upstream"); +} + #[test] fn test_branch_force_delete_outputs_confirmation() { let repo = create_committed_repo_via_cli(); diff --git a/tests/command/remote_test.rs b/tests/command/remote_test.rs index 3a691732..b3effdc1 100644 --- a/tests/command/remote_test.rs +++ b/tests/command/remote_test.rs @@ -2,6 +2,8 @@ //! //! **Layer:** L1 — deterministic, no external dependencies. +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use std::{fs, process::Command}; use libra::{ @@ -752,3 +754,133 @@ async fn test_remote_prune_nonexistent_remote_returns_error() { // The command should fail gracefully (error is printed to stderr, not returned) // We can't easily test stderr output, but we can verify it doesn't panic } + +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn test_remote_prune_does_not_report_success_when_delete_fails() { + let temp_root = tempdir().unwrap(); + let remote_dir = temp_root.path().join("remote.git"); + let work_dir = temp_root.path().join("workdir"); + let repo_dir = temp_root.path().join("libra_repo"); + + assert!( + Command::new("git") + .args(["init", "--bare", remote_dir.to_str().unwrap()]) + .status() + .unwrap() + .success() + ); + assert!( + Command::new("git") + .args(["init", work_dir.to_str().unwrap()]) + .status() + .unwrap() + .success() + ); + assert!( + Command::new("git") + .current_dir(&work_dir) + .args(["config", "user.name", "Libra Tester"]) + .status() + .unwrap() + .success() + ); + assert!( + Command::new("git") + .current_dir(&work_dir) + .args(["config", "user.email", "tester@example.com"]) + .status() + .unwrap() + .success() + ); + fs::write(work_dir.join("README.md"), "hello libra").unwrap(); + assert!( + Command::new("git") + .current_dir(&work_dir) + .args(["add", "README.md"]) + .status() + .unwrap() + .success() + ); + assert!( + Command::new("git") + .current_dir(&work_dir) + .args(["commit", "-m", "initial commit"]) + .status() + .unwrap() + .success() + ); + assert!( + Command::new("git") + .current_dir(&work_dir) + .args(["remote", "add", "origin", remote_dir.to_str().unwrap()]) + .status() + .unwrap() + .success() + ); + assert!( + Command::new("git") + .current_dir(&work_dir) + .args(["checkout", "-b", "stale_branch"]) + .status() + .unwrap() + .success() + ); + assert!( + Command::new("git") + .current_dir(&work_dir) + .args(["push", "origin", "stale_branch"]) + .status() + .unwrap() + .success() + ); + fs::create_dir_all(&repo_dir).unwrap(); + init_repo_via_cli(&repo_dir); + + let remote_path = remote_dir.to_str().unwrap().to_string(); + let add_remote = run_libra_command(&["remote", "add", "origin", &remote_path], &repo_dir); + assert_cli_success(&add_remote, "remote add origin"); + + let fetch_output = run_libra_command(&["fetch", "origin"], &repo_dir); + assert_cli_success(&fetch_output, "fetch origin"); + + let tracked_branch = "refs/remotes/origin/stale_branch"; + { + let _guard = test::ChangeDirGuard::new(&repo_dir); + assert!( + Branch::find_branch(tracked_branch, Some("origin")) + .await + .is_some(), + "expected stale remote-tracking branch to exist before prune" + ); + } + + assert!( + Command::new("git") + .current_dir(remote_dir.to_str().unwrap()) + .args(["update-ref", "-d", "refs/heads/stale_branch"]) + .status() + .unwrap() + .success() + ); + + let db_path = repo_dir.join(".libra").join("libra.db"); + let original_mode = fs::metadata(&db_path).unwrap().permissions().mode(); + fs::set_permissions(&db_path, std::fs::Permissions::from_mode(0o444)).unwrap(); + let output = run_libra_command(&["remote", "prune", "origin"], &repo_dir); + fs::set_permissions(&db_path, std::fs::Permissions::from_mode(original_mode)).unwrap(); + + let stdout = String::from_utf8_lossy(&output.stdout); + let (stderr, report) = parse_cli_error_stderr(&output.stderr); + assert_eq!(output.status.code(), Some(128)); + assert_eq!(report.error_code, "LBR-IO-002"); + assert!( + !stdout.contains("[pruned] origin/stale_branch"), + "prune should not report success when deletion fails: {stdout}" + ); + assert!( + stderr.contains("failed to delete branch"), + "unexpected stderr: {stderr}" + ); +} diff --git a/tests/command/show_ref_test.rs b/tests/command/show_ref_test.rs index 0d043a4f..cb4b3191 100644 --- a/tests/command/show_ref_test.rs +++ b/tests/command/show_ref_test.rs @@ -4,7 +4,8 @@ use std::{fs, io::Write, process::Command}; -use libra::internal::branch::Branch; +use libra::internal::{branch::Branch, db::get_db_conn_instance, model::reference}; +use sea_orm::{ActiveModelTrait, Set}; use serial_test::serial; use tempfile::tempdir; @@ -87,6 +88,39 @@ async fn test_show_ref_lists_branch() { ); } +#[tokio::test] +#[serial] +async fn test_show_ref_surfaces_corrupt_branch_storage() { + let temp = tempdir().unwrap(); + let _guard = setup_repo_with_commit(&temp).await; + let db = get_db_conn_instance().await; + reference::ActiveModel { + name: Set(Some("broken".to_string())), + kind: Set(reference::ConfigKind::Branch), + commit: Set(Some("not-a-valid-hash".to_string())), + remote: Set(None), + ..Default::default() + } + .insert(&db) + .await + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_libra")) + .current_dir(temp.path()) + .arg("show-ref") + .arg("--heads") + .output() + .unwrap(); + + let (stderr, report) = parse_cli_error_stderr(&output.stderr); + assert_eq!(output.status.code(), Some(128)); + assert_eq!(report.error_code, "LBR-REPO-002"); + assert!( + stderr.contains("stored branch reference 'broken' is corrupt"), + "unexpected stderr: {stderr}" + ); +} + /// show-ref --tags should list tags after creating one. #[tokio::test] #[serial] From 9c61191447ad09af115c56e297369169bb395efe Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Thu, 2 Apr 2026 02:53:15 +0800 Subject: [PATCH 04/14] fix(head-review): propagate fallible branch and cwd handling Signed-off-by: Quanyi Ma --- src/command/branch.rs | 65 +++-- src/command/reset.rs | 30 +- src/internal/protocol/local_client.rs | 386 ++++++++++++++------------ src/utils/util.rs | 28 +- tests/command/branch_test.rs | 104 +++++++ tests/command/reset_test.rs | 38 +++ tests/local_client_test.rs | 52 ++++ 7 files changed, 496 insertions(+), 207 deletions(-) create mode 100644 tests/local_client_test.rs diff --git a/src/command/branch.rs b/src/command/branch.rs index 408d45ca..53e81e69 100644 --- a/src/command/branch.rs +++ b/src/command/branch.rs @@ -328,6 +328,18 @@ fn map_branch_store_error(error: branch::BranchStoreError) -> BranchError { } } +fn map_head_commit_store_error(error: branch::BranchStoreError) -> BranchError { + let cli_error = match error { + branch::BranchStoreError::Query(detail) => { + CliError::fatal(format!("failed to resolve HEAD commit: {detail}")) + .with_stable_code(StableErrorCode::IoReadFailed) + } + other => CliError::fatal(format!("failed to resolve HEAD commit: {other}")) + .with_stable_code(StableErrorCode::RepoCorrupt), + }; + BranchError::DelegatedCli(cli_error) +} + fn find_similar_branch_names(branch_name: &str, branches: &[Branch]) -> Vec { let target_len = branch_name.chars().count(); let mut best: Option<(usize, String)> = None; @@ -358,15 +370,14 @@ fn find_similar_branch_names(branch_name: &str, branches: &[Branch]) -> Vec Result { - let similar = Branch::list_branches_result(None) - .await - .map(|branches| find_similar_branch_names(branch_name, &branches)) - .map_err(map_branch_store_error)?; - Ok(BranchError::NotFound { - name: branch_name.to_string(), - similar, - }) +async fn branch_not_found_error(branch_name: &str) -> BranchError { + match Branch::list_branches_result(None).await { + Ok(branches) => BranchError::NotFound { + name: branch_name.to_string(), + similar: find_similar_branch_names(branch_name, &branches), + }, + Err(error) => map_branch_store_error(error), + } } async fn require_existing_local_branch(branch_name: &str) -> Result { @@ -375,7 +386,7 @@ async fn require_existing_local_branch(branch_name: &str) -> Result Ok(branch), - None => Err(branch_not_found_error(branch_name).await?), + None => Err(branch_not_found_error(branch_name).await), } } @@ -480,7 +491,10 @@ async fn create_branch_impl( .await .map_err(|_| BranchError::InvalidCommit(branch_or_commit))?, None => { - if let Some(commit_id) = Head::current_commit().await { + if let Some(commit_id) = Head::current_commit_result() + .await + .map_err(map_head_commit_store_error)? + { commit_id } else { let current = match Head::current().await { @@ -530,12 +544,15 @@ async fn delete_branch_impl(branch_name: String, force: bool) -> Result Head::current_commit().await.ok_or_else(|| { - BranchError::DelegatedCli( - CliError::fatal("cannot get HEAD commit") - .with_stable_code(StableErrorCode::RepoStateInvalid), - ) - })?, + Head::Branch(_) => Head::current_commit_result() + .await + .map_err(map_head_commit_store_error)? + .ok_or_else(|| { + BranchError::DelegatedCli( + CliError::fatal("cannot get HEAD commit") + .with_stable_code(StableErrorCode::RepoStateInvalid), + ) + })?, Head::Detached(commit_hash) => commit_hash, }; @@ -1017,7 +1034,11 @@ mod tests { use git_internal::hash::{ObjectHash, get_hash_kind}; use sea_orm::Database; - use super::{Branch, BranchError, format_branch_name, load_remote_branches_with_conn}; + use super::{ + Branch, BranchError, format_branch_name, load_remote_branches_with_conn, + map_head_commit_store_error, + }; + use crate::utils::error::{CliError, StableErrorCode}; fn any_hash() -> ObjectHash { ObjectHash::from_str(&ObjectHash::zero_str(get_hash_kind())).unwrap() @@ -1060,4 +1081,12 @@ mod tests { other => panic!("expected config read failure, got {other:?}"), } } + + #[test] + fn test_head_commit_query_error_maps_to_io_read_failed() { + let cli_error = CliError::from(map_head_commit_store_error( + crate::internal::branch::BranchStoreError::Query("database is locked".into()), + )); + assert_eq!(cli_error.stable_code(), StableErrorCode::IoReadFailed); + } } diff --git a/src/command/reset.rs b/src/command/reset.rs index 9f6225ac..782149b1 100644 --- a/src/command/reset.rs +++ b/src/command/reset.rs @@ -20,7 +20,7 @@ use crate::{ command::{get_target_commit, load_object}, common_utils::parse_commit_msg, internal::{ - branch::Branch, + branch::{self, Branch}, db::get_db_conn_instance, head::Head, reflog::{ReflogAction, ReflogContext, with_reflog}, @@ -140,6 +140,12 @@ enum ResetError { #[error("Cannot reset: HEAD is unborn and points to no commit.")] HeadUnborn, + #[error("failed to resolve HEAD commit: {0}")] + HeadRead(String), + + #[error("stored HEAD reference is corrupt: {0}")] + HeadCorrupt(String), + #[error("failed to load {kind} '{object_id}': {detail}")] ObjectLoad { kind: &'static str, @@ -185,6 +191,12 @@ impl From for CliError { ResetError::HeadUnborn => CliError::fatal(error.to_string()) .with_stable_code(StableErrorCode::RepoStateInvalid) .with_hint("create a commit first before resetting HEAD."), + ResetError::HeadRead(_) => CliError::fatal(error.to_string()) + .with_stable_code(StableErrorCode::IoReadFailed) + .with_hint("check whether the repository database is readable."), + ResetError::HeadCorrupt(_) => CliError::fatal(error.to_string()) + .with_stable_code(StableErrorCode::RepoCorrupt) + .with_hint("the HEAD reference or branch metadata may be corrupted."), ResetError::ObjectLoad { .. } => CliError::fatal(error.to_string()) .with_stable_code(StableErrorCode::RepoCorrupt) .with_hint("the object store may be corrupted."), @@ -245,6 +257,13 @@ fn object_load_error( } } +fn map_reset_head_commit_error(error: branch::BranchStoreError) -> ResetError { + match error { + branch::BranchStoreError::Query(detail) => ResetError::HeadRead(detail), + other => ResetError::HeadCorrupt(other.to_string()), + } +} + async fn run_reset(args: ResetArgs) -> Result { util::require_repo().map_err(|_| ResetError::NotInRepo)?; @@ -368,8 +387,9 @@ async fn perform_reset( ) -> Result { // avoids holding the transaction open while doing read-only preparations. let db = get_db_conn_instance().await; - let old_oid = Head::current_commit_with_conn(&db) + let old_oid = Head::current_commit_result_with_conn(&db) .await + .map_err(map_reset_head_commit_error)? .ok_or(ResetError::HeadUnborn)?; let current_head_state = if old_oid != target_commit_id { Some(Head::current_with_conn(&db).await) @@ -952,6 +972,12 @@ mod tests { assert_eq!(error.stable_code(), StableErrorCode::RepoStateInvalid); } + #[test] + fn test_reset_error_maps_head_read_failures_as_io_read() { + let error = CliError::from(ResetError::HeadRead("database is locked".into())); + assert_eq!(error.stable_code(), StableErrorCode::IoReadFailed); + } + #[test] fn test_reset_error_maps_file_read_failures_as_io_read() { let error = CliError::from(ResetError::WorktreeRead( diff --git a/src/internal/protocol/local_client.rs b/src/internal/protocol/local_client.rs index 986d7ddb..ddcae0cc 100644 --- a/src/internal/protocol/local_client.rs +++ b/src/internal/protocol/local_client.rs @@ -3,6 +3,7 @@ use std::{ collections::HashSet, env, + future::Future, io::Error as IoError, path::{Path, PathBuf}, }; @@ -69,6 +70,34 @@ impl ProtocolClient for LocalClient { } impl LocalClient { + async fn with_repo_current_dir(&self, operation: F) -> Result + where + E: From, + F: FnOnce() -> Fut, + Fut: Future>, + { + let original_dir = cur_dir(); + env::set_current_dir(&self.repo_path).map_err(E::from)?; + + match operation().await { + Ok(value) => { + env::set_current_dir(&original_dir).map_err(E::from)?; + Ok(value) + } + Err(error) => { + if let Err(restore_error) = env::set_current_dir(&original_dir) { + tracing::error!( + repo_path = %self.repo_path.display(), + restore_dir = %original_dir.display(), + error = %restore_error, + "failed to restore working directory after local protocol operation" + ); + } + Err(error) + } + } + } + pub fn from_path(path: impl AsRef) -> Result { let path = path.as_ref(); let absolute = if path.is_absolute() { @@ -157,41 +186,40 @@ impl LocalClient { parse_discovered_references(bytes, service) } RepoType::LibraRepo => { - let original_dir = cur_dir(); - env::set_current_dir(&self.repo_path)?; - let local_branches = Branch::list_branches_result(None) - .await - .map_err(|error| GitError::CustomError(error.to_string()))?; - - let remote_configs = ConfigKv::all_remote_configs() - .await - .map_err(|error| GitError::CustomError(error.to_string()))?; - let mut remote_branches: Vec<_> = vec![]; - for remote in remote_configs { - remote_branches.extend( - Branch::list_branches_result(Some(&remote.name)) - .await - .map_err(|error| GitError::CustomError(error.to_string()))?, - ); - } - let head_commit = Head::current_commit_result() - .await - .map_err(|error| GitError::CustomError(error.to_string()))?; - let result = DiscoveryResult { - refs: local_branches - .into_iter() - .chain(remote_branches) - .map(Into::into) - .chain(head_commit.map(|x| x.to_string()).map(|hash| DiscRef { - _hash: hash, - _ref: reflog::HEAD.to_string(), - })) - .collect::>(), - capabilities: vec![], - hash_kind: get_hash_kind(), - }; - env::set_current_dir(original_dir)?; - Ok(result) + self.with_repo_current_dir(|| async { + let local_branches = Branch::list_branches_result(None) + .await + .map_err(|error| GitError::CustomError(error.to_string()))?; + + let remote_configs = ConfigKv::all_remote_configs() + .await + .map_err(|error| GitError::CustomError(error.to_string()))?; + let mut remote_branches: Vec<_> = vec![]; + for remote in remote_configs { + remote_branches.extend( + Branch::list_branches_result(Some(&remote.name)) + .await + .map_err(|error| GitError::CustomError(error.to_string()))?, + ); + } + let head_commit = Head::current_commit_result() + .await + .map_err(|error| GitError::CustomError(error.to_string()))?; + Ok(DiscoveryResult { + refs: local_branches + .into_iter() + .chain(remote_branches) + .map(Into::into) + .chain(head_commit.map(|x| x.to_string()).map(|hash| DiscRef { + _hash: hash, + _ref: reflog::HEAD.to_string(), + })) + .collect::>(), + capabilities: vec![], + hash_kind: get_hash_kind(), + }) + }) + .await } } } @@ -237,171 +265,169 @@ impl LocalClient { Ok(stream::once(async move { Ok(stdout) }).boxed()) } RepoType::LibraRepo => { - let original_dir = cur_dir(); - env::set_current_dir(&self.repo_path)?; - - let mut seen = HashSet::new(); - have.iter().for_each(|hash| { - seen.insert(hash.clone()); - }); - - let commits = stream::iter(want) - .then(|branch_hash| async move { - // TODO: `unwrap_or_default` silently swallows storage - // errors. Propagate once the surrounding pipeline - // supports fallible streams. - get_reachable_commits(branch_hash.to_string(), depth) - .await - .unwrap_or_else(|e| { - tracing::warn!( - %branch_hash, - error = %e, - "failed to walk reachable commits; treating as empty" - ); - Vec::new() - }) - }) - .flat_map(stream::iter) - .collect::>() - .await - .into_iter() - .filter(|c| seen.insert(c.id.to_string())) - .collect::>(); - - let (tree_hash, blob_hash): (Vec<_>, Vec<_>) = commits - .iter() - .map(|commit| &commit.tree_id) - .map(load_object::) - .collect::, _>>() - .map_err(|giterror| match giterror { - GitError::IOError(io_error) => io_error, - _ => IoError::other(format!("{}", giterror)), - })? - .into_iter() - .flat_map(|t| { - t.get_items_with_mode() - .into_iter() - .map(|(_, hash, mode)| (hash, mode)) - }) - .filter(|(hash, _)| seen.insert(hash.to_string())) - .partition(|(_, mode)| *mode == TreeItemMode::Tree); - - let trees = tree_hash - .into_iter() - .map(|(hash, _)| load_object::(&hash)) - .collect::, _>>() - .map_err(|giterror| match giterror { - GitError::IOError(io_error) => io_error, - _ => IoError::other(format!("{}", giterror)), - })?; - - let blobs = blob_hash - .into_iter() - .map(|(hash, _)| load_object::(&hash)) - .collect::, _>>() - .map_err(|giterror| match giterror { - GitError::IOError(io_error) => io_error, - _ => IoError::other(format!("{}", giterror)), - })?; + self.with_repo_current_dir(|| async { + let mut seen = HashSet::new(); + have.iter().for_each(|hash| { + seen.insert(hash.clone()); + }); + + let commits = stream::iter(want) + .then(|branch_hash| async move { + // TODO: `unwrap_or_default` silently swallows storage + // errors. Propagate once the surrounding pipeline + // supports fallible streams. + get_reachable_commits(branch_hash.to_string(), depth) + .await + .unwrap_or_else(|e| { + tracing::warn!( + %branch_hash, + error = %e, + "failed to walk reachable commits; treating as empty" + ); + Vec::new() + }) + }) + .flat_map(stream::iter) + .collect::>() + .await + .into_iter() + .filter(|c| seen.insert(c.id.to_string())) + .collect::>(); + + let (tree_hash, blob_hash): (Vec<_>, Vec<_>) = commits + .iter() + .map(|commit| &commit.tree_id) + .map(load_object::) + .collect::, _>>() + .map_err(|giterror| match giterror { + GitError::IOError(io_error) => io_error, + _ => IoError::other(format!("{}", giterror)), + })? + .into_iter() + .flat_map(|t| { + t.get_items_with_mode() + .into_iter() + .map(|(_, hash, mode)| (hash, mode)) + }) + .filter(|(hash, _)| seen.insert(hash.to_string())) + .partition(|(_, mode)| *mode == TreeItemMode::Tree); + + let trees = tree_hash + .into_iter() + .map(|(hash, _)| load_object::(&hash)) + .collect::, _>>() + .map_err(|giterror| match giterror { + GitError::IOError(io_error) => io_error, + _ => IoError::other(format!("{}", giterror)), + })?; + + let blobs = blob_hash + .into_iter() + .map(|(hash, _)| load_object::(&hash)) + .collect::, _>>() + .map_err(|giterror| match giterror { + GitError::IOError(io_error) => io_error, + _ => IoError::other(format!("{}", giterror)), + })?; - let commit_entries: Vec = commits.into_iter().map(Entry::from).collect(); + let commit_entries: Vec = commits.into_iter().map(Entry::from).collect(); - let tree_entries: Vec = trees.into_iter().map(Entry::from).collect(); + let tree_entries: Vec = trees.into_iter().map(Entry::from).collect(); - let blob_entries: Vec = blobs.into_iter().map(Entry::from).collect(); + let blob_entries: Vec = blobs.into_iter().map(Entry::from).collect(); - let mut all_entries = Vec::new(); - all_entries.extend(commit_entries); - all_entries.extend(tree_entries); - all_entries.extend(blob_entries); + let mut all_entries = Vec::new(); + all_entries.extend(commit_entries); + all_entries.extend(tree_entries); + all_entries.extend(blob_entries); - let (entry_tx, entry_rx) = - tokio::sync::mpsc::channel::>(1_000); - let (stream_tx, mut stream_rx) = tokio::sync::mpsc::channel(1_000); + let (entry_tx, entry_rx) = + tokio::sync::mpsc::channel::>(1_000); + let (stream_tx, mut stream_rx) = tokio::sync::mpsc::channel(1_000); - let total_objects = all_entries.len(); - let window_size = 0; + let total_objects = all_entries.len(); + let window_size = 0; - let encoder = PackEncoder::new(total_objects, window_size, stream_tx); + let encoder = PackEncoder::new(total_objects, window_size, stream_tx); - let encode_handle = encoder - .encode_async(entry_rx) - .await - .map_err(|e| IoError::other(format!("Failed to start encoding: {}", e)))?; + let encode_handle = encoder + .encode_async(entry_rx) + .await + .map_err(|e| IoError::other(format!("Failed to start encoding: {}", e)))?; - for entry in all_entries { - let entry_meta = EntryMeta::default(); - let meta_entry = MetaAttached { - inner: entry, - meta: entry_meta, - }; + for entry in all_entries { + let entry_meta = EntryMeta::default(); + let meta_entry = MetaAttached { + inner: entry, + meta: entry_meta, + }; - if let Err(e) = entry_tx.send(meta_entry).await { - return Err(IoError::other(format!("Failed to send entry: {}", e))); + if let Err(e) = entry_tx.send(meta_entry).await { + return Err(IoError::other(format!("Failed to send entry: {}", e))); + } } - } - - drop(entry_tx); - let mut pack_data = Vec::new(); - while let Some(chunk) = stream_rx.recv().await { - pack_data.extend(chunk); - } + drop(entry_tx); - encode_handle - .await - .map_err(|e| IoError::other(format!("Encode task panicked: {}", e)))?; + let mut pack_data = Vec::new(); + while let Some(chunk) = stream_rx.recv().await { + pack_data.extend(chunk); + } - if pack_data.len() < 12 { - return Err(IoError::other("Pack data too short")); - } + encode_handle + .await + .map_err(|e| IoError::other(format!("Encode task panicked: {}", e)))?; - if &pack_data[0..4] != b"PACK" { - return Err(IoError::other("Invalid pack signature")); - } + if pack_data.len() < 12 { + return Err(IoError::other("Pack data too short")); + } - let mut response_data = Vec::new(); - - let nak_line = "NAK\n"; - let nak_len = nak_line.len() + 4; - let nak_len_hex = format!("{:04x}", nak_len); - response_data.extend_from_slice(nak_len_hex.as_bytes()); - response_data.extend_from_slice(nak_line.as_bytes()); - - let chunk_size = 65500; - for chunk in pack_data.chunks(chunk_size) { - let mut sideband_data = Vec::with_capacity(1 + chunk.len()); - sideband_data.push(1); - sideband_data.extend_from_slice(chunk); - - let total_len = sideband_data.len() + 4; - let len_hex = format!("{:04x}", total_len); - - response_data.extend_from_slice(len_hex.as_bytes()); - response_data.extend_from_slice(&sideband_data); - - // Send progress update every ~10 chunks (approximately 655KB) - const PROGRESS_CHUNK_INTERVAL: usize = 10; - if response_data.len() % (chunk_size * PROGRESS_CHUNK_INTERVAL) == 0 { - let progress_msg = - format!("Pack {}/{}...\n", response_data.len(), pack_data.len()); - let mut progress_data = Vec::with_capacity(1 + progress_msg.len()); - progress_data.push(2); - progress_data.extend_from_slice(progress_msg.as_bytes()); - - let progress_len = progress_data.len() + 4; - let progress_len_hex = format!("{:04x}", progress_len); - response_data.extend_from_slice(progress_len_hex.as_bytes()); - response_data.extend_from_slice(&progress_data); + if &pack_data[0..4] != b"PACK" { + return Err(IoError::other("Invalid pack signature")); } - } - response_data.extend_from_slice(b"0000"); + let mut response_data = Vec::new(); + + let nak_line = "NAK\n"; + let nak_len = nak_line.len() + 4; + let nak_len_hex = format!("{:04x}", nak_len); + response_data.extend_from_slice(nak_len_hex.as_bytes()); + response_data.extend_from_slice(nak_line.as_bytes()); + + let chunk_size = 65500; + for chunk in pack_data.chunks(chunk_size) { + let mut sideband_data = Vec::with_capacity(1 + chunk.len()); + sideband_data.push(1); + sideband_data.extend_from_slice(chunk); + + let total_len = sideband_data.len() + 4; + let len_hex = format!("{:04x}", total_len); + + response_data.extend_from_slice(len_hex.as_bytes()); + response_data.extend_from_slice(&sideband_data); + + // Send progress update every ~10 chunks (approximately 655KB) + const PROGRESS_CHUNK_INTERVAL: usize = 10; + if response_data.len() % (chunk_size * PROGRESS_CHUNK_INTERVAL) == 0 { + let progress_msg = + format!("Pack {}/{}...\n", response_data.len(), pack_data.len()); + let mut progress_data = Vec::with_capacity(1 + progress_msg.len()); + progress_data.push(2); + progress_data.extend_from_slice(progress_msg.as_bytes()); + + let progress_len = progress_data.len() + 4; + let progress_len_hex = format!("{:04x}", progress_len); + response_data.extend_from_slice(progress_len_hex.as_bytes()); + response_data.extend_from_slice(&progress_data); + } + } - let response_stream = stream::iter(vec![Ok(Bytes::from(response_data))]); + response_data.extend_from_slice(b"0000"); - env::set_current_dir(&original_dir)?; - Ok(Box::pin(response_stream)) + let response_stream = stream::iter(vec![Ok(Bytes::from(response_data))]); + Ok(Box::pin(response_stream) as FetchStream) + }) + .await } } } diff --git a/src/utils/util.rs b/src/utils/util.rs index eb33bc81..4692caa5 100644 --- a/src/utils/util.rs +++ b/src/utils/util.rs @@ -483,17 +483,31 @@ pub async fn get_commit_base(name: &str) -> Result { return Ok(branch.commit); } - // Added: detect remote branch in remote/branch format + // Support both short remote branches (`main` with `remote = origin`) and + // fetched remote-tracking refs (`refs/remotes/origin/main`) for inputs such + // as `origin/main`. if let Some((remote, branch_name)) = name.split_once('/') && !remote.is_empty() && !branch_name.is_empty() - && let Some(branch) = Branch::find_branch_result(branch_name, Some(remote)) - .await - .map_err(|error| { - format!("fatal: failed to resolve remote branch '{remote}/{branch_name}': {error}") - })? { - return Ok(branch.commit); + let remote_tracking_ref = format!("refs/remotes/{remote}/{branch_name}"); + let remote_lookup_error = |error| { + format!("fatal: failed to resolve remote branch '{remote}/{branch_name}': {error}") + }; + + if let Some(branch) = Branch::find_branch_result(&remote_tracking_ref, Some(remote)) + .await + .map_err(remote_lookup_error)? + { + return Ok(branch.commit); + } + + if let Some(branch) = Branch::find_branch_result(branch_name, Some(remote)) + .await + .map_err(remote_lookup_error)? + { + return Ok(branch.commit); + } } // 3. Check for a tag diff --git a/tests/command/branch_test.rs b/tests/command/branch_test.rs index 85fafeef..88a46275 100644 --- a/tests/command/branch_test.rs +++ b/tests/command/branch_test.rs @@ -324,6 +324,110 @@ async fn test_create_branch_from_remote() { assert_eq!(branch.commit, hash); } +#[tokio::test] +#[serial] +async fn test_create_branch_from_remote_tracking_ref() { + let temp_path = tempdir().unwrap(); + test::setup_with_new_libra_in(temp_path.path()).await; + let _guard = ChangeDirGuard::new(temp_path.path()); + + commit::execute(CommitArgs { + message: Some("first".to_string()), + allow_empty: true, + disable_pre: true, + no_verify: false, + ..Default::default() + }) + .await; + + let hash = Head::current_commit().await.unwrap(); + Branch::update_branch( + "refs/remotes/origin/main", + &hash.to_string(), + Some("origin"), + ) + .await + .unwrap(); + + assert!(get_target_commit("origin/main").await.is_ok()); + + execute(BranchArgs { + new_branch: Some("tracking-copy".to_string()), + commit_hash: Some("origin/main".into()), + list: false, + delete: None, + delete_safe: None, + set_upstream_to: None, + show_current: false, + rename: vec![], + remotes: false, + all: false, + contains: vec![], + no_contains: vec![], + }) + .await; + + let branch = Branch::find_branch("tracking-copy", None) + .await + .expect("branch create from tracking ref failed"); + assert_eq!(branch.commit, hash); +} + +#[tokio::test] +#[serial] +async fn test_branch_create_without_base_surfaces_corrupt_head_storage() { + let repo = create_committed_repo_via_cli(); + { + let _guard = ChangeDirGuard::new(repo.path()); + Branch::update_branch("main", "not-a-valid-hash", None) + .await + .unwrap(); + } + + let output = run_libra_command(&["branch", "feature"], repo.path()); + let (stderr, report) = parse_cli_error_stderr(&output.stderr); + + assert_eq!(output.status.code(), Some(128)); + assert_eq!(report.error_code, "LBR-REPO-002"); + assert!( + stderr.contains("failed to resolve HEAD commit"), + "unexpected stderr: {stderr}" + ); + assert!( + stderr.contains("stored branch reference 'main' is corrupt"), + "unexpected stderr: {stderr}" + ); +} + +#[tokio::test] +#[serial] +async fn test_branch_delete_safe_surfaces_corrupt_head_storage() { + let repo = create_committed_repo_via_cli(); + let create = run_libra_command(&["branch", "topic"], repo.path()); + assert_cli_success(&create, "branch topic"); + + { + let _guard = ChangeDirGuard::new(repo.path()); + Branch::update_branch("main", "not-a-valid-hash", None) + .await + .unwrap(); + } + + let output = run_libra_command(&["branch", "-d", "topic"], repo.path()); + let (stderr, report) = parse_cli_error_stderr(&output.stderr); + + assert_eq!(output.status.code(), Some(128)); + assert_eq!(report.error_code, "LBR-REPO-002"); + assert!( + stderr.contains("failed to resolve HEAD commit"), + "unexpected stderr: {stderr}" + ); + assert!( + stderr.contains("stored branch reference 'main' is corrupt"), + "unexpected stderr: {stderr}" + ); +} + #[tokio::test] #[serial] /// Tests the behavior of creating a branch with an invalid name. diff --git a/tests/command/reset_test.rs b/tests/command/reset_test.rs index 5885c35e..46244528 100644 --- a/tests/command/reset_test.rs +++ b/tests/command/reset_test.rs @@ -201,6 +201,44 @@ fn test_reset_json_hard_with_pathspec_returns_usage_error() { ); } +#[tokio::test] +#[serial] +async fn test_reset_corrupt_head_reference_returns_repo_corrupt() { + let repo = create_committed_repo_via_cli(); + let target_commit = { + let _guard = ChangeDirGuard::new(repo.path()); + InternalBranch::find_branch("main", None) + .await + .expect("main branch should exist") + .commit + .to_string() + }; + { + let _guard = ChangeDirGuard::new(repo.path()); + InternalBranch::update_branch("main", "not-a-valid-hash", None) + .await + .unwrap(); + } + + let output = run_libra_command(&["reset", &target_commit], repo.path()); + let (stderr, report) = parse_cli_error_stderr(&output.stderr); + + assert_eq!(output.status.code(), Some(128)); + assert_eq!(report.error_code, "LBR-REPO-002"); + assert!( + stderr.contains("stored HEAD reference is corrupt"), + "unexpected stderr: {stderr}" + ); + assert!( + stderr.contains("stored branch reference 'main' is corrupt"), + "unexpected stderr: {stderr}" + ); + assert!( + !stderr.contains("HEAD is unborn"), + "reset should not misreport corrupt HEAD as unborn: {stderr}" + ); +} + #[tokio::test] #[serial] async fn test_reset_pathspec_surfaces_subtree_corruption_as_repo_corrupt() { diff --git a/tests/local_client_test.rs b/tests/local_client_test.rs new file mode 100644 index 00000000..0e63af7b --- /dev/null +++ b/tests/local_client_test.rs @@ -0,0 +1,52 @@ +use std::env; + +use libra::{ + command::commit::{self, CommitArgs}, + git_protocol::ServiceType, + internal::{branch::Branch, protocol::local_client::LocalClient}, + utils::test::{self, ChangeDirGuard}, +}; +use serial_test::serial; +use tempfile::tempdir; + +#[tokio::test] +#[serial] +async fn discovery_reference_restores_current_dir_after_error() { + let caller_dir = tempdir().unwrap(); + let repo_dir = tempdir().unwrap(); + + test::setup_with_new_libra_in(repo_dir.path()).await; + { + let _guard = ChangeDirGuard::new(repo_dir.path()); + commit::execute(CommitArgs { + message: Some("initial".into()), + allow_empty: true, + disable_pre: true, + ..Default::default() + }) + .await; + + Branch::update_branch("broken", "not-a-valid-hash", None) + .await + .unwrap(); + } + + env::set_current_dir(caller_dir.path()).unwrap(); + let original_dir = env::current_dir().unwrap(); + + let client = LocalClient::from_path(repo_dir.path()).unwrap(); + let error = client + .discovery_reference(ServiceType::UploadPack) + .await + .expect_err("expected corrupt branch storage to fail discovery"); + + assert!( + error.to_string().contains("corrupt"), + "unexpected error: {error}" + ); + assert_eq!( + env::current_dir().unwrap(), + original_dir, + "local protocol discovery should restore the original working directory", + ); +} From 7a5667bac9e8bfab41ccffefeb2e280c62e05beb Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Thu, 2 Apr 2026 03:38:13 +0800 Subject: [PATCH 05/14] fix(branch): surface show-current head resolution failures Signed-off-by: Quanyi Ma --- src/command/branch.rs | 11 ++++++++--- tests/command/branch_test.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/command/branch.rs b/src/command/branch.rs index 53e81e69..684583a6 100644 --- a/src/command/branch.rs +++ b/src/command/branch.rs @@ -708,18 +708,23 @@ async fn run_branch(args: &BranchArgs) -> Result { } else if let Some(branch_to_delete) = args.delete_safe.clone() { delete_branch_impl(branch_to_delete, false).await } else if args.show_current { - Ok(match Head::current().await { + let head = Head::current().await; + let output = match head { Head::Branch(name) => BranchOutput::ShowCurrent { name: Some(name), detached: false, - commit: Head::current_commit().await.map(|hash| hash.to_string()), + commit: Head::current_commit_result() + .await + .map_err(map_head_commit_store_error)? + .map(|hash| hash.to_string()), }, Head::Detached(hash) => BranchOutput::ShowCurrent { name: None, detached: true, commit: Some(hash.to_string()), }, - }) + }; + Ok(output) } else if let Some(upstream) = args.set_upstream_to.as_deref() { let branch = match Head::current().await { Head::Branch(name) => name, diff --git a/tests/command/branch_test.rs b/tests/command/branch_test.rs index 88a46275..cc0f7067 100644 --- a/tests/command/branch_test.rs +++ b/tests/command/branch_test.rs @@ -428,6 +428,32 @@ async fn test_branch_delete_safe_surfaces_corrupt_head_storage() { ); } +#[tokio::test] +#[serial] +async fn test_branch_show_current_surfaces_corrupt_head_storage() { + let repo = create_committed_repo_via_cli(); + { + let _guard = ChangeDirGuard::new(repo.path()); + Branch::update_branch("main", "not-a-valid-hash", None) + .await + .unwrap(); + } + + let output = run_libra_command(&["branch", "--show-current"], repo.path()); + let (stderr, report) = parse_cli_error_stderr(&output.stderr); + + assert_eq!(output.status.code(), Some(128)); + assert_eq!(report.error_code, "LBR-REPO-002"); + assert!( + stderr.contains("failed to resolve HEAD commit"), + "unexpected stderr: {stderr}" + ); + assert!( + stderr.contains("stored branch reference 'main' is corrupt"), + "unexpected stderr: {stderr}" + ); +} + #[tokio::test] #[serial] /// Tests the behavior of creating a branch with an invalid name. From cedf0910b37d9f65adfe29db040266f508e9e052 Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Thu, 2 Apr 2026 04:22:36 +0800 Subject: [PATCH 06/14] fix(branch-status-show-ref): address follow-up review findings Signed-off-by: Quanyi Ma --- src/command/branch.rs | 11 +++--- src/command/show_ref.rs | 3 +- src/command/status.rs | 61 ++++++++++++++++++++++++++++++++-- tests/command/branch_test.rs | 22 ++++++++++++ tests/command/show_ref_test.rs | 4 ++- 5 files changed, 90 insertions(+), 11 deletions(-) diff --git a/src/command/branch.rs b/src/command/branch.rs index 684583a6..5c2e7b94 100644 --- a/src/command/branch.rs +++ b/src/command/branch.rs @@ -205,7 +205,7 @@ enum BranchError { #[error("failed to query branch storage: {0}")] StorageQueryFailed(String), - #[error("stored branch reference is corrupt: {0}")] + #[error("{0}")] StoredReferenceCorrupt(String), #[error("failed to create branch '{branch}': {detail}")] @@ -288,8 +288,7 @@ impl From for CliError { .with_stable_code(StableErrorCode::IoReadFailed) } BranchError::StoredReferenceCorrupt(detail) => { - CliError::fatal(format!("stored branch reference is corrupt: {detail}")) - .with_stable_code(StableErrorCode::RepoCorrupt) + CliError::fatal(detail).with_stable_code(StableErrorCode::RepoCorrupt) } BranchError::CreateFailed { branch, detail } => { CliError::fatal(format!("failed to create branch '{branch}': {detail}")) @@ -314,9 +313,9 @@ fn detached_head_branch_error() -> BranchError { fn map_branch_store_error(error: branch::BranchStoreError) -> BranchError { match error { branch::BranchStoreError::Query(detail) => BranchError::StorageQueryFailed(detail), - branch::BranchStoreError::Corrupt { detail, .. } => { - BranchError::StoredReferenceCorrupt(detail) - } + branch::BranchStoreError::Corrupt { name, detail } => BranchError::StoredReferenceCorrupt( + format!("stored branch reference '{name}' is corrupt: {detail}"), + ), branch::BranchStoreError::NotFound(name) => BranchError::NotFound { name, similar: Vec::new(), diff --git a/src/command/show_ref.rs b/src/command/show_ref.rs index 45da1b4d..dcd187c0 100644 --- a/src/command/show_ref.rs +++ b/src/command/show_ref.rs @@ -161,7 +161,8 @@ async fn collect_show_ref_entries(args: &ShowRefArgs) -> CliResult CliError } } +fn status_config_read_error(context: &str, error: anyhow::Error) -> CliError { + CliError::fatal(format!("failed to {context}: {error}")) + .with_stable_code(StableErrorCode::IoReadFailed) +} + async fn resolve_upstream_info( head: &Head, local_commit: Option<&ObjectHash>, @@ -1185,8 +1190,15 @@ async fn resolve_upstream_info( Head::Detached(_) => return Ok(None), }; - let Some(branch_config) = ConfigKv::branch_config(&branch_name).await.ok().flatten() else { - return Ok(None); + let branch_config = match ConfigKv::branch_config(&branch_name).await { + Ok(Some(config)) => config, + Ok(None) => return Ok(None), + Err(error) => { + return Err(status_config_read_error( + &format!("read branch configuration for '{branch_name}'"), + error, + )); + } }; let remote = &branch_config.remote; @@ -1569,4 +1581,47 @@ pub fn list_ignored_files() -> Result { } #[cfg(test)] -mod test {} +mod test { + use sea_orm::{ConnectionTrait, Statement}; + use serial_test::serial; + use tempfile::tempdir; + + use super::*; + use crate::{ + internal::db::{get_db_conn_instance, reset_db_conn_instance_for_path}, + utils::{ + error::StableErrorCode, + test::{self, ChangeDirGuard}, + }, + }; + + #[tokio::test] + #[serial] + async fn resolve_upstream_info_surfaces_branch_config_query_failures() { + let repo = tempdir().expect("failed to create temp repo"); + test::setup_with_new_libra_in(repo.path()).await; + let _guard = ChangeDirGuard::new(repo.path()); + let db_path = repo.path().join(".libra").join("libra.db"); + + let db = get_db_conn_instance().await; + db.execute(Statement::from_string( + db.get_database_backend(), + "DROP TABLE config_kv", + )) + .await + .expect("dropping config_kv table should succeed"); + + let err = resolve_upstream_info(&Head::Branch("main".to_string()), None) + .await + .expect_err("missing config_kv table should surface as an error"); + + assert_eq!(err.stable_code(), StableErrorCode::IoReadFailed); + assert!( + err.to_string() + .contains("failed to read branch configuration for 'main'"), + "unexpected error: {err}" + ); + + reset_db_conn_instance_for_path(&db_path).await; + } +} diff --git a/tests/command/branch_test.rs b/tests/command/branch_test.rs index cc0f7067..1fc3f9ee 100644 --- a/tests/command/branch_test.rs +++ b/tests/command/branch_test.rs @@ -454,6 +454,28 @@ async fn test_branch_show_current_surfaces_corrupt_head_storage() { ); } +#[tokio::test] +#[serial] +async fn test_branch_list_surfaces_corrupt_reference_name() { + let repo = create_committed_repo_via_cli(); + { + let _guard = ChangeDirGuard::new(repo.path()); + Branch::update_branch("broken-topic", "not-a-valid-hash", None) + .await + .unwrap(); + } + + let output = run_libra_command(&["branch"], repo.path()); + let (stderr, report) = parse_cli_error_stderr(&output.stderr); + + assert_eq!(output.status.code(), Some(128)); + assert_eq!(report.error_code, "LBR-REPO-002"); + assert!( + stderr.contains("stored branch reference 'broken-topic' is corrupt"), + "unexpected stderr: {stderr}" + ); +} + #[tokio::test] #[serial] /// Tests the behavior of creating a branch with an invalid name. diff --git a/tests/command/show_ref_test.rs b/tests/command/show_ref_test.rs index cb4b3191..5e1c2212 100644 --- a/tests/command/show_ref_test.rs +++ b/tests/command/show_ref_test.rs @@ -218,7 +218,9 @@ async fn test_show_ref_pattern_no_match() { .output() .unwrap(); - let stderr = String::from_utf8_lossy(&output.stderr); + let (stderr, report) = parse_cli_error_stderr(&output.stderr); + assert_eq!(output.status.code(), Some(129)); + assert_eq!(report.error_code, "LBR-CLI-003"); assert!( stderr.contains("no matching refs found"), "expected error for non-matching pattern, got stderr: {stderr}" From f783c2d6431a5be0e6edf9f9588d0c57a05a0895 Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Thu, 2 Apr 2026 13:41:29 +0800 Subject: [PATCH 07/14] fix(local-client-branch-clone): address follow-up review findings Signed-off-by: Quanyi Ma --- src/command/branch.rs | 9 +- src/command/clone.rs | 65 +++++++++++--- src/internal/protocol/local_client.rs | 120 ++++++++++++++++++++++---- tests/command/branch_test.rs | 41 +++++++++ 4 files changed, 204 insertions(+), 31 deletions(-) diff --git a/src/command/branch.rs b/src/command/branch.rs index 5c2e7b94..4e20ced5 100644 --- a/src/command/branch.rs +++ b/src/command/branch.rs @@ -663,6 +663,7 @@ async fn collect_branch_output(args: &BranchArgs) -> Result Result CliResu short_display_hash(detached_head).green() ); } + if *show_unborn_head && let Some(head_name) = head_name { + println!("* {}", head_name.green()); + } if branches.is_empty() { - if *show_unborn_head && let Some(head_name) = head_name { - println!("* {}", head_name.green()); - } return Ok(()); } diff --git a/src/command/clone.rs b/src/command/clone.rs index e2668096..dffa8406 100644 --- a/src/command/clone.rs +++ b/src/command/clone.rs @@ -26,7 +26,7 @@ use crate::{ restore::{RestoreArgs, RestoreError}, }, internal::{ - branch::Branch, + branch::{self, Branch}, config::{ConfigKv, RemoteConfig}, head::Head, reflog::{ReflogAction, ReflogContext, with_reflog}, @@ -142,8 +142,8 @@ pub enum CloneError { InitializeRepository { source: InitError }, #[error("remote branch {branch} not found in upstream origin")] RemoteBranchNotFound { branch: String }, - #[error("failed to inspect local branch state after fetch: {message}")] - LocalBranchState { message: String }, + #[error("failed to inspect local branch state after fetch: {source}")] + LocalBranchState { source: branch::BranchStoreError }, #[error("fetch failed: {source}")] FetchFailed { source: fetch::FetchError }, #[error("failed to checkout working tree")] @@ -196,11 +196,8 @@ impl From for CliError { .with_hint( "use `-b ` to specify an existing branch, or omit to use remote HEAD", ), - CloneError::LocalBranchState { message } => CliError::fatal(format!( - "failed to inspect local branch state after fetch: {message}" - )) - .with_stable_code(StableErrorCode::RepoCorrupt) - .with_hint("run 'libra status' to verify the local repository state"), + CloneError::LocalBranchState { source } => map_local_branch_state_error(source) + .with_hint("run 'libra status' to verify the local repository state"), CloneError::FetchFailed { source } => map_fetch_error(source), CloneError::CheckoutFailed { source } => map_checkout_error(source), CloneError::SetupFailed { .. } => CliError::fatal(error.to_string()) @@ -310,6 +307,33 @@ fn map_checkout_error(source: RestoreError) -> CliError { } } +fn map_local_branch_state_error(source: branch::BranchStoreError) -> CliError { + match source { + branch::BranchStoreError::Query(detail) => { + CliError::fatal(format!( + "failed to inspect local branch state after fetch: {detail}" + )) + .with_stable_code(StableErrorCode::IoReadFailed) + } + branch::BranchStoreError::Corrupt { .. } => { + CliError::fatal(format!( + "failed to inspect local branch state after fetch: {source}" + )) + .with_stable_code(StableErrorCode::RepoCorrupt) + } + branch::BranchStoreError::NotFound(name) => { + CliError::fatal(format!( + "failed to inspect local branch state after fetch: branch '{name}' not found" + )) + .with_stable_code(StableErrorCode::RepoStateInvalid) + } + branch::BranchStoreError::Delete { name, detail } => CliError::fatal(format!( + "failed to inspect local branch state after fetch: failed to delete branch '{name}': {detail}" + )) + .with_stable_code(StableErrorCode::IoWriteFailed), + } +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -755,9 +779,7 @@ pub(crate) async fn setup_repository( Some(&remote_config.name), ) .await - .map_err(|error| CloneError::LocalBranchState { - message: error.to_string(), - })? + .map_err(|source| CloneError::LocalBranchState { source })? .ok_or_else(|| CloneError::RemoteBranchNotFound { branch: branch_name.clone(), })?; @@ -943,4 +965,25 @@ mod tests { assert_eq!(cli.stable_code(), StableErrorCode::IoWriteFailed); assert_eq!(cli.exit_code(), 128); } + + #[test] + fn local_branch_state_query_maps_to_io_read_failed() { + let cli = map_local_branch_state_error(branch::BranchStoreError::Query( + "database is locked".into(), + )); + + assert_eq!(cli.stable_code(), StableErrorCode::IoReadFailed); + assert_eq!(cli.exit_code(), 128); + } + + #[test] + fn local_branch_state_corrupt_maps_to_repo_corrupt() { + let cli = map_local_branch_state_error(branch::BranchStoreError::Corrupt { + name: "refs/remotes/origin/main".into(), + detail: "invalid object id".into(), + }); + + assert_eq!(cli.stable_code(), StableErrorCode::RepoCorrupt); + assert_eq!(cli.exit_code(), 128); + } } diff --git a/src/internal/protocol/local_client.rs b/src/internal/protocol/local_client.rs index ddcae0cc..17f23b9e 100644 --- a/src/internal/protocol/local_client.rs +++ b/src/internal/protocol/local_client.rs @@ -49,6 +49,49 @@ pub struct LocalClient { source_type: RepoType, } +/// RAII guard for temporarily switching the process current directory. +/// +/// This supports an explicit `restore()` so callers can surface restore +/// failures on the success path, while `Drop` still restores the directory if +/// the surrounding future is cancelled or aborted. +struct RepoCurrentDirGuard { + original_dir: PathBuf, + restored: bool, +} + +impl RepoCurrentDirGuard { + fn change_to(new_dir: &Path) -> Result { + let original_dir = env::current_dir()?; + env::set_current_dir(new_dir)?; + Ok(Self { + original_dir, + restored: false, + }) + } + + fn restore(&mut self) -> Result<(), IoError> { + env::set_current_dir(&self.original_dir)?; + self.restored = true; + Ok(()) + } +} + +impl Drop for RepoCurrentDirGuard { + fn drop(&mut self) { + if self.restored { + return; + } + + if let Err(error) = env::set_current_dir(&self.original_dir) { + tracing::error!( + restore_dir = %self.original_dir.display(), + error = %error, + "failed to restore working directory after local protocol operation" + ); + } + } +} + impl ProtocolClient for LocalClient { fn from_url(url: &Url) -> Self { let path = url @@ -76,25 +119,23 @@ impl LocalClient { F: FnOnce() -> Fut, Fut: Future>, { - let original_dir = cur_dir(); - env::set_current_dir(&self.repo_path).map_err(E::from)?; - - match operation().await { - Ok(value) => { - env::set_current_dir(&original_dir).map_err(E::from)?; - Ok(value) - } - Err(error) => { - if let Err(restore_error) = env::set_current_dir(&original_dir) { + let mut guard = RepoCurrentDirGuard::change_to(&self.repo_path).map_err(E::from)?; + let result = operation().await; + + match guard.restore() { + Ok(()) => result, + Err(restore_error) => match result { + Ok(_) => Err(E::from(restore_error)), + Err(error) => { tracing::error!( repo_path = %self.repo_path.display(), - restore_dir = %original_dir.display(), + restore_dir = %guard.original_dir.display(), error = %restore_error, "failed to restore working directory after local protocol operation" ); + Err(error) } - Err(error) - } + }, } } @@ -435,14 +476,18 @@ impl LocalClient { #[cfg(test)] mod tests { - use std::{ffi::OsStr, process::Command as StdCommand}; + use std::{ffi::OsStr, fs, future::pending, process::Command as StdCommand}; + use serial_test::serial; use tempfile::tempdir; - use tokio::io::AsyncReadExt; + use tokio::{io::AsyncReadExt, sync::oneshot}; use tokio_util::io::StreamReader; use super::*; - use crate::git_protocol::ServiceType; + use crate::{ + git_protocol::ServiceType, + utils::test::{ChangeDirGuard, setup_with_new_libra_in}, + }; fn run_git(cwd: Option<&Path>, args: I) -> StdCommand where @@ -577,4 +622,47 @@ mod tests { reader.read_to_end(&mut buf).await.unwrap(); assert!(buf.windows(4).any(|w| w == b"PACK")); } + + #[tokio::test] + #[serial] + async fn with_repo_current_dir_restores_current_dir_when_task_is_cancelled() { + let caller_dir = tempdir().unwrap(); + let repo_dir = tempdir().unwrap(); + let _guard = ChangeDirGuard::new(caller_dir.path()); + setup_with_new_libra_in(repo_dir.path()).await; + + let client = LocalClient::from_path(repo_dir.path()).unwrap(); + let original_dir = env::current_dir().unwrap(); + let repo_storage_dir = client.repo_path().to_path_buf(); + let (entered_tx, entered_rx) = oneshot::channel(); + + let handle = tokio::spawn({ + let client = client.clone(); + async move { + let _ = client + .with_repo_current_dir(|| async move { + let _ = entered_tx.send(env::current_dir().unwrap()); + pending::<()>().await; + #[allow(unreachable_code)] + Ok::<(), IoError>(()) + }) + .await; + } + }); + + let entered_dir = entered_rx.await.unwrap(); + assert_eq!( + fs::canonicalize(entered_dir).unwrap(), + fs::canonicalize(repo_storage_dir).unwrap() + ); + + handle.abort(); + let _ = handle.await; + + assert_eq!( + fs::canonicalize(env::current_dir().unwrap()).unwrap(), + fs::canonicalize(original_dir).unwrap(), + "aborted local protocol operation should restore caller cwd", + ); + } } diff --git a/tests/command/branch_test.rs b/tests/command/branch_test.rs index 1fc3f9ee..ff963b48 100644 --- a/tests/command/branch_test.rs +++ b/tests/command/branch_test.rs @@ -8,6 +8,7 @@ use std::os::unix::fs::PermissionsExt; use std::{collections::HashSet, fs}; +use git_internal::hash::{ObjectHash, get_hash_kind}; use libra::internal::config::ConfigKv; use serial_test::serial; use tempfile::tempdir; @@ -58,6 +59,46 @@ fn test_branch_create_outputs_confirmation() { ); } +#[tokio::test] +#[serial] +async fn test_branch_all_shows_unborn_head_even_with_remote_refs() { + let repo = tempdir().unwrap(); + test::setup_with_new_libra_in(repo.path()).await; + let _guard = ChangeDirGuard::new(repo.path()); + + let remote_add = run_libra_command( + &[ + "remote", + "add", + "origin", + "https://example.invalid/repo.git", + ], + repo.path(), + ); + assert_cli_success(&remote_add, "remote add origin"); + + Branch::update_branch( + "refs/remotes/origin/main", + &ObjectHash::zero_str(get_hash_kind()), + Some("origin"), + ) + .await + .unwrap(); + + let output = run_libra_command(&["branch", "-a"], repo.path()); + assert_cli_success(&output, "branch -a on unborn repo with remotes"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("* main"), + "expected unborn HEAD marker in stdout: {stdout}" + ); + assert!( + stdout.contains("origin/main"), + "expected remote branch in stdout: {stdout}" + ); +} + #[test] fn test_branch_not_found_suggests_similar_name() { let repo = create_committed_repo_via_cli(); From d73bea0f241e158044f756e58545a95de1c20e98 Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Thu, 2 Apr 2026 14:55:54 +0800 Subject: [PATCH 08/14] fix(cli-tag-show-ref): address follow-up review findings Signed-off-by: Quanyi Ma --- src/cli.rs | 3 +++ src/command/show_ref.rs | 18 +++++++++++++++--- src/command/tag.rs | 6 +++++- tests/command/show_ref_test.rs | 33 +++++++++++++++++++++++++++++++++ tests/command/tag_test.rs | 19 +++++++++++++++++++ 5 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 78d141c8..4f7fac36 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -581,6 +581,9 @@ pub async fn parse_async(args: Option<&[&str]>) -> CliResult<()> { _ => return Err(classify_parse_error(&argv, &err)), }, }; + if let Commands::Tag(tag_args) = &args.command { + command::tag::validate_cli_args(tag_args)?; + } match &args.command { Commands::Init(_) | Commands::Clone(_) => {} // Config global/system scopes don't require a repository diff --git a/src/command/show_ref.rs b/src/command/show_ref.rs index dcd187c0..fb0afbb4 100644 --- a/src/command/show_ref.rs +++ b/src/command/show_ref.rs @@ -3,6 +3,7 @@ use std::io::Write; use clap::Parser; +use sea_orm::DbErr; use serde::Serialize; use crate::{ @@ -95,6 +96,19 @@ fn show_ref_branch_store_error(context: &str, error: BranchStoreError) -> CliErr } } +fn show_ref_tag_list_error(error: anyhow::Error) -> CliError { + let stable_code = if error + .chain() + .any(|cause| cause.downcast_ref::().is_some()) + { + StableErrorCode::IoReadFailed + } else { + StableErrorCode::RepoCorrupt + }; + + CliError::fatal(format!("failed to list tags: {error}")).with_stable_code(stable_code) +} + async fn collect_show_ref_entries(args: &ShowRefArgs) -> CliResult> { // When neither --heads nor --tags is specified, show both let show_heads = args.heads || !args.tags; @@ -131,9 +145,7 @@ async fn collect_show_ref_entries(args: &ShowRefArgs) -> CliResult if show_tags { - let tag_list = tag::list() - .await - .map_err(|e| CliError::failure(e.to_string()))?; + let tag_list = tag::list().await.map_err(show_ref_tag_list_error)?; for t in tag_list { // For annotated tags use the tag object hash; for lightweight use the commit hash. let hash = match &t.object { diff --git a/src/command/tag.rs b/src/command/tag.rs index 166a3425..1d768fe8 100644 --- a/src/command/tag.rs +++ b/src/command/tag.rs @@ -89,6 +89,10 @@ pub async fn execute_safe(args: TagArgs, output: &OutputConfig) -> CliResult<()> render_tag_output(&result, output) } +pub(crate) fn validate_cli_args(args: &TagArgs) -> CliResult<()> { + validate_named_tag_action(args).map_err(CliError::from) +} + #[derive(Debug, thiserror::Error)] enum TagError { #[error("not a libra repository")] @@ -232,8 +236,8 @@ fn map_create_tag_error(tag_name: &str, error: tag::CreateTagError) -> TagError } async fn run_tag(args: &TagArgs) -> Result { - util::require_repo().map_err(|_| TagError::NotInRepo)?; validate_named_tag_action(args)?; + util::require_repo().map_err(|_| TagError::NotInRepo)?; if args.list || args.n_lines.is_some() || args.name.is_none() { return Ok(TagOutput::List { diff --git a/tests/command/show_ref_test.rs b/tests/command/show_ref_test.rs index 5e1c2212..33985fa3 100644 --- a/tests/command/show_ref_test.rs +++ b/tests/command/show_ref_test.rs @@ -147,6 +147,39 @@ async fn test_show_ref_lists_tag() { ); } +#[tokio::test] +#[serial] +async fn test_show_ref_surfaces_corrupt_tag_storage() { + let temp = tempdir().unwrap(); + let _guard = setup_repo_with_commit(&temp).await; + let db = get_db_conn_instance().await; + reference::ActiveModel { + name: Set(Some("refs/tags/broken".to_string())), + kind: Set(reference::ConfigKind::Tag), + commit: Set(Some("not-a-valid-hash".to_string())), + remote: Set(None), + ..Default::default() + } + .insert(&db) + .await + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_libra")) + .current_dir(temp.path()) + .arg("show-ref") + .arg("--tags") + .output() + .unwrap(); + + let (stderr, report) = parse_cli_error_stderr(&output.stderr); + assert_eq!(output.status.code(), Some(128)); + assert_eq!(report.error_code, "LBR-REPO-002"); + assert!( + stderr.contains("failed to list tags"), + "unexpected stderr: {stderr}" + ); +} + /// show-ref --head should include HEAD. #[tokio::test] #[serial] diff --git a/tests/command/tag_test.rs b/tests/command/tag_test.rs index c4e08417..7edc2a36 100644 --- a/tests/command/tag_test.rs +++ b/tests/command/tag_test.rs @@ -190,6 +190,25 @@ fn test_tag_missing_name_action_flags_return_usage_errors() { } } +#[test] +fn test_tag_missing_name_usage_outranks_repo_not_found_outside_repo() { + let cwd = tempdir().expect("failed to create non-repo directory"); + + let output = run_libra_command(&["tag", "-d"], cwd.path()); + let (stderr, report) = parse_cli_error_stderr(&output.stderr); + + assert_eq!(output.status.code(), Some(129)); + assert_eq!(report.error_code, "LBR-CLI-002"); + assert!( + stderr.contains("tag name is required for --delete"), + "unexpected stderr: {stderr}" + ); + assert!( + !stderr.contains("not a Libra repository"), + "usage error should outrank repo precondition, got: {stderr}" + ); +} + #[test] fn test_tag_json_missing_name_action_flags_return_usage_errors() { let repo = create_committed_repo_via_cli(); From de8628e7971d122c96f911030643a76d4b83d881 Mon Sep 17 00:00:00 2001 From: Eli Ma Date: Thu, 2 Apr 2026 17:07:12 +0800 Subject: [PATCH 09/14] fix(errors): preserve rollback and tag context Signed-off-by: Eli Ma --- src/command/reset.rs | 168 ++++++++++++++------------ src/command/show_ref.rs | 1 + src/command/tag.rs | 134 ++++++++++++++------ src/internal/protocol/local_client.rs | 12 ++ 4 files changed, 200 insertions(+), 115 deletions(-) diff --git a/src/command/reset.rs b/src/command/reset.rs index 782149b1..e8f223be 100644 --- a/src/command/reset.rs +++ b/src/command/reset.rs @@ -179,67 +179,93 @@ enum ResetError { #[error("pathspec '{0}' did not match any file(s) known to libra")] PathspecNotMatched(String), + + #[error("{primary}; rollback failed: {rollback}")] + Rollback { + primary: Box, + rollback: Box, + }, } -impl From for CliError { - fn from(error: ResetError) -> Self { - match error { - ResetError::NotInRepo => CliError::repo_not_found(), - ResetError::InvalidRevision(message) => CliError::fatal(message) - .with_stable_code(StableErrorCode::CliInvalidTarget) - .with_hint("check the revision name and try again."), - ResetError::HeadUnborn => CliError::fatal(error.to_string()) - .with_stable_code(StableErrorCode::RepoStateInvalid) - .with_hint("create a commit first before resetting HEAD."), - ResetError::HeadRead(_) => CliError::fatal(error.to_string()) - .with_stable_code(StableErrorCode::IoReadFailed) - .with_hint("check whether the repository database is readable."), - ResetError::HeadCorrupt(_) => CliError::fatal(error.to_string()) - .with_stable_code(StableErrorCode::RepoCorrupt) - .with_hint("the HEAD reference or branch metadata may be corrupted."), - ResetError::ObjectLoad { .. } => CliError::fatal(error.to_string()) - .with_stable_code(StableErrorCode::RepoCorrupt) - .with_hint("the object store may be corrupted."), - ResetError::IndexLoad(_) => CliError::fatal(error.to_string()) - .with_stable_code(StableErrorCode::RepoCorrupt) - .with_hint("the index file may be corrupted."), - ResetError::IndexSave(_) => { - CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::IoWriteFailed) - } - ResetError::HeadUpdate(_) => { - CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::IoWriteFailed) +impl ResetError { + fn stable_code(&self) -> StableErrorCode { + match self { + Self::NotInRepo => StableErrorCode::RepoNotFound, + Self::InvalidRevision(_) => StableErrorCode::CliInvalidTarget, + Self::HeadUnborn => StableErrorCode::RepoStateInvalid, + Self::HeadRead(_) => StableErrorCode::IoReadFailed, + Self::HeadCorrupt(_) => StableErrorCode::RepoCorrupt, + Self::ObjectLoad { .. } => StableErrorCode::RepoCorrupt, + Self::IndexLoad(_) => StableErrorCode::RepoCorrupt, + Self::IndexSave(_) => StableErrorCode::IoWriteFailed, + Self::HeadUpdate(_) => StableErrorCode::IoWriteFailed, + Self::WorktreeRead(_) => StableErrorCode::IoReadFailed, + Self::WorktreeRestore(_) => StableErrorCode::IoWriteFailed, + Self::InvalidPathspecEncoding(_) => StableErrorCode::CliInvalidArguments, + Self::PathspecWithSoft(_) => StableErrorCode::CliInvalidArguments, + Self::PathspecWithHard => StableErrorCode::CliInvalidArguments, + Self::PathspecNotMatched(_) => StableErrorCode::CliInvalidTarget, + Self::Rollback { primary, .. } => primary.stable_code(), + } + } + + fn hint(&self) -> Option<&'static str> { + match self { + Self::NotInRepo => { + Some("run 'libra init' to create a repository in the current directory.") } - ResetError::WorktreeRead(_) => { - CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::IoReadFailed) + Self::InvalidRevision(_) => Some("check the revision name and try again."), + Self::HeadUnborn => Some("create a commit first before resetting HEAD."), + Self::HeadRead(_) => Some("check whether the repository database is readable."), + Self::HeadCorrupt(_) => Some("the HEAD reference or branch metadata may be corrupted."), + Self::ObjectLoad { .. } => Some("the object store may be corrupted."), + Self::IndexLoad(_) => Some("the index file may be corrupted."), + Self::InvalidPathspecEncoding(_) => { + Some("rename the path or invoke libra from a path representable as UTF-8.") } - ResetError::WorktreeRestore(_) => { - CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::IoWriteFailed) + Self::PathspecWithSoft(_) => { + Some("--soft only moves HEAD; use --mixed to reset index for specific paths.") } - ResetError::InvalidPathspecEncoding(path) => CliError::fatal(format!( - "path contains invalid UTF-8: {path}" - )) - .with_stable_code(StableErrorCode::CliInvalidArguments) - .with_hint( - "rename the path or invoke libra from a path representable as UTF-8.", + Self::PathspecWithHard => Some( + "--hard updates the working tree; omit pathspecs or use --mixed for specific paths.", ), - ResetError::PathspecWithSoft(pathspec) => CliError::command_usage(format!( - "pathspec '{pathspec}' is not compatible with --soft reset" - )) - .with_stable_code(StableErrorCode::CliInvalidArguments) - .with_hint("--soft only moves HEAD; use --mixed to reset index for specific paths."), - ResetError::PathspecWithHard => { - CliError::command_usage("Cannot do hard reset with paths.") - .with_stable_code(StableErrorCode::CliInvalidArguments) - .with_hint( - "--hard updates the working tree; omit pathspecs or use --mixed for specific paths.", - ) - } - ResetError::PathspecNotMatched(pathspec) => { - CliError::fatal(format!( - "pathspec '{pathspec}' did not match any file(s) known to libra" - )) - .with_stable_code(StableErrorCode::CliInvalidTarget) - .with_hint("check the path and try again.") + Self::PathspecNotMatched(_) => Some("check the path and try again."), + Self::IndexSave(_) + | Self::HeadUpdate(_) + | Self::WorktreeRead(_) + | Self::WorktreeRestore(_) => None, + Self::Rollback { primary, .. } => primary.hint(), + } + } + + fn is_command_usage(&self) -> bool { + match self { + Self::PathspecWithSoft(_) | Self::PathspecWithHard => true, + Self::Rollback { primary, .. } => primary.is_command_usage(), + _ => false, + } + } +} + +impl From for CliError { + fn from(error: ResetError) -> Self { + match error { + ResetError::NotInRepo => CliError::repo_not_found(), + other => { + let message = other.to_string(); + let stable_code = other.stable_code(); + let mut cli = if other.is_command_usage() { + CliError::command_usage(message) + } else { + CliError::fatal(message) + } + .with_stable_code(stable_code); + + if let Some(hint) = other.hint() { + cli = cli.with_hint(hint); + } + + cli } } } @@ -526,27 +552,9 @@ async fn update_reset_reference( fn merge_reset_failure(error: ResetError, rollback: Result<(), ResetError>) -> ResetError { match rollback { Ok(()) => error, - Err(rollback_error) => match error { - ResetError::IndexSave(detail) => { - ResetError::IndexSave(format!("{detail}; rollback failed: {rollback_error}")) - } - ResetError::HeadUpdate(detail) => { - ResetError::HeadUpdate(format!("{detail}; rollback failed: {rollback_error}")) - } - ResetError::WorktreeRead(detail) => { - ResetError::WorktreeRead(format!("{detail}; rollback failed: {rollback_error}")) - } - ResetError::WorktreeRestore(detail) => { - ResetError::WorktreeRestore(format!("{detail}; rollback failed: {rollback_error}")) - } - other => { - tracing::error!( - "rollback after reset failed: {} (primary error: {})", - rollback_error, - other - ); - other - } + Err(rollback_error) => ResetError::Rollback { + primary: Box::new(error), + rollback: Box::new(rollback_error), }, } } @@ -999,8 +1007,14 @@ mod tests { )), ); - assert!(matches!(merged, ResetError::ObjectLoad { .. })); + assert!(matches!(merged, ResetError::Rollback { .. })); let cli_error = CliError::from(merged); assert_eq!(cli_error.stable_code(), StableErrorCode::RepoCorrupt); + assert!(cli_error.message().contains("rollback failed")); + assert!( + cli_error + .message() + .contains("failed to restore working tree") + ); } } diff --git a/src/command/show_ref.rs b/src/command/show_ref.rs index fb0afbb4..3c610ce6 100644 --- a/src/command/show_ref.rs +++ b/src/command/show_ref.rs @@ -97,6 +97,7 @@ fn show_ref_branch_store_error(context: &str, error: BranchStoreError) -> CliErr } fn show_ref_tag_list_error(error: anyhow::Error) -> CliError { + // TODO: Remove this DbErr-chain heuristic once tag::list() returns a typed error. let stable_code = if error .chain() .any(|cause| cause.downcast_ref::().is_some()) diff --git a/src/command/tag.rs b/src/command/tag.rs index 1d768fe8..30c9547c 100644 --- a/src/command/tag.rs +++ b/src/command/tag.rs @@ -1,6 +1,10 @@ //! Manages tags by resolving target commits, creating lightweight or annotated tag objects, storing refs, and listing existing tags. +use std::io; + use clap::Parser; +use git_internal::errors::GitError; +use sea_orm::DbErr; use serde::Serialize; use crate::{ @@ -110,30 +114,58 @@ enum TagError { #[error("Cannot create tag: HEAD does not point to a commit")] HeadUnborn, - #[error("failed to read existing tags before creating '{name}': {detail}")] - CheckExistingFailed { name: String, detail: String }, + #[error("failed to read existing tags before creating '{name}': {source}")] + CheckExistingFailed { + name: String, + #[source] + source: DbErr, + }, #[error("failed to serialize annotated tag object: {0}")] - SerializeAnnotatedTag(String), + SerializeAnnotatedTag(#[source] GitError), #[error("failed to store annotated tag object: {0}")] - StoreObjectFailed(String), + StoreObjectFailed(#[source] io::Error), - #[error("failed to persist tag reference '{name}': {detail}")] - PersistReferenceFailed { name: String, detail: String }, + #[error("failed to persist tag reference '{name}': {source}")] + PersistReferenceFailed { + name: String, + #[source] + source: DbErr, + }, - #[error("failed to delete tag '{name}': {detail}")] - DeleteFailed { name: String, detail: String }, + #[error("failed to delete tag '{name}': {source}")] + DeleteFailed { + name: String, + #[source] + source: anyhow::Error, + }, - #[error("failed to load tag '{name}': {detail}")] - LoadFailed { name: String, detail: String }, + #[error("failed to load tag '{name}': {source}")] + LoadFailed { + name: String, + #[source] + source: anyhow::Error, + }, #[error("failed to list tags: {0}")] - ListFailed(String), + ListFailed(#[source] anyhow::Error), +} + +fn classify_tag_read_error(error: &anyhow::Error) -> StableErrorCode { + if error + .chain() + .any(|cause| cause.downcast_ref::().is_some()) + { + StableErrorCode::IoReadFailed + } else { + StableErrorCode::RepoCorrupt + } } impl From for CliError { fn from(error: TagError) -> Self { + let message = error.to_string(); match error { TagError::NotInRepo => CliError::repo_not_found(), TagError::AlreadyExists(name) => { @@ -149,28 +181,29 @@ impl From for CliError { .with_stable_code(StableErrorCode::CliInvalidArguments) .with_hint("use 'libra tag ' to create or update a tag") .with_hint("use 'libra tag -l' to list existing tags"), - TagError::HeadUnborn => CliError::fatal(error.to_string()) + TagError::HeadUnborn => CliError::fatal(message) .with_stable_code(StableErrorCode::RepoStateInvalid) .with_hint("create a commit first before tagging HEAD."), TagError::CheckExistingFailed { .. } => { - CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::RepoCorrupt) + CliError::fatal(message).with_stable_code(StableErrorCode::IoReadFailed) + } + TagError::SerializeAnnotatedTag(_) => { + CliError::fatal(message).with_stable_code(StableErrorCode::InternalInvariant) } - TagError::SerializeAnnotatedTag(_) => CliError::fatal(error.to_string()) - .with_stable_code(StableErrorCode::InternalInvariant), TagError::StoreObjectFailed(_) => { - CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::IoWriteFailed) + CliError::fatal(message).with_stable_code(StableErrorCode::IoWriteFailed) } TagError::PersistReferenceFailed { .. } => { - CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::IoWriteFailed) + CliError::fatal(message).with_stable_code(StableErrorCode::IoWriteFailed) } TagError::DeleteFailed { .. } => { - CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::IoWriteFailed) + CliError::fatal(message).with_stable_code(StableErrorCode::IoWriteFailed) } - TagError::LoadFailed { .. } => { - CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::RepoCorrupt) + TagError::LoadFailed { source, .. } => { + CliError::fatal(message).with_stable_code(classify_tag_read_error(&source)) } - TagError::ListFailed(_) => { - CliError::fatal(error.to_string()).with_stable_code(StableErrorCode::RepoCorrupt) + TagError::ListFailed(source) => { + CliError::fatal(message).with_stable_code(classify_tag_read_error(&source)) } } } @@ -222,15 +255,13 @@ fn map_create_tag_error(tag_name: &str, error: tag::CreateTagError) -> TagError tag::CreateTagError::HeadUnborn => TagError::HeadUnborn, tag::CreateTagError::CheckExisting(source) => TagError::CheckExistingFailed { name: tag_name.to_string(), - detail: source.to_string(), + source, }, - tag::CreateTagError::SerializeTag(source) => { - TagError::SerializeAnnotatedTag(source.to_string()) - } - tag::CreateTagError::StoreObject(source) => TagError::StoreObjectFailed(source.to_string()), + tag::CreateTagError::SerializeTag(source) => TagError::SerializeAnnotatedTag(source), + tag::CreateTagError::StoreObject(source) => TagError::StoreObjectFailed(source), tag::CreateTagError::PersistReference(source) => TagError::PersistReferenceFailed { name: tag_name.to_string(), - detail: source.to_string(), + source, }, } } @@ -292,7 +323,7 @@ fn render_tag_output(result: &TagOutput, output: &OutputConfig) -> CliResult<()> pub async fn render_tags(show_lines: usize) -> Result { let tags = collect_tags(show_lines) .await - .map_err(|e| anyhow::anyhow!(e.to_string()))?; + .map_err(anyhow::Error::from)?; Ok(format_tag_entries(&tags)) } @@ -332,9 +363,9 @@ async fn run_delete_tag(tag_name: &str) -> Result { let snapshot = resolve_tag_ref_for_delete(tag_name).await?; tag::delete(tag_name) .await - .map_err(|e| TagError::DeleteFailed { + .map_err(|source| TagError::DeleteFailed { name: tag_name.to_string(), - detail: e.to_string(), + source, })?; Ok(TagOutput::Delete { name: tag_name.to_string(), @@ -343,9 +374,7 @@ async fn run_delete_tag(tag_name: &str) -> Result { } async fn collect_tags(show_lines: usize) -> Result, TagError> { - let tags = tag::list() - .await - .map_err(|e| TagError::ListFailed(e.to_string()))?; + let tags = tag::list().await.map_err(TagError::ListFailed)?; let mut entries = Vec::with_capacity(tags.len()); for tag in tags { entries.push(tag_to_list_entry(tag, show_lines)); @@ -361,9 +390,9 @@ async fn lookup_tag(tag_name: &str, show_lines: usize) -> Result Err(TagError::NotFound(tag_name.to_string())), - Err(e) => Err(TagError::LoadFailed { + Err(source) => Err(TagError::LoadFailed { name: tag_name.to_string(), - detail: e.to_string(), + source: source.into(), }), } } @@ -446,9 +475,9 @@ async fn resolve_tag_ref_for_delete(tag_name: &str) -> Result Ok(reference), Ok(None) => Err(TagError::NotFound(tag_name.to_string())), - Err(e) => Err(TagError::LoadFailed { + Err(source) => Err(TagError::LoadFailed { name: tag_name.to_string(), - detail: format!("failed to query tag ref: {e}"), + source: source.into(), }), } } @@ -458,6 +487,7 @@ mod tests { use std::fs; use git_internal::internal::object::types::ObjectType; + use sea_orm::DbErr; use serial_test::serial; use tempfile::tempdir; @@ -601,4 +631,32 @@ mod tests { let tags = tag::list().await.unwrap(); assert!(tags.is_empty()); } + + #[test] + fn test_tag_check_existing_db_error_maps_as_io_read() { + let cli_error = CliError::from(TagError::CheckExistingFailed { + name: "v1.0".to_string(), + source: DbErr::Custom("database is locked".to_string()), + }); + + assert_eq!(cli_error.stable_code(), StableErrorCode::IoReadFailed); + } + + #[test] + fn test_tag_list_db_error_maps_as_io_read() { + let cli_error = CliError::from(TagError::ListFailed(anyhow::Error::new(DbErr::Custom( + "database is locked".to_string(), + )))); + + assert_eq!(cli_error.stable_code(), StableErrorCode::IoReadFailed); + } + + #[test] + fn test_tag_list_object_error_maps_as_repo_corrupt() { + let cli_error = CliError::from(TagError::ListFailed(anyhow::anyhow!( + "Invalid ObjectHash: not-a-valid-hash" + ))); + + assert_eq!(cli_error.stable_code(), StableErrorCode::RepoCorrupt); + } } diff --git a/src/internal/protocol/local_client.rs b/src/internal/protocol/local_client.rs index 17f23b9e..123103a0 100644 --- a/src/internal/protocol/local_client.rs +++ b/src/internal/protocol/local_client.rs @@ -57,6 +57,7 @@ pub struct LocalClient { struct RepoCurrentDirGuard { original_dir: PathBuf, restored: bool, + restore_failure_logged: bool, } impl RepoCurrentDirGuard { @@ -66,6 +67,7 @@ impl RepoCurrentDirGuard { Ok(Self { original_dir, restored: false, + restore_failure_logged: false, }) } @@ -74,6 +76,10 @@ impl RepoCurrentDirGuard { self.restored = true; Ok(()) } + + fn mark_restore_failure_logged(&mut self) { + self.restore_failure_logged = true; + } } impl Drop for RepoCurrentDirGuard { @@ -83,6 +89,11 @@ impl Drop for RepoCurrentDirGuard { } if let Err(error) = env::set_current_dir(&self.original_dir) { + if self.restore_failure_logged { + return; + } + + self.restore_failure_logged = true; tracing::error!( restore_dir = %self.original_dir.display(), error = %error, @@ -127,6 +138,7 @@ impl LocalClient { Err(restore_error) => match result { Ok(_) => Err(E::from(restore_error)), Err(error) => { + guard.mark_restore_failure_logged(); tracing::error!( repo_path = %self.repo_path.display(), restore_dir = %guard.original_dir.display(), From bb87970161bf1a48d74882aff3b2eaaaacdc259c Mon Sep 17 00:00:00 2001 From: Eli Ma Date: Thu, 2 Apr 2026 17:25:47 +0800 Subject: [PATCH 10/14] test(command): skip permission injection under root Signed-off-by: Eli Ma --- tests/command/branch_test.rs | 11 +++++++++++ tests/command/mod.rs | 16 ++++++++++++++++ tests/command/remote_test.rs | 6 ++++++ 3 files changed, 33 insertions(+) diff --git a/tests/command/branch_test.rs b/tests/command/branch_test.rs index ff963b48..f6fbb399 100644 --- a/tests/command/branch_test.rs +++ b/tests/command/branch_test.rs @@ -140,6 +140,11 @@ fn test_branch_set_upstream_detached_head_returns_repo_state_error() { #[cfg(unix)] #[test] fn test_branch_set_upstream_surfaces_config_write_failure() { + if skip_permission_denied_test_if_root("test_branch_set_upstream_surfaces_config_write_failure") + { + return; + } + let repo = create_committed_repo_via_cli(); let db_path = repo.path().join(".libra").join("libra.db"); let original_mode = fs::metadata(&db_path).unwrap().permissions().mode(); @@ -160,6 +165,12 @@ fn test_branch_set_upstream_surfaces_config_write_failure() { #[cfg(unix)] #[test] fn test_branch_set_upstream_idempotent_path_skips_redundant_write() { + if skip_permission_denied_test_if_root( + "test_branch_set_upstream_idempotent_path_skips_redundant_write", + ) { + return; + } + let repo = create_committed_repo_via_cli(); let first = run_libra_command(&["branch", "--set-upstream-to", "origin/main"], repo.path()); diff --git a/tests/command/mod.rs b/tests/command/mod.rs index 1f5aa544..30ee9a80 100644 --- a/tests/command/mod.rs +++ b/tests/command/mod.rs @@ -139,6 +139,22 @@ fn create_committed_repo_via_cli() -> tempfile::TempDir { repo } +#[cfg(unix)] +fn skip_permission_denied_test_if_root(test_name: &str) -> bool { + unsafe extern "C" { + fn geteuid() -> u32; + } + + let is_root = unsafe { geteuid() == 0 }; + if is_root { + eprintln!( + "skipping {test_name}: permission-based write failure injection is unreliable as root" + ); + } + + is_root +} + mod add_cli_test; mod add_json_test; mod add_test; diff --git a/tests/command/remote_test.rs b/tests/command/remote_test.rs index b3effdc1..03d3dab2 100644 --- a/tests/command/remote_test.rs +++ b/tests/command/remote_test.rs @@ -759,6 +759,12 @@ async fn test_remote_prune_nonexistent_remote_returns_error() { #[tokio::test] #[serial] async fn test_remote_prune_does_not_report_success_when_delete_fails() { + if skip_permission_denied_test_if_root( + "test_remote_prune_does_not_report_success_when_delete_fails", + ) { + return; + } + let temp_root = tempdir().unwrap(); let remote_dir = temp_root.path().join("remote.git"); let work_dir = temp_root.path().join("workdir"); From df07053762dda62ed9cccba3705ff5870f1e75cb Mon Sep 17 00:00:00 2001 From: Eli Ma Date: Thu, 2 Apr 2026 17:44:27 +0800 Subject: [PATCH 11/14] fix(reset): preserve warnings and revision error codes Signed-off-by: Eli Ma --- src/command/reset.rs | 53 ++++++++++- src/utils/util.rs | 177 ++++++++++++++++++++++++++---------- tests/command/reset_test.rs | 30 ++++++ 3 files changed, 206 insertions(+), 54 deletions(-) diff --git a/src/command/reset.rs b/src/command/reset.rs index e8f223be..a34c807c 100644 --- a/src/command/reset.rs +++ b/src/command/reset.rs @@ -17,7 +17,7 @@ use git_internal::{ use serde::Serialize; use crate::{ - command::{get_target_commit, load_object}, + command::load_object, common_utils::parse_commit_msg, internal::{ branch::{self, Branch}, @@ -168,6 +168,12 @@ enum ResetError { #[error("failed to restore working tree: {0}")] WorktreeRestore(String), + #[error("{0}")] + RevisionRead(String), + + #[error("{0}")] + RevisionCorrupt(String), + #[error("path contains invalid UTF-8: {0}")] InvalidPathspecEncoding(String), @@ -201,6 +207,8 @@ impl ResetError { Self::HeadUpdate(_) => StableErrorCode::IoWriteFailed, Self::WorktreeRead(_) => StableErrorCode::IoReadFailed, Self::WorktreeRestore(_) => StableErrorCode::IoWriteFailed, + Self::RevisionRead(_) => StableErrorCode::IoReadFailed, + Self::RevisionCorrupt(_) => StableErrorCode::RepoCorrupt, Self::InvalidPathspecEncoding(_) => StableErrorCode::CliInvalidArguments, Self::PathspecWithSoft(_) => StableErrorCode::CliInvalidArguments, Self::PathspecWithHard => StableErrorCode::CliInvalidArguments, @@ -230,6 +238,12 @@ impl ResetError { "--hard updates the working tree; omit pathspecs or use --mixed for specific paths.", ), Self::PathspecNotMatched(_) => Some("check the path and try again."), + Self::RevisionRead(_) => { + Some("check whether the repository references and object storage are readable.") + } + Self::RevisionCorrupt(_) => { + Some("the referenced branch, tag, or object metadata may be corrupted.") + } Self::IndexSave(_) | Self::HeadUpdate(_) | Self::WorktreeRead(_) @@ -767,7 +781,11 @@ fn restore_working_directory_from_tree_counted_typed( /// except for the .libra directory and the working directory root. pub(crate) fn remove_empty_directories(workdir: &Path) -> Result<(), String> { remove_empty_directories_with_warnings(workdir) - .map(|_| ()) + .map(|warnings| { + for warning in warnings { + emit_warning(warning); + } + }) .map_err(|e| e.to_string()) } @@ -843,9 +861,18 @@ fn remove_empty_directories_with_warnings(workdir: &Path) -> Result, /// Resolve a reference string to a commit ObjectHash. /// Accepts commit hashes, branch names, or HEAD references. async fn resolve_commit(reference: &str) -> Result { - get_target_commit(reference) + util::get_commit_base_typed(reference) .await - .map_err(|e| ResetError::InvalidRevision(e.to_string())) + .map_err(map_commit_base_error) +} + +fn map_commit_base_error(error: util::CommitBaseError) -> ResetError { + match error { + util::CommitBaseError::HeadUnborn => ResetError::HeadUnborn, + util::CommitBaseError::InvalidReference(message) => ResetError::InvalidRevision(message), + util::CommitBaseError::ReadFailure(message) => ResetError::RevisionRead(message), + util::CommitBaseError::CorruptReference(message) => ResetError::RevisionCorrupt(message), + } } /// Get the first line of a commit's message for display purposes. @@ -994,6 +1021,24 @@ mod tests { assert_eq!(error.stable_code(), StableErrorCode::IoReadFailed); } + #[test] + fn test_reset_error_maps_revision_read_failures_as_io_read() { + let error = CliError::from(ResetError::RevisionRead( + "failed to resolve branch 'main': failed to query branch storage: database is locked" + .into(), + )); + assert_eq!(error.stable_code(), StableErrorCode::IoReadFailed); + } + + #[test] + fn test_reset_error_maps_revision_corruption_as_repo_corrupt() { + let error = CliError::from(ResetError::RevisionCorrupt( + "failed to resolve branch 'main': stored branch reference 'main' is corrupt: invalid hash" + .into(), + )); + assert_eq!(error.stable_code(), StableErrorCode::RepoCorrupt); + } + #[test] fn test_merge_reset_failure_preserves_primary_error_category() { let merged = merge_reset_failure( diff --git a/src/utils/util.rs b/src/utils/util.rs index 4692caa5..76eea7f9 100644 --- a/src/utils/util.rs +++ b/src/utils/util.rs @@ -18,7 +18,11 @@ use once_cell::sync::Lazy; use path_absolutize::*; use crate::{ - internal::{branch::Branch, head::Head, tag}, + internal::{ + branch::{Branch, BranchStoreError}, + head::Head, + tag, + }, utils::{client_storage::ClientStorage, path, path_ext::PathExt}, }; @@ -455,32 +459,70 @@ pub fn path_to_string(path: &Path) -> String { path.to_string_lossy().to_string() } -/// Resolve a string to a commit ObjectHash. -/// The string can be a branch name, a tag name, or a commit hash prefix. -/// Order of resolution: -/// 1. HEAD -/// 2. Local Branch -/// 3. Tag -/// 4. Commit hash prefix -pub async fn get_commit_base(name: &str) -> Result { - // 1. Check for HEAD - if name.to_uppercase() == "HEAD" { - if let Some(commit_id) = Head::current_commit_result() - .await - .map_err(|error| format!("fatal: failed to resolve HEAD: {error}"))? +#[derive(Debug, thiserror::Error)] +pub enum CommitBaseError { + #[error("HEAD does not point to a commit")] + HeadUnborn, + #[error("{0}")] + InvalidReference(String), + #[error("{0}")] + ReadFailure(String), + #[error("{0}")] + CorruptReference(String), +} + +impl CommitBaseError { + fn from_branch_store_error(context: String, error: BranchStoreError) -> Self { + let message = format!("{context}: {error}"); + match error { + BranchStoreError::Query(_) | BranchStoreError::Delete { .. } => { + Self::ReadFailure(message) + } + BranchStoreError::Corrupt { .. } => Self::CorruptReference(message), + BranchStoreError::NotFound(_) => Self::InvalidReference(message), + } + } + + fn classify_storage_failure(message: String) -> Self { + let lower = message.to_ascii_lowercase(); + if lower.contains("database is locked") + || lower.contains("database schema is locked") + || lower.contains("permission denied") + || lower.contains("input/output error") + || lower.contains("failed to read") + || lower.contains("could not read") + || lower.contains("failed to query") { - return Ok(commit_id); + Self::ReadFailure(message) } else { - return Err("fatal: HEAD does not point to a commit".to_string()); + Self::CorruptReference(message) } } +} + +pub async fn get_commit_base_typed(name: &str) -> Result { + // 1. Check for HEAD + if name.eq_ignore_ascii_case("HEAD") { + return match Head::current_commit_result().await { + Ok(Some(commit_id)) => Ok(commit_id), + Ok(None) => Err(CommitBaseError::HeadUnborn), + Err(error) => Err(CommitBaseError::from_branch_store_error( + "failed to resolve HEAD".to_string(), + error, + )), + }; + } // 2. Check for a local branch - if let Some(branch) = Branch::find_branch_result(name, None) - .await - .map_err(|error| format!("fatal: failed to resolve branch '{name}': {error}"))? - { - return Ok(branch.commit); + match Branch::find_branch_result(name, None).await { + Ok(Some(branch)) => return Ok(branch.commit), + Ok(None) => {} + Err(error) => { + return Err(CommitBaseError::from_branch_store_error( + format!("failed to resolve branch '{name}'"), + error, + )); + } } // Support both short remote branches (`main` with `remote = origin`) and @@ -490,64 +532,99 @@ pub async fn get_commit_base(name: &str) -> Result { && !remote.is_empty() && !branch_name.is_empty() { - let remote_tracking_ref = format!("refs/remotes/{remote}/{branch_name}"); - let remote_lookup_error = |error| { - format!("fatal: failed to resolve remote branch '{remote}/{branch_name}': {error}") - }; + let remote_lookup_context = + format!("failed to resolve remote branch '{remote}/{branch_name}'"); - if let Some(branch) = Branch::find_branch_result(&remote_tracking_ref, Some(remote)) - .await - .map_err(remote_lookup_error)? + match Branch::find_branch_result( + &format!("refs/remotes/{remote}/{branch_name}"), + Some(remote), + ) + .await { - return Ok(branch.commit); + Ok(Some(branch)) => return Ok(branch.commit), + Ok(None) => {} + Err(error) => { + return Err(CommitBaseError::from_branch_store_error( + remote_lookup_context.clone(), + error, + )); + } } - if let Some(branch) = Branch::find_branch_result(branch_name, Some(remote)) - .await - .map_err(remote_lookup_error)? - { - return Ok(branch.commit); + match Branch::find_branch_result(branch_name, Some(remote)).await { + Ok(Some(branch)) => return Ok(branch.commit), + Ok(None) => {} + Err(error) => { + return Err(CommitBaseError::from_branch_store_error( + remote_lookup_context, + error, + )); + } } } // 3. Check for a tag - if let Ok(Some((_tag_object, commit))) = tag::find_tag_and_commit(name).await { - // The find_tag_and_commit function already dereferences annotated tags for us. - return Ok(commit.id); + match tag::find_tag_and_commit(name).await { + Ok(Some((_tag_object, commit))) => return Ok(commit.id), + Ok(None) => {} + Err(error) => { + return Err(CommitBaseError::classify_storage_failure(format!( + "failed to resolve tag '{name}': {error}" + ))); + } } // 4. Check for a hash prefix let storage = objects_storage(); let commits = storage.search(name).await; if commits.is_empty() { - return Err(format!("fatal: invalid reference: {}", name)); + return Err(CommitBaseError::InvalidReference(format!( + "invalid reference: {name}" + ))); } else if commits.len() > 1 { - return Err(format!("fatal: ambiguous argument: {}", name)); + return Err(CommitBaseError::InvalidReference(format!( + "ambiguous argument: {name}" + ))); } let object_id = commits[0]; - let object_type = storage - .get_object_type(&object_id) - .map_err(|e| format!("fatal: could not read object type for {}: {}", name, e))?; + let object_type = storage.get_object_type(&object_id).map_err(|e| { + CommitBaseError::classify_storage_failure(format!( + "could not read object type for {name}: {e}" + )) + })?; match object_type { ObjectType::Commit => Ok(object_id), ObjectType::Tag => { // Manually dereference tag if search returned a tag object directly let tag_obj: git_internal::internal::object::tag::Tag = - match crate::command::load_object(&object_id) { - Ok(obj) => obj, - Err(e) => return Err(format!("fatal: failed to load tag object: {}", e)), - }; + crate::command::load_object(&object_id).map_err(|e| { + CommitBaseError::classify_storage_failure(format!( + "failed to load tag object: {e}" + )) + })?; Ok(tag_obj.object_hash) } - _ => Err(format!( - "fatal: reference is not a commit: {}, is {}", - name, object_type - )), + _ => Err(CommitBaseError::InvalidReference(format!( + "reference is not a commit: {name}, is {object_type}" + ))), } } +/// Resolve a string to a commit ObjectHash. +/// The string can be a branch name, a tag name, or a commit hash prefix. +/// Order of resolution: +/// 1. HEAD +/// 2. Local Branch +/// 3. Tag +/// 4. Commit hash prefix +pub async fn get_commit_base(name: &str) -> Result { + get_commit_base_typed(name) + .await + .map_err(|error| format!("fatal: {error}")) +} + /// Get the repository name from the url /// - e.g. `https://github.com/web3infra-foundation/mega.git/` -> mega /// - e.g. `https://github.com/web3infra-foundation/mega.git` -> mega diff --git a/tests/command/reset_test.rs b/tests/command/reset_test.rs index 46244528..cd7ff524 100644 --- a/tests/command/reset_test.rs +++ b/tests/command/reset_test.rs @@ -239,6 +239,36 @@ async fn test_reset_corrupt_head_reference_returns_repo_corrupt() { ); } +#[tokio::test] +#[serial] +async fn test_reset_corrupt_target_branch_returns_repo_corrupt() { + let repo = create_committed_repo_via_cli(); + { + let _guard = ChangeDirGuard::new(repo.path()); + InternalBranch::update_branch("main", "not-a-valid-hash", None) + .await + .unwrap(); + } + + let output = run_libra_command(&["reset", "main"], repo.path()); + let (stderr, report) = parse_cli_error_stderr(&output.stderr); + + assert_eq!(output.status.code(), Some(128)); + assert_eq!(report.error_code, "LBR-REPO-002"); + assert!( + stderr.contains("failed to resolve branch 'main'"), + "unexpected stderr: {stderr}" + ); + assert!( + stderr.contains("stored branch reference 'main' is corrupt"), + "unexpected stderr: {stderr}" + ); + assert!( + !stderr.contains("invalid reference"), + "reset should not misclassify corrupt branch storage as invalid target: {stderr}" + ); +} + #[tokio::test] #[serial] async fn test_reset_pathspec_surfaces_subtree_corruption_as_repo_corrupt() { From 44ce4a34aee3e94eb0584257dbe2f5065fb52102 Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Thu, 2 Apr 2026 22:53:31 +0800 Subject: [PATCH 12/14] fix(local-client-tag): address follow-up review findings Signed-off-by: Quanyi Ma --- src/command/reset.rs | 14 ++-- src/command/tag.rs | 44 ++++++----- src/internal/protocol/local_client.rs | 101 +++++++++++++++++++++++++- src/internal/tag.rs | 33 +++++++-- src/utils/util.rs | 12 +-- tests/command/tag_test.rs | 55 +++++++++++++- 6 files changed, 217 insertions(+), 42 deletions(-) diff --git a/src/command/reset.rs b/src/command/reset.rs index a34c807c..ea2d429a 100644 --- a/src/command/reset.rs +++ b/src/command/reset.rs @@ -29,7 +29,9 @@ use crate::{ error::{CliError, CliResult, StableErrorCode, emit_warning}, object_ext::{BlobExt, TreeExt}, output::{OutputConfig, emit_json_data}, - path, util, + path, + text::short_display_hash, + util, }, }; @@ -327,12 +329,13 @@ async fn run_reset(args: ResetArgs) -> Result { let target_commit_id = resolve_commit(&args.target).await?; let changed_paths = reset_pathspecs(&args.pathspecs, &target_commit_id).await?; let subject = load_commit_summary_or_warn(&target_commit_id); + let commit = target_commit_id.to_string(); return Ok(ResetExecution { output: ResetOutput { mode: mode.as_str().to_string(), - commit: target_commit_id.to_string(), - short_commit: target_commit_id.to_string()[..7].to_string(), + short_commit: short_display_hash(&commit).to_string(), + commit, subject, previous_commit, files_unstaged: changed_paths.len(), @@ -347,11 +350,12 @@ async fn run_reset(args: ResetArgs) -> Result { let reset_stats = perform_reset(target_commit_id, mode, &args.target).await?; let subject = load_commit_summary_or_warn(&target_commit_id); + let commit = target_commit_id.to_string(); Ok(ResetExecution { output: ResetOutput { mode: mode.as_str().to_string(), - commit: target_commit_id.to_string(), - short_commit: target_commit_id.to_string()[..7].to_string(), + short_commit: short_display_hash(&commit).to_string(), + commit, subject, previous_commit, files_unstaged: 0, diff --git a/src/command/tag.rs b/src/command/tag.rs index 30c9547c..b209e67b 100644 --- a/src/command/tag.rs +++ b/src/command/tag.rs @@ -8,7 +8,7 @@ use sea_orm::DbErr; use serde::Serialize; use crate::{ - internal::{tag, tag::TagObject}, + internal::{branch, tag, tag::TagObject}, utils::{ error::{CliError, CliResult, StableErrorCode}, output::{OutputConfig, emit_json_data}, @@ -114,6 +114,9 @@ enum TagError { #[error("Cannot create tag: HEAD does not point to a commit")] HeadUnborn, + #[error("failed to resolve HEAD commit: {0}")] + ResolveHead(#[source] branch::BranchStoreError), + #[error("failed to read existing tags before creating '{name}': {source}")] CheckExistingFailed { name: String, @@ -184,6 +187,13 @@ impl From for CliError { TagError::HeadUnborn => CliError::fatal(message) .with_stable_code(StableErrorCode::RepoStateInvalid) .with_hint("create a commit first before tagging HEAD."), + TagError::ResolveHead(source) => { + let stable_code = match source { + branch::BranchStoreError::Query(_) => StableErrorCode::IoReadFailed, + _ => StableErrorCode::RepoCorrupt, + }; + CliError::fatal(message).with_stable_code(stable_code) + } TagError::CheckExistingFailed { .. } => { CliError::fatal(message).with_stable_code(StableErrorCode::IoReadFailed) } @@ -253,6 +263,7 @@ fn map_create_tag_error(tag_name: &str, error: tag::CreateTagError) -> TagError TagError::AlreadyExists(existing_tag_name) } tag::CreateTagError::HeadUnborn => TagError::HeadUnborn, + tag::CreateTagError::ResolveHead(source) => TagError::ResolveHead(source), tag::CreateTagError::CheckExisting(source) => TagError::CheckExistingFailed { name: tag_name.to_string(), source, @@ -346,16 +357,18 @@ async fn run_create_tag( message: Option, force: bool, ) -> Result { - tag::create(tag_name, message, force) + let created = tag::create(tag_name, message, force) .await .map_err(|error| map_create_tag_error(tag_name, error))?; - - let snapshot = lookup_tag(tag_name, usize::MAX).await?; Ok(TagOutput::Create { - name: snapshot.name, - hash: snapshot.hash, - tag_type: snapshot.tag_type, - message: snapshot.message, + name: created.name, + hash: created.target.to_string(), + tag_type: if created.annotated { + "annotated".to_string() + } else { + "lightweight".to_string() + }, + message: created.message, }) } @@ -382,21 +395,6 @@ async fn collect_tags(show_lines: usize) -> Result, TagError> Ok(entries) } -async fn lookup_tag(tag_name: &str, show_lines: usize) -> Result { - match tag::find_tag_and_commit(tag_name).await { - Ok(Some((object, _commit))) => Ok(tag_object_to_list_entry( - tag_name.to_string(), - object, - show_lines, - )), - Ok(None) => Err(TagError::NotFound(tag_name.to_string())), - Err(source) => Err(TagError::LoadFailed { - name: tag_name.to_string(), - source: source.into(), - }), - } -} - fn tag_to_list_entry(tag: tag::Tag, show_lines: usize) -> TagListEntry { let tag::Tag { name, object } = tag; tag_object_to_list_entry(name, object, show_lines) diff --git a/src/internal/protocol/local_client.rs b/src/internal/protocol/local_client.rs index 123103a0..31caf108 100644 --- a/src/internal/protocol/local_client.rs +++ b/src/internal/protocol/local_client.rs @@ -6,6 +6,7 @@ use std::{ future::Future, io::Error as IoError, path::{Path, PathBuf}, + sync::OnceLock, }; use bytes::Bytes; @@ -23,7 +24,7 @@ use git_internal::{ pack::{encode::PackEncoder, entry::Entry}, }, }; -use tokio::{io::AsyncWriteExt, process::Command}; +use tokio::{io::AsyncWriteExt, process::Command, sync::Mutex}; use url::Url; use super::{ @@ -49,6 +50,12 @@ pub struct LocalClient { source_type: RepoType, } +static LOCAL_PROTOCOL_CWD_LOCK: OnceLock> = OnceLock::new(); + +fn local_protocol_cwd_lock() -> &'static Mutex<()> { + LOCAL_PROTOCOL_CWD_LOCK.get_or_init(|| Mutex::new(())) +} + /// RAII guard for temporarily switching the process current directory. /// /// This supports an explicit `restore()` so callers can surface restore @@ -130,6 +137,9 @@ impl LocalClient { F: FnOnce() -> Fut, Fut: Future>, { + // Local protocol operations mutate the process cwd, so serialize them + // to avoid cross-task races while the repo-scoped cwd is active. + let _cwd_lock = local_protocol_cwd_lock().lock().await; let mut guard = RepoCurrentDirGuard::change_to(&self.repo_path).map_err(E::from)?; let result = operation().await; @@ -492,7 +502,11 @@ mod tests { use serial_test::serial; use tempfile::tempdir; - use tokio::{io::AsyncReadExt, sync::oneshot}; + use tokio::{ + io::AsyncReadExt, + sync::{mpsc, oneshot}, + time::{Duration, timeout}, + }; use tokio_util::io::StreamReader; use super::*; @@ -677,4 +691,87 @@ mod tests { "aborted local protocol operation should restore caller cwd", ); } + + #[tokio::test] + #[serial] + async fn with_repo_current_dir_serializes_concurrent_operations() { + let caller_dir = tempdir().unwrap(); + let repo_a = tempdir().unwrap(); + let repo_b = tempdir().unwrap(); + let _guard = ChangeDirGuard::new(caller_dir.path()); + setup_with_new_libra_in(repo_a.path()).await; + setup_with_new_libra_in(repo_b.path()).await; + + let client_a = LocalClient::from_path(repo_a.path()).unwrap(); + let client_b = LocalClient::from_path(repo_b.path()).unwrap(); + let repo_a_storage_dir = client_a.repo_path().to_path_buf(); + let repo_b_storage_dir = client_b.repo_path().to_path_buf(); + let original_dir = env::current_dir().unwrap(); + let (entered_tx, mut entered_rx) = mpsc::unbounded_channel::<(u8, PathBuf)>(); + let (release_tx, release_rx) = oneshot::channel::<()>(); + + let handle_a = tokio::spawn({ + let client = client_a.clone(); + let entered_tx = entered_tx.clone(); + async move { + client + .with_repo_current_dir(|| async move { + let _ = entered_tx.send((1, env::current_dir().unwrap())); + let _ = release_rx.await; + Ok::<(), IoError>(()) + }) + .await + .unwrap(); + } + }); + + let (first_id, first_dir) = entered_rx.recv().await.unwrap(); + assert_eq!(first_id, 1); + assert_eq!( + fs::canonicalize(first_dir).unwrap(), + fs::canonicalize(repo_a_storage_dir).unwrap() + ); + + let handle_b = tokio::spawn({ + let client = client_b.clone(); + let entered_tx = entered_tx.clone(); + async move { + client + .with_repo_current_dir(|| async move { + let _ = entered_tx.send((2, env::current_dir().unwrap())); + Ok::<(), IoError>(()) + }) + .await + .unwrap(); + } + }); + + assert!( + timeout(Duration::from_millis(100), entered_rx.recv()) + .await + .is_err(), + "concurrent local protocol operations should serialize cwd changes", + ); + + release_tx.send(()).unwrap(); + + let (second_id, second_dir) = timeout(Duration::from_secs(5), entered_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(second_id, 2); + assert_eq!( + fs::canonicalize(second_dir).unwrap(), + fs::canonicalize(repo_b_storage_dir).unwrap() + ); + + handle_a.await.unwrap(); + handle_b.await.unwrap(); + + assert_eq!( + fs::canonicalize(env::current_dir().unwrap()).unwrap(), + fs::canonicalize(original_dir).unwrap(), + "serialized local protocol operations should restore caller cwd", + ); + } } diff --git a/src/internal/tag.rs b/src/internal/tag.rs index 365173c3..8b6fc5e0 100644 --- a/src/internal/tag.rs +++ b/src/internal/tag.rs @@ -19,7 +19,10 @@ use sea_orm::{ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter, Se use crate::{ command::load_object, - internal::{config::ConfigKv, db::get_db_conn_instance, head::Head, model::reference}, + internal::{ + branch::BranchStoreError, config::ConfigKv, db::get_db_conn_instance, head::Head, + model::reference, + }, utils::{client_storage::ClientStorage, path}, }; @@ -79,6 +82,8 @@ pub struct TagReference { pub enum CreateTagError { #[error("Cannot create tag: HEAD does not point to a commit")] HeadUnborn, + #[error("failed to resolve HEAD commit: {0}")] + ResolveHead(#[source] BranchStoreError), #[error("Tag '{0}' already exists")] AlreadyExists(String), #[error("failed to query existing tag refs: {0}")] @@ -91,6 +96,14 @@ pub enum CreateTagError { PersistReference(#[source] DbErr), } +#[derive(Debug, Clone)] +pub struct CreateTagResult { + pub name: String, + pub target: ObjectHash, + pub annotated: bool, + pub message: Option, +} + /// Creates a new tag, either lightweight or annotated, pointing to the current HEAD commit. /// /// * `name` - The name of the tag. @@ -99,10 +112,12 @@ pub async fn create( name: &str, message: Option, force: bool, -) -> Result<(), CreateTagError> { - let head_commit_id = Head::current_commit() - .await - .ok_or(CreateTagError::HeadUnborn)?; +) -> Result { + let head_commit_id = match Head::current_commit_result().await { + Ok(Some(head_commit_id)) => head_commit_id, + Ok(None) => return Err(CreateTagError::HeadUnborn), + Err(source) => return Err(CreateTagError::ResolveHead(source)), + }; let db = get_db_conn_instance().await; let exists = reference::Entity::find() @@ -117,6 +132,7 @@ pub async fn create( } let ref_target_id: ObjectHash; + let create_message = message.clone(); if let Some(msg) = message { // Create an annotated tag object let user_name = ConfigKv::get("user.name") @@ -181,7 +197,12 @@ pub async fn create( } } - Ok(()) + Ok(CreateTagResult { + name: name.to_string(), + target: ref_target_id, + annotated: create_message.is_some(), + message: create_message, + }) } /// Lists all tags available in the repository. diff --git a/src/utils/util.rs b/src/utils/util.rs index 76eea7f9..a6eecb14 100644 --- a/src/utils/util.rs +++ b/src/utils/util.rs @@ -612,13 +612,15 @@ pub async fn get_commit_base_typed(name: &str) -> Result Result { get_commit_base_typed(name) .await diff --git a/tests/command/tag_test.rs b/tests/command/tag_test.rs index 7edc2a36..afa8bfed 100644 --- a/tests/command/tag_test.rs +++ b/tests/command/tag_test.rs @@ -8,7 +8,10 @@ use std::os::unix::fs::PermissionsExt; use libra::{ command::tag::{self, TagArgs}, - internal::{config::ConfigKv, db::get_db_conn_instance, model::reference, tag as internal_tag}, + internal::{ + branch::Branch, config::ConfigKv, db::get_db_conn_instance, model::reference, + tag as internal_tag, + }, utils::{ path, test::{ChangeDirGuard, setup_with_new_libra_in}, @@ -530,6 +533,33 @@ fn test_tag_cli_unborn_head_returns_repo_state_error() { ); } +#[tokio::test] +#[serial] +async fn test_tag_cli_corrupt_head_storage_returns_repo_corrupt() { + let repo = create_committed_repo_via_cli(); + let _guard = ChangeDirGuard::new(repo.path()); + Branch::update_branch("main", "not-a-valid-hash", None) + .await + .unwrap(); + + let output = run_libra_command(&["tag", "v1"], repo.path()); + let stdout = String::from_utf8_lossy(&output.stdout); + let (stderr, report) = parse_cli_error_stderr(&output.stderr); + + assert_eq!(output.status.code(), Some(128)); + assert!(stdout.trim().is_empty(), "unexpected stdout: {stdout}"); + assert_eq!(report.error_code, "LBR-REPO-002"); + assert_eq!(report.category, "repo"); + assert!( + stderr.contains("failed to resolve HEAD commit"), + "unexpected stderr: {stderr}" + ); + assert!( + stderr.contains("stored branch reference 'main' is corrupt"), + "unexpected stderr: {stderr}" + ); +} + #[test] fn test_tag_json_unborn_head_returns_repo_state_error() { let repo = tempdir().expect("failed to create repository root"); @@ -705,6 +735,29 @@ async fn test_force_tag_store_failure_preserves_existing_ref() { } } +#[tokio::test] +#[serial] +async fn test_internal_create_returns_metadata_for_annotated_tag() { + let (_temp, _guard) = setup_repo_with_commit_with("content", "Base").await; + + let created = internal_tag::create("v1.0", Some("Release v1.0".into()), false) + .await + .unwrap(); + + assert_eq!(created.name, "v1.0"); + assert!(created.annotated); + assert_eq!(created.message.as_deref(), Some("Release v1.0")); + + let tag = get_tag_by_name("v1.0") + .await + .expect("created tag should exist"); + let stored_hash = match tag.object { + internal_tag::TagObject::Tag(tag_object) => tag_object.id.to_string(), + other => panic!("expected annotated tag object, got {other:?}"), + }; + assert_eq!(created.target.to_string(), stored_hash); +} + #[tokio::test] #[serial] async fn test_list_tags() { From 95b87581c3f90fa8b43465bd0b455680224773fc Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Fri, 3 Apr 2026 00:17:34 +0800 Subject: [PATCH 13/14] fix(restore-reset): address follow-up review findings Signed-off-by: Quanyi Ma --- src/command/reset.rs | 6 +--- src/command/restore.rs | 60 ++++++++++++++++++++--------------- src/internal/branch.rs | 22 +++++++++++++ tests/command/mod.rs | 2 ++ tests/command/restore_test.rs | 51 +++++++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 31 deletions(-) diff --git a/src/command/reset.rs b/src/command/reset.rs index ea2d429a..9882e891 100644 --- a/src/command/reset.rs +++ b/src/command/reset.rs @@ -785,11 +785,7 @@ fn restore_working_directory_from_tree_counted_typed( /// except for the .libra directory and the working directory root. pub(crate) fn remove_empty_directories(workdir: &Path) -> Result<(), String> { remove_empty_directories_with_warnings(workdir) - .map(|warnings| { - for warning in warnings { - emit_warning(warning); - } - }) + .map(|_| ()) .map_err(|e| e.to_string()) } diff --git a/src/command/restore.rs b/src/command/restore.rs index 94f84264..9c5e2e49 100644 --- a/src/command/restore.rs +++ b/src/command/restore.rs @@ -23,6 +23,7 @@ use crate::{ protocol::lfs_client::LFSClient, }, utils::{ + client_storage::ClientStorage, error::{CliError, CliResult}, lfs, object_ext::{BlobExt, CommitExt, TreeExt}, @@ -124,20 +125,11 @@ pub async fn execute_checked(args: RestoreArgs) -> io::Result<()> { Head::current_commit_result() .await .map_err(|error| io::Error::other(error.to_string()))? - } else if let Some(branch) = Branch::find_branch_result(src, None) - .await - .map_err(|error| io::Error::other(error.to_string()))? - { - Some(branch.commit) } else { - // [Commit Hash, e.g. a1b2c3d4] || [Wrong Branch Name] - let objs = storage.search(src).await; - // TODO hash can be `commit` or `tree` - if objs.len() != 1 || !storage.is_object_type(&objs[0], ObjectType::Commit) { - None // Wrong Commit Hash - } else { - Some(objs[0]) - } + resolve_source_commit(src, &storage) + .await + .map(Some) + .map_err(|error| io::Error::other(error.to_string()))? } } }; @@ -232,20 +224,8 @@ pub async fn execute_checked_typed(args: RestoreArgs) -> Result<(), RestoreError .await .map_err(map_restore_branch_store_error)? .ok_or(RestoreError::ResolveSource)? - } else if let Some(branch) = Branch::find_branch_result(src, None) - .await - .map_err(map_restore_branch_store_error)? - { - branch.commit } else { - let objs = storage.search(src).await; - if objs.len() != 1 { - return Err(RestoreError::ResolveSource); - } - if !storage.is_object_type(&objs[0], ObjectType::Commit) { - return Err(RestoreError::ReferenceNotCommit); - } - objs[0] + resolve_source_commit(src, &storage).await? }; let tree_id = load_object::(&commit) @@ -267,6 +247,34 @@ pub async fn execute_checked_typed(args: RestoreArgs) -> Result<(), RestoreError Ok(()) } +async fn resolve_source_commit( + src: &str, + storage: &ClientStorage, +) -> Result { + if let Some(branch) = Branch::find_branch_result(src, None) + .await + .map_err(map_restore_branch_store_error)? + { + return Ok(branch.commit); + } + + if Branch::exists_result(src, None) + .await + .map_err(map_restore_branch_store_error)? + { + return Err(RestoreError::ResolveSource); + } + + let objs = storage.search(src).await; + if objs.len() != 1 { + return Err(RestoreError::ResolveSource); + } + if !storage.is_object_type(&objs[0], ObjectType::Commit) { + return Err(RestoreError::ReferenceNotCommit); + } + Ok(objs[0]) +} + fn map_restore_branch_store_error(error: BranchStoreError) -> RestoreError { match error { BranchStoreError::Query(_) => RestoreError::ReadObject, diff --git a/src/internal/branch.rs b/src/internal/branch.rs index a22de1a2..b010759d 100644 --- a/src/internal/branch.rs +++ b/src/internal/branch.rs @@ -179,6 +179,28 @@ impl Branch { Self::exists_with_conn(&db_conn, branch_name).await } + pub async fn exists_result_with_conn( + db: &C, + branch_name: &str, + remote: Option<&str>, + ) -> Result + where + C: ConnectionTrait, + { + query_reference_with_conn(db, branch_name, remote) + .await + .map(|branch| branch.is_some()) + .map_err(|err| BranchStoreError::Query(err.to_string())) + } + + pub async fn exists_result( + branch_name: &str, + remote: Option<&str>, + ) -> Result { + let db_conn = get_db_conn_instance().await; + Self::exists_result_with_conn(&db_conn, branch_name, remote).await + } + // `_with_conn` version for `find_branch` pub async fn find_branch_with_conn( db: &C, diff --git a/tests/command/mod.rs b/tests/command/mod.rs index 30ee9a80..74c0c453 100644 --- a/tests/command/mod.rs +++ b/tests/command/mod.rs @@ -145,6 +145,8 @@ fn skip_permission_denied_test_if_root(test_name: &str) -> bool { fn geteuid() -> u32; } + // SAFETY: On Unix targets libc exposes `geteuid()` with no arguments and a + // numeric return type compatible with `u32` on the platforms this suite runs on. let is_root = unsafe { geteuid() == 0 }; if is_root { eprintln!( diff --git a/tests/command/restore_test.rs b/tests/command/restore_test.rs index 9fada591..48f1ae14 100644 --- a/tests/command/restore_test.rs +++ b/tests/command/restore_test.rs @@ -2,6 +2,12 @@ //! //! **Layer:** L1 — deterministic, no external dependencies. +use libra::{ + internal::{db::get_db_conn_instance, head::Head, model::reference}, + utils::test::ChangeDirGuard, +}; +use sea_orm::{ActiveModelTrait, Set}; + use super::*; #[test] @@ -16,3 +22,48 @@ fn test_restore_cli_outside_repository_returns_fatal_128() { "unexpected stderr: {stderr}" ); } + +#[tokio::test] +#[serial] +async fn test_restore_source_does_not_fall_back_from_unborn_branch_to_hash_prefix() { + let repo = create_committed_repo_via_cli(); + let _guard = ChangeDirGuard::new(repo.path()); + + let head_commit = Head::current_commit() + .await + .expect("expected committed repository"); + let branch_name = head_commit.to_string()[..7].to_string(); + + let db = get_db_conn_instance().await; + reference::ActiveModel { + name: Set(Some(branch_name.clone())), + kind: Set(reference::ConfigKind::Branch), + commit: Set(None), + remote: Set(None), + ..Default::default() + } + .insert(&db) + .await + .expect("failed to insert unborn branch row"); + + std::fs::write(repo.path().join("tracked.txt"), "modified\n") + .expect("failed to modify tracked file"); + + let output = run_libra_command( + &["restore", "--source", &branch_name, "tracked.txt"], + repo.path(), + ); + assert!( + !output.status.success(), + "restore unexpectedly succeeded: stdout={}, stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let content = std::fs::read_to_string(repo.path().join("tracked.txt")) + .expect("failed to read tracked file"); + assert_eq!( + content, "modified\n", + "restore should not overwrite from hash fallback" + ); +} From a59d0bbe7832fd7cfe69ae6fe974cf549a8afc63 Mon Sep 17 00:00:00 2001 From: Quanyi Ma Date: Fri, 3 Apr 2026 01:23:13 +0800 Subject: [PATCH 14/14] fix(head-util): harden head and commit-base resolution Signed-off-by: Quanyi Ma --- src/internal/head.rs | 121 +++++++++++++++++++++++++++++++++- src/utils/util.rs | 130 ++++++++++++++++++++++++++++--------- tests/local_client_test.rs | 2 +- 3 files changed, 217 insertions(+), 36 deletions(-) diff --git a/src/internal/head.rs b/src/internal/head.rs index f4dd8a2e..512a37c1 100644 --- a/src/internal/head.rs +++ b/src/internal/head.rs @@ -53,6 +53,41 @@ impl Head { message.contains("database is locked") || message.contains("database schema is locked") } + async fn query_local_head_result_with_conn( + db: &C, + ) -> Result + where + C: ConnectionTrait, + { + for attempt in 0..=Self::SQLITE_BUSY_MAX_RETRIES { + match reference::Entity::find() + .filter(reference::Column::Kind.eq(reference::ConfigKind::Head)) + .filter(reference::Column::Remote.is_null()) + .one(db) + .await + { + Ok(Some(model)) => return Ok(model), + Ok(None) => { + return Err(BranchStoreError::Corrupt { + name: "HEAD".to_string(), + detail: "HEAD reference is missing from storage".to_string(), + }); + } + Err(err) + if Self::is_sqlite_busy(&err) && attempt < Self::SQLITE_BUSY_MAX_RETRIES => + { + sleep(Duration::from_millis( + Self::SQLITE_BUSY_RETRY_BASE_MS * (attempt as u64 + 1), + )) + .await; + } + Err(err) => return Err(BranchStoreError::Query(err.to_string())), + } + } + + unreachable!("sqlite retry loop must return") + } + async fn query_local_head_with_conn(db: &C) -> reference::Model where C: ConnectionTrait, @@ -161,11 +196,24 @@ impl Head { where C: ConnectionTrait, { - match Self::current_with_conn(db).await { - Head::Detached(commit_hash) => Ok(Some(commit_hash)), - Head::Branch(name) => Ok(Branch::find_branch_result_with_conn(db, &name, None) + let head = Self::query_local_head_result_with_conn(db).await?; + match head.name { + Some(name) => Ok(Branch::find_branch_result_with_conn(db, &name, None) .await? .map(|branch| branch.commit)), + None => { + let commit_hash = head.commit.ok_or_else(|| BranchStoreError::Corrupt { + name: "HEAD".to_string(), + detail: "detached HEAD is missing commit hash".to_string(), + })?; + let commit_hash = ObjectHash::from_str(commit_hash.as_str()).map_err(|error| { + BranchStoreError::Corrupt { + name: "HEAD".to_string(), + detail: format!("invalid detached HEAD commit hash: {error}"), + } + })?; + Ok(Some(commit_hash)) + } } } @@ -275,3 +323,70 @@ impl Head { Self::update_with_conn(&db_conn, new_head, remote).await; } } + +#[cfg(test)] +mod tests { + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + use serial_test::serial; + use tempfile::tempdir; + + use super::*; + use crate::utils::test::{self, ChangeDirGuard}; + + #[tokio::test] + #[serial] + async fn current_commit_result_with_conn_returns_corrupt_when_head_row_missing() { + let repo = tempdir().unwrap(); + test::setup_with_new_libra_in(repo.path()).await; + let _guard = ChangeDirGuard::new(repo.path()); + + let db = get_db_conn_instance().await; + reference::Entity::delete_many() + .filter(reference::Column::Kind.eq(reference::ConfigKind::Head)) + .filter(reference::Column::Remote.is_null()) + .exec(&db) + .await + .unwrap(); + + let error = Head::current_commit_result_with_conn(&db) + .await + .expect_err("missing HEAD row should surface as corruption"); + assert!(matches!(error, BranchStoreError::Corrupt { .. })); + assert!( + error.to_string().contains("HEAD reference is missing"), + "unexpected error: {error}" + ); + } + + #[tokio::test] + #[serial] + async fn current_commit_result_with_conn_returns_corrupt_for_invalid_detached_hash() { + let repo = tempdir().unwrap(); + test::setup_with_new_libra_in(repo.path()).await; + let _guard = ChangeDirGuard::new(repo.path()); + + let db = get_db_conn_instance().await; + let head = reference::Entity::find() + .filter(reference::Column::Kind.eq(reference::ConfigKind::Head)) + .filter(reference::Column::Remote.is_null()) + .one(&db) + .await + .unwrap() + .expect("expected HEAD row"); + let mut head: reference::ActiveModel = head.into(); + head.name = Set(None); + head.commit = Set(Some("not-a-valid-hash".to_string())); + head.update(&db).await.unwrap(); + + let error = Head::current_commit_result_with_conn(&db) + .await + .expect_err("invalid detached HEAD hash should surface as corruption"); + assert!(matches!(error, BranchStoreError::Corrupt { .. })); + assert!( + error + .to_string() + .contains("invalid detached HEAD commit hash"), + "unexpected error: {error}" + ); + } +} diff --git a/src/utils/util.rs b/src/utils/util.rs index a6eecb14..5e4d176d 100644 --- a/src/utils/util.rs +++ b/src/utils/util.rs @@ -500,6 +500,31 @@ impl CommitBaseError { } } +async fn resolve_branch_commit_typed( + branch_name: &str, + remote: Option<&str>, + display_name: &str, +) -> Result, CommitBaseError> { + let context = match remote { + Some(remote_name) => { + format!("failed to resolve branch '{display_name}' on remote '{remote_name}'") + } + None => format!("failed to resolve branch '{display_name}'"), + }; + + match Branch::find_branch_result(branch_name, remote).await { + Ok(Some(branch)) => Ok(Some(branch.commit)), + Ok(None) => match Branch::exists_result(branch_name, remote).await { + Ok(true) => Err(CommitBaseError::InvalidReference(format!( + "branch '{display_name}' does not point to a commit" + ))), + Ok(false) => Ok(None), + Err(error) => Err(CommitBaseError::from_branch_store_error(context, error)), + }, + Err(error) => Err(CommitBaseError::from_branch_store_error(context, error)), + } +} + pub async fn get_commit_base_typed(name: &str) -> Result { // 1. Check for HEAD if name.eq_ignore_ascii_case("HEAD") { @@ -514,15 +539,8 @@ pub async fn get_commit_base_typed(name: &str) -> Result return Ok(branch.commit), - Ok(None) => {} - Err(error) => { - return Err(CommitBaseError::from_branch_store_error( - format!("failed to resolve branch '{name}'"), - error, - )); - } + if let Some(commit) = resolve_branch_commit_typed(name, None, name).await? { + return Ok(commit); } // Support both short remote branches (`main` with `remote = origin`) and @@ -532,34 +550,18 @@ pub async fn get_commit_base_typed(name: &str) -> Result return Ok(branch.commit), - Ok(None) => {} - Err(error) => { - return Err(CommitBaseError::from_branch_store_error( - remote_lookup_context.clone(), - error, - )); - } + return Ok(commit); } - match Branch::find_branch_result(branch_name, Some(remote)).await { - Ok(Some(branch)) => return Ok(branch.commit), - Ok(None) => {} - Err(error) => { - return Err(CommitBaseError::from_branch_store_error( - remote_lookup_context, - error, - )); - } + if let Some(commit) = resolve_branch_commit_typed(branch_name, Some(remote), name).await? { + return Ok(commit); } } @@ -784,11 +786,19 @@ pub fn get_min_unique_hash_length(commits: &[Commit]) -> usize { mod test { use std::{env, path::PathBuf}; + use sea_orm::{ActiveModelTrait, Set}; use serial_test::serial; use tempfile::tempdir; use super::*; - use crate::utils::test; + use crate::{ + command::{ + add::{self, AddArgs}, + commit::{self, CommitArgs}, + }, + internal::{db::get_db_conn_instance, head::Head, model::reference}, + utils::test, + }; #[test] ///Test get current directory success. @@ -840,6 +850,62 @@ mod test { assert_eq!(to_relative(".", "src"), PathBuf::from("..")); } + #[tokio::test] + #[serial] + async fn get_commit_base_typed_rejects_unborn_branch_before_hash_fallback() { + let repo = tempdir().unwrap(); + test::setup_with_new_libra_in(repo.path()).await; + let _guard = test::ChangeDirGuard::new(repo.path()); + + test::ensure_file("tracked.txt", Some("tracked\n")); + add::execute(AddArgs { + pathspec: vec!["tracked.txt".into()], + all: false, + update: false, + refresh: false, + verbose: false, + force: false, + dry_run: false, + ignore_errors: false, + }) + .await; + commit::execute(CommitArgs { + message: Some("base".into()), + disable_pre: true, + no_verify: true, + ..Default::default() + }) + .await; + + let head_commit = Head::current_commit() + .await + .expect("expected committed HEAD"); + let branch_name = head_commit.to_string()[..7].to_string(); + + let db = get_db_conn_instance().await; + reference::ActiveModel { + name: Set(Some(branch_name.clone())), + kind: Set(reference::ConfigKind::Branch), + commit: Set(None), + remote: Set(None), + ..Default::default() + } + .insert(&db) + .await + .expect("failed to insert unborn branch"); + + let error = get_commit_base_typed(&branch_name) + .await + .expect_err("unborn branch must not fall back to hash prefix resolution"); + assert!(matches!(error, CommitBaseError::InvalidReference(_))); + assert!( + error.to_string().contains(&format!( + "branch '{branch_name}' does not point to a commit" + )), + "unexpected error: {error}" + ); + } + #[tokio::test] #[serial] ///Test the function of to_workdir_path. diff --git a/tests/local_client_test.rs b/tests/local_client_test.rs index 0e63af7b..3b0db75c 100644 --- a/tests/local_client_test.rs +++ b/tests/local_client_test.rs @@ -31,7 +31,7 @@ async fn discovery_reference_restores_current_dir_after_error() { .unwrap(); } - env::set_current_dir(caller_dir.path()).unwrap(); + let _caller_guard = ChangeDirGuard::new(caller_dir.path()); let original_dir = env::current_dir().unwrap(); let client = LocalClient::from_path(repo_dir.path()).unwrap();